Skip to content

Commit 127ec5a

Browse files
fix(inference): collapse trivial shell churn in doom-loop signature (#1109)
* fix(inference): collapse trivial shell churn in doom-loop signature * fix(inference): keep trivial shell identity off the command string A legal command string as the no-op marker collided with a real run_shell call, and basename matching collapsed relative-path echo and true scripts into the same loop.
1 parent dfeac6d commit 127ec5a

3 files changed

Lines changed: 317 additions & 1 deletion

File tree

‎vendor/intx-inference/PATCHES.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,31 @@ opens a fresh run. Upstream's fatal exit wedges a long-lived session —
425425
upstream; workers keep it. **Disposition:** Re-carryable — sits at the
426426
doom-loop fatal break in the main loop.
427427

428+
## reactor-ts-doom-loop-trivial-shell-churn
429+
430+
`reactor.ts` — Doom-loop batch signature normalizes trivial `run_shell`
431+
no-ops. `toolBatchSignature` maps each call through `canonicalCallIdentity`:
432+
a bare `echo`/`true` (or `/bin`/`/usr/bin` of those) with no shell
433+
metacharacters (chaining, piping, redirection, substitution, grouping,
434+
comments, newlines) collapses to `{ name: "run_shell", trivialNoopShell: true }`,
435+
so `echo churn-1`, `echo churn-2`, and `true whatever` count as the same
436+
consecutive loop instead of evading the guard via fresh payload strings.
437+
The marker is disjoint from any legal `(name, arguments)` pair — it is
438+
not a fake command string — so a real `trivial-noop-shell` invocation
439+
cannot trip with echo churn. Relative paths (`./true`, `scripts/echo`)
440+
are not the closed set and keep their exact signatures. Extra fields
441+
(`cwd`, `timeout`) drop from the marker and collapse among no-ops only.
442+
Anything that may do real work (`echo hi > file`, `npm test`, distinct
443+
commands) keeps its exact `(name, arguments)` signature and never
444+
collapses.
445+
446+
**Disposition:** Promotion candidate. Guard-evasion fix — upstream's exact
447+
`(name, arguments)` identity lets a runaway model loop forever on
448+
ever-varying no-op shells. **Removal path:** Upstream PR normalizing
449+
trivial-shell signatures in the doom-loop accounting.
450+
**Re-carry:** sits on `toolBatchSignature`; re-verify the normalization
451+
after upstream changes to batch signatures or repeat counting.
452+
428453
## sse-ts-max-line-length
429454

430455
`sse.ts` — `MAX_LINE_LENGTH` (16 MiB) caps the unterminated SSE line buffer

‎vendor/intx-inference/src/reactor.test.ts‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,239 @@ describe("createReactor — doom-loop detection", () => {
13991399
expect(getEvent(events, "message.run.ended").data.status).toBe("completed");
14001400
});
14011401

1402+
test("trips on trivial run_shell churn with fresh echo strings", async () => {
1403+
// A runaway model evading the guard by varying a no-op payload: every
1404+
// turn runs `echo` with a brand-new string, so argument-level identity
1405+
// never repeats. Each turn is still the same trivial shell no-op, so the
1406+
// default threshold of 3 must trip.
1407+
const { reactor, events, waitFor } = createTestReactor({
1408+
director: createBatchLoopDirector((turn) =>
1409+
turn < 8
1410+
? [
1411+
{
1412+
id: `c${turn}`,
1413+
name: "run_shell",
1414+
arguments: { command: `echo churn-${turn}` },
1415+
},
1416+
]
1417+
: null,
1418+
),
1419+
});
1420+
1421+
reactor.start();
1422+
reactor.deliver(makeInboundMessage());
1423+
await waitFor("reactor.done");
1424+
1425+
const error = getEvent(events, "reactor.error");
1426+
expect(error.data.fatal).toBe(true);
1427+
expect(error.data.error).toContain("run_shell");
1428+
expect(getEvent(events, "message.run.ended").data.error?.kind).toBe(
1429+
"doom_loop",
1430+
);
1431+
expect(events.filter((e) => e.type === "tool.start").length).toBe(3);
1432+
});
1433+
1434+
test("collapses echo churn even when cwd and timeout differ", async () => {
1435+
// Extra fields drop from the no-op marker so they collapse among no-ops,
1436+
// but the marker has no `arguments` key so it cannot share identity with
1437+
// a real run_shell call that happens to carry the same cwd/timeout.
1438+
const { reactor, events, waitFor } = createTestReactor({
1439+
director: createBatchLoopDirector((turn) =>
1440+
turn < 8
1441+
? [
1442+
{
1443+
id: `c${turn}`,
1444+
name: "run_shell",
1445+
arguments: {
1446+
command: `echo churn-${turn}`,
1447+
cwd: `/tmp/cwd-${turn}`,
1448+
timeout: 1000 + turn,
1449+
},
1450+
},
1451+
]
1452+
: null,
1453+
),
1454+
});
1455+
1456+
reactor.start();
1457+
reactor.deliver(makeInboundMessage());
1458+
await waitFor("reactor.done");
1459+
1460+
expect(getEvent(events, "reactor.error").data.fatal).toBe(true);
1461+
expect(getEvent(events, "message.run.ended").data.error?.kind).toBe(
1462+
"doom_loop",
1463+
);
1464+
expect(events.filter((e) => e.type === "tool.start").length).toBe(3);
1465+
});
1466+
1467+
test("trips on mixed echo/true trivial churn", async () => {
1468+
// Alternating between the two no-op programs with fresh arguments must
1469+
// count as the same loop, not as distinct work.
1470+
const commands = [
1471+
"echo alpha",
1472+
"true alpha",
1473+
"echo bravo",
1474+
"true bravo",
1475+
"echo charlie",
1476+
"true charlie",
1477+
"echo delta",
1478+
"true delta",
1479+
];
1480+
const { reactor, events, waitFor } = createTestReactor({
1481+
director: createBatchLoopDirector((turn) => {
1482+
const command = commands[turn];
1483+
return command === undefined
1484+
? null
1485+
: [{ id: `c${turn}`, name: "run_shell", arguments: { command } }];
1486+
}),
1487+
});
1488+
1489+
reactor.start();
1490+
reactor.deliver(makeInboundMessage());
1491+
await waitFor("reactor.done");
1492+
1493+
expect(getEvent(events, "reactor.error").data.fatal).toBe(true);
1494+
expect(getEvent(events, "message.run.ended").data.error?.kind).toBe(
1495+
"doom_loop",
1496+
);
1497+
expect(events.filter((e) => e.type === "tool.start").length).toBe(3);
1498+
});
1499+
1500+
test("does not trip on distinct legitimate shell work", async () => {
1501+
// Different real commands are different turns: a changing command string
1502+
// here is genuine work, not no-op churn, so five distinct commands must
1503+
// run to completion.
1504+
const commands = [
1505+
"git status",
1506+
"npm test",
1507+
"git diff --stat",
1508+
"npm run build",
1509+
"git log --oneline -3",
1510+
];
1511+
const { reactor, events, waitFor } = createTestReactor({
1512+
director: createBatchLoopDirector((turn) => {
1513+
const command = commands[turn];
1514+
return command === undefined
1515+
? null
1516+
: [{ id: `c${turn}`, name: "run_shell", arguments: { command } }];
1517+
}),
1518+
});
1519+
1520+
reactor.start();
1521+
reactor.deliver(makeInboundMessage());
1522+
await waitFor("reactor.done");
1523+
1524+
expect(events.some((e) => e.type === "reactor.error")).toBe(false);
1525+
expect(getEvent(events, "message.run.ended").data.status).toBe("completed");
1526+
expect(events.filter((e) => e.type === "tool.start").length).toBe(5);
1527+
});
1528+
1529+
test("does not collapse echo with redirection into trivial churn", async () => {
1530+
// `echo payload > file` writes a file: a side effect, not a no-op. Fresh
1531+
// redirect targets must keep distinct signatures so real file writes are
1532+
// never mistaken for churn.
1533+
const { reactor, events, waitFor } = createTestReactor({
1534+
director: createBatchLoopDirector((turn) =>
1535+
turn < 5
1536+
? [
1537+
{
1538+
id: `c${turn}`,
1539+
name: "run_shell",
1540+
arguments: { command: `echo payload-${turn} > out-${turn}.txt` },
1541+
},
1542+
]
1543+
: null,
1544+
),
1545+
});
1546+
1547+
reactor.start();
1548+
reactor.deliver(makeInboundMessage());
1549+
await waitFor("reactor.done");
1550+
1551+
expect(events.some((e) => e.type === "reactor.error")).toBe(false);
1552+
expect(getEvent(events, "message.run.ended").data.status).toBe("completed");
1553+
expect(events.filter((e) => e.type === "tool.start").length).toBe(5);
1554+
});
1555+
1556+
test("does not collapse a real trivial-noop-shell command into echo churn", async () => {
1557+
// The no-op marker must be disjoint from any legal command string. If it
1558+
// reused `trivial-noop-shell` as a command, two echo no-ops plus one real
1559+
// `trivial-noop-shell` would trip the default threshold of 3.
1560+
const commands = [
1561+
"echo a",
1562+
"echo b",
1563+
"trivial-noop-shell",
1564+
"echo c",
1565+
"echo d",
1566+
];
1567+
const { reactor, events, waitFor } = createTestReactor({
1568+
director: createBatchLoopDirector((turn) => {
1569+
const command = commands[turn];
1570+
return command === undefined
1571+
? null
1572+
: [{ id: `c${turn}`, name: "run_shell", arguments: { command } }];
1573+
}),
1574+
});
1575+
1576+
reactor.start();
1577+
reactor.deliver(makeInboundMessage());
1578+
await waitFor("reactor.done");
1579+
1580+
expect(events.some((e) => e.type === "reactor.error")).toBe(false);
1581+
expect(getEvent(events, "message.run.ended").data.status).toBe("completed");
1582+
expect(events.filter((e) => e.type === "tool.start").length).toBe(5);
1583+
});
1584+
1585+
test("does not collapse relative-path true into trivial churn", async () => {
1586+
// Exact program tokens only: `./true` is not `true`. Basename matching
1587+
// would collapse these five payloads and trip at the default threshold.
1588+
const { reactor, events, waitFor } = createTestReactor({
1589+
director: createBatchLoopDirector((turn) =>
1590+
turn < 5
1591+
? [
1592+
{
1593+
id: `c${turn}`,
1594+
name: "run_shell",
1595+
arguments: { command: `./true --fix-${turn}` },
1596+
},
1597+
]
1598+
: null,
1599+
),
1600+
});
1601+
1602+
reactor.start();
1603+
reactor.deliver(makeInboundMessage());
1604+
await waitFor("reactor.done");
1605+
1606+
expect(events.some((e) => e.type === "reactor.error")).toBe(false);
1607+
expect(getEvent(events, "message.run.ended").data.status).toBe("completed");
1608+
expect(events.filter((e) => e.type === "tool.start").length).toBe(5);
1609+
});
1610+
1611+
test("does not collapse relative-path echo into trivial churn", async () => {
1612+
const { reactor, events, waitFor } = createTestReactor({
1613+
director: createBatchLoopDirector((turn) =>
1614+
turn < 5
1615+
? [
1616+
{
1617+
id: `c${turn}`,
1618+
name: "run_shell",
1619+
arguments: { command: `scripts/echo payload-${turn}` },
1620+
},
1621+
]
1622+
: null,
1623+
),
1624+
});
1625+
1626+
reactor.start();
1627+
reactor.deliver(makeInboundMessage());
1628+
await waitFor("reactor.done");
1629+
1630+
expect(events.some((e) => e.type === "reactor.error")).toBe(false);
1631+
expect(getEvent(events, "message.run.ended").data.status).toBe("completed");
1632+
expect(events.filter((e) => e.type === "tool.start").length).toBe(5);
1633+
});
1634+
14021635
test("treats key-reordered arguments as identical", async () => {
14031636
// Same logical arguments, keys emitted in different orders. Canonical
14041637
// serialization must collapse them to one signature, so three such turns

‎vendor/intx-inference/src/reactor.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,16 +215,74 @@ function resolveDoomLoopThreshold(
215215
* differs on every request); sorting makes a parallel batch match regardless
216216
* of the order the model emitted its calls. Two turns share a signature when
217217
* they run the same multiset of `(name, arguments)` pairs.
218+
*
219+
* Trivial `run_shell` no-ops (`echo`/`true` with fresh strings) normalize to
220+
* one shared identity — see `isTrivialNoopShellCommand`. Without that, a
221+
* runaway model evades the guard by varying a payload that does no work.
222+
* The marker is `{ name: "run_shell", trivialNoopShell: true }`, disjoint
223+
* from any legal `(name, arguments)` pair, so a real command string cannot
224+
* collide with the collapsed identity.
225+
*
226+
* Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-trivial-shell-churn
218227
*/
219228
function toolBatchSignature(calls: ToolCall[]): string {
220229
return calls
221230
.map((call) =>
222-
canonicalJsonStringify({ name: call.name, arguments: call.arguments }),
231+
canonicalJsonStringify(canonicalCallIdentity(call)),
223232
)
224233
.sort()
225234
.join("\n");
226235
}
227236

237+
/**
238+
* Shared signature identity for one tool call. Everything but a trivial
239+
* `run_shell` no-op keeps its exact `(name, arguments)` pair; a trivial
240+
* no-op collapses to a constant marker so `echo churn-1`, `echo churn-2`,
241+
* and `true whatever` all count as the same loop. Extra fields such as
242+
* `cwd`/`timeout` ride the marker (dropped) so they collapse among no-ops
243+
* only — they cannot share identity with a real call, which still carries
244+
* `arguments`.
245+
*/
246+
function canonicalCallIdentity(call: ToolCall): unknown {
247+
if (
248+
call.name === "run_shell" &&
249+
isTrivialNoopShellCommand(call.arguments["command"])
250+
) {
251+
return { name: "run_shell", trivialNoopShell: true };
252+
}
253+
return { name: call.name, arguments: call.arguments };
254+
}
255+
256+
const TRIVIAL_NOOP_SHELL_PROGRAMS = new Set([
257+
"echo",
258+
"true",
259+
"/bin/echo",
260+
"/usr/bin/echo",
261+
"/bin/true",
262+
"/usr/bin/true",
263+
]);
264+
265+
/**
266+
* Whether a `run_shell` command value is a side-effect-free no-op whose
267+
* arguments are churn, not work: a bare `echo`/`true` (or their `/bin` /
268+
* `/usr/bin` paths) with no chaining, redirection, substitution, or comment
269+
* operators. Relative paths (`./true`, `scripts/echo`) are not the closed
270+
* set — they may be real scripts. Anything with shell metacharacters
271+
* (`echo hi > file`, `echo a && test`) may do real work, so it keeps its
272+
* exact signature and never collapses.
273+
*/
274+
function isTrivialNoopShellCommand(command: unknown): boolean {
275+
if (typeof command !== "string") return false;
276+
const trimmed = command.trim();
277+
if (trimmed.length === 0) return false;
278+
// Chaining, piping, redirection, substitution, grouping, comments, and
279+
// newlines all mark a command that can do more than print-and-exit.
280+
if (/[;&|><`$(){}#\n\r]/.test(trimmed)) return false;
281+
const firstToken = trimmed.split(/\s+/, 1)[0] ?? "";
282+
const unquoted = firstToken.replace(/^["']|["']$/g, "");
283+
return TRIVIAL_NOOP_SHELL_PROGRAMS.has(unquoted);
284+
}
285+
228286
/**
229287
* Creates a reactor instance bound to the given configuration.
230288
* Call `start()` to begin the event loop.

0 commit comments

Comments
 (0)