Skip to content

Commit 2893f2e

Browse files
committed
feat(agent): gate optional interchange loop behind flag
1 parent 081c161 commit 2893f2e

3 files changed

Lines changed: 134 additions & 0 deletions

File tree

‎src/agent/interchange-loop.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
INTERCHANGE_LOOP_ENV_VAR,
4+
isInterchangeLoopEnabled,
5+
resolveInterchangeLoopRunner,
6+
} from "./interchange-loop.js";
7+
8+
function envWith(value: string | undefined): NodeJS.ProcessEnv {
9+
const env: NodeJS.ProcessEnv = {};
10+
if (value !== undefined) env[INTERCHANGE_LOOP_ENV_VAR] = value;
11+
return env;
12+
}
13+
14+
describe("isInterchangeLoopEnabled", () => {
15+
test("is disabled by default", () => {
16+
expect(isInterchangeLoopEnabled(envWith(undefined))).toBe(false);
17+
});
18+
19+
test.each(["1", "true", "TRUE", " True "])("is enabled for %j", (value) => {
20+
expect(isInterchangeLoopEnabled(envWith(value))).toBe(true);
21+
});
22+
23+
test.each(["0", "false", "yes", ""])("stays disabled for %j", (value) => {
24+
expect(isInterchangeLoopEnabled(envWith(value))).toBe(false);
25+
});
26+
27+
test("never reads process.env when an env is passed", () => {
28+
const prior = process.env.CORBITS_INTERCHANGE_LOOP;
29+
process.env.CORBITS_INTERCHANGE_LOOP = "1";
30+
try {
31+
expect(isInterchangeLoopEnabled(envWith(undefined))).toBe(false);
32+
} finally {
33+
if (prior === undefined) delete process.env.CORBITS_INTERCHANGE_LOOP;
34+
else process.env.CORBITS_INTERCHANGE_LOOP = prior;
35+
}
36+
});
37+
});
38+
39+
describe("resolveInterchangeLoopRunner", () => {
40+
test("never throws and reports unavailable without the workflow package", async () => {
41+
// `@intx/workflow` is not installed in this tree (it arrives with the
42+
// 0.4.0 hub), so the seam must fall back instead of rejecting.
43+
const status = await resolveInterchangeLoopRunner();
44+
expect(status.status).toBe("unavailable");
45+
if (status.status === "unavailable") {
46+
expect(status.reason).toContain("@intx/workflow");
47+
}
48+
});
49+
});

‎src/agent/interchange-loop.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Optional Interchange-driven core loop (CL-8267).
3+
*
4+
* The reactor (`@intx/agent` + `ChatDirector`) remains the only loop driver.
5+
* This module is the opt-in seam for driving it through Interchange's
6+
* `runWorkflow`/`runLocal` instead, without waiting on the full 0.4.0 hub.
7+
*
8+
* Gating: enabled only when `CORBITS_INTERCHANGE_LOOP` is `1` or `true`.
9+
* Resolution is lazy and total — `@intx/workflow` is not a dependency of this
10+
* package yet, so when it cannot be loaded the caller keeps the reactor path.
11+
* Nothing here changes behavior unless the flag is set.
12+
*/
13+
14+
export const INTERCHANGE_LOOP_ENV_VAR = "CORBITS_INTERCHANGE_LOOP";
15+
16+
const INTERCHANGE_WORKFLOW_SPECIFIER = "@intx/workflow";
17+
18+
export type InterchangeLoopStatus =
19+
| { status: "driven"; via: "runWorkflow" | "runLocal" }
20+
| { status: "unavailable"; reason: string };
21+
22+
/** True only when the operator explicitly opts into the Interchange loop. */
23+
export function isInterchangeLoopEnabled(
24+
env: NodeJS.ProcessEnv = process.env,
25+
): boolean {
26+
const raw = env[INTERCHANGE_LOOP_ENV_VAR]?.trim().toLowerCase();
27+
return raw === "1" || raw === "true";
28+
}
29+
30+
type WorkflowModule = Record<string, unknown>;
31+
32+
function pickDriver(loaded: WorkflowModule): InterchangeLoopStatus {
33+
// Prefer the hub-backed entry point; the in-memory runner is the local
34+
// fallback. Both names come from upstream `@intx/workflow`.
35+
if (typeof loaded["runWorkflow"] === "function")
36+
return { status: "driven", via: "runWorkflow" };
37+
if (typeof loaded["runLocal"] === "function")
38+
return { status: "driven", via: "runLocal" };
39+
return {
40+
status: "unavailable",
41+
reason: `${INTERCHANGE_WORKFLOW_SPECIFIER} loaded but exports neither runWorkflow nor runLocal`,
42+
};
43+
}
44+
45+
/**
46+
* Resolve the Interchange loop driver. Never throws and never imports
47+
* statically — the package is an optional peer, absent until the workflow
48+
* runtime lands. Callers fall through to the reactor on `unavailable`.
49+
*/
50+
export async function resolveInterchangeLoopRunner(): Promise<InterchangeLoopStatus> {
51+
let loaded: unknown;
52+
try {
53+
loaded = (await import(
54+
INTERCHANGE_WORKFLOW_SPECIFIER
55+
)) as unknown as WorkflowModule;
56+
} catch (err: unknown) {
57+
const detail = err instanceof Error ? err.message : String(err);
58+
return {
59+
status: "unavailable",
60+
reason: `${INTERCHANGE_WORKFLOW_SPECIFIER} is not installed (${detail})`,
61+
};
62+
}
63+
if (typeof loaded !== "object" || loaded === null)
64+
return {
65+
status: "unavailable",
66+
reason: `${INTERCHANGE_WORKFLOW_SPECIFIER} did not load as a module`,
67+
};
68+
return pickDriver(loaded as unknown as WorkflowModule);
69+
}

‎src/exec/runner.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import { formatMcpTrustQuestion } from "../trust/project-trust.js";
2020
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
2121
import type { DirectorId, DirectorPackage } from "../agent/directors/types.js";
2222
import { submitOutputDefinition } from "../agent/director.js";
23+
import {
24+
isInterchangeLoopEnabled,
25+
resolveInterchangeLoopRunner,
26+
} from "../agent/interchange-loop.js";
2327
import { handleChatDirectorEvent } from "../agent/chat-event-subscribers.js";
2428
import {
2529
CodexRefreshLockError,
@@ -469,6 +473,18 @@ export async function runExec(config: Config): Promise<ExecResult> {
469473
const sessionId =
470474
config.sessionId.length > 0 ? config.sessionId : generateSessionId();
471475
const startedAt = Date.now();
476+
// CL-8267: optional Interchange loop seam. Off by default; when the
477+
// operator opts in but the workflow runtime is absent, stay on the
478+
// reactor path with a warning instead of failing the run.
479+
if (isInterchangeLoopEnabled()) {
480+
const loop = await resolveInterchangeLoopRunner();
481+
if (loop.status === "unavailable") {
482+
logger.warn(
483+
"Interchange loop requested but {reason}; continuing on reactor path",
484+
{ reason: loop.reason },
485+
);
486+
}
487+
}
472488
const workdir = sessionContextDir(config.cwd, sessionId);
473489
// Setup runs before the main try below: initSessionDir/loadState hit disk
474490
// before any session_end coverage exists, so an EACCES/EROFS here would

0 commit comments

Comments
 (0)