diff --git a/.gitignore b/.gitignore index 8187eebb8..046e458b7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,14 @@ subagents/ # Working notes and design spikes — local only, never committed docs/plans/ +# Fleet/harness working notes — local only, never committed +plans/ + +# Benchmark/eval scratch — local only, never committed +tb-dataset/ +tb-rerun-50/ +terminal-bench/ +evals/**/__pycache__/ # Scratch repro tests — never committed __repro/ diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index 9a28ad486..ee4b8c58b 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -19,6 +19,7 @@ import { GROK_RESPONSES_PROVIDER } from "./grok-responses.js"; import { withAnthropicCacheBreakpoint } from "./anthropic-cache-breakpoint.js"; import { withReplaySanitizer } from "./replay-sanitizer.js"; import { isPollOnlyPendingBatch } from "../subagent/poll-exempt.js"; +import { isIdempotentTaskBatch } from "../subagent/idempotent-task-exempt.js"; import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js"; import { BIFROST_PROVIDER } from "./bifrost-adapter.js"; import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses.js"; @@ -101,7 +102,13 @@ export function createInferenceDependencies(): Promise { .then((deps) => ({ ...deps, fetch: withCodexContentTypeRepair(deps.fetch), - isPollOnlyPendingBatch, + // Compose the first-party doom-loop exemptions into the single liveness + // hook the vendored reactor already consumes: still-pending polls and + // idempotent task bookkeeping both reset the repeat streak instead of + // counting toward the fail-run threshold. + isPollOnlyPendingBatch: (calls, results) => + isPollOnlyPendingBatch(calls, results) || + isIdempotentTaskBatch(calls, results), })); } return cached; diff --git a/src/subagent/idempotent-task-exempt.test.ts b/src/subagent/idempotent-task-exempt.test.ts new file mode 100644 index 000000000..947cb82e4 --- /dev/null +++ b/src/subagent/idempotent-task-exempt.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import type { ToolCall, ToolResult } from "@intx/types/runtime"; +import { isIdempotentTaskBatch } from "./idempotent-task-exempt.js"; + +function call(name: string, id = "c1"): ToolCall { + return { id, name, arguments: {} }; +} + +function taskResult(): ToolResult { + return { callId: "c1", content: "{}" }; +} + +describe("isIdempotentTaskBatch", () => { + test("manage_tasks batch is exempt", () => { + expect(isIdempotentTaskBatch([call("manage_tasks")], [taskResult()])).toBe( + true, + ); + }); + + test("todowrite alias is exempt", () => { + expect(isIdempotentTaskBatch([call("todowrite")], [taskResult()])).toBe( + true, + ); + }); + + test("update_plan hidden alias is exempt", () => { + expect(isIdempotentTaskBatch([call("update_plan")], [taskResult()])).toBe( + true, + ); + }); + + test("repeated task-only batches stay exempt", () => { + expect( + isIdempotentTaskBatch( + [call("todowrite", "c1"), call("todowrite", "c2")], + [taskResult(), { callId: "c2", content: "{}" }], + ), + ).toBe(true); + }); + + test("a real tool in the batch is never exempt", () => { + expect( + isIdempotentTaskBatch( + [call("todowrite"), call("run_shell")], + [taskResult(), { callId: "c2", content: "{}" }], + ), + ).toBe(false); + }); + + test("non-task tools are never exempt", () => { + expect(isIdempotentTaskBatch([call("run_shell")], [taskResult()])).toBe( + false, + ); + }); + + test("empty batches are never exempt", () => { + expect(isIdempotentTaskBatch([], [])).toBe(false); + }); +}); diff --git a/src/subagent/idempotent-task-exempt.ts b/src/subagent/idempotent-task-exempt.ts new file mode 100644 index 000000000..3a2d7ba1d --- /dev/null +++ b/src/subagent/idempotent-task-exempt.ts @@ -0,0 +1,23 @@ +import { isSameTool } from "../agent/canonical-tool-name.js"; +import type { ToolCall, ToolResult } from "@intx/types/runtime"; + +/** + * Doom-loop exemption for idempotent task bookkeeping. A batch is exempt only + * when it is non-empty and every executed call is a task-bookkeeping tool + * (`manage_tasks` — `isSameTool` folds the `todowrite` and `update_plan` + * aliases onto that engine id). + * + * Repeating an identical task update is legitimate bookkeeping, not a runaway + * loop: "finalize checklist" three times changes nothing and should not + * abort an otherwise-finished run under the `fail-run` policy. This resets + * the doom-loop streak (same mechanism as the poll exemption) so consecutive + * task-only batches never accumulate toward the threshold, while any real + * tool in the batch counts normally and always preserves the guard. + */ +export function isIdempotentTaskBatch( + calls: readonly ToolCall[], + _results: readonly ToolResult[], +): boolean { + if (calls.length === 0) return false; + return calls.every((call) => isSameTool(call.name, "manage_tasks")); +}