@bridge_gpt/mcp-server 0.2.60 → 0.2.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -11
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +8 -7
- package/build/conductor/bridge-api-client.js +678 -14
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +104 -20
- package/build/conductor/readiness.js +129 -25
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +194 -0
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +161 -7
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1318 -111
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +464 -0
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +48 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +19 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +117 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1386 -35
- package/build/executor/job-types.js +10 -2
- package/build/executor/observation.js +114 -7
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +246 -1
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +55 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +772 -954
- package/build/install-bridge-conductor.js +622 -40
- package/build/install-bridge.js +92 -16
- package/build/merge-pull-request.js +1 -1
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +148 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +130 -48
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -1
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The required-work ledger and closure supervisor (BAPI-1301, D4a, plan Step 5).
|
|
3
|
+
*
|
|
4
|
+
* A covered Claude attempt runs with `--input-format stream-json` and its stdin
|
|
5
|
+
* HELD OPEN, so the CLI stays idle between turns instead of exiting and killing
|
|
6
|
+
* background work. This module reads that stream line by line and decides two
|
|
7
|
+
* things from executor-observed lifecycle events only:
|
|
8
|
+
*
|
|
9
|
+
* 1. WHEN stdin may be closed (the closure rule), and
|
|
10
|
+
* 2. WHAT the terminal completion-evidence envelope says (the verdict).
|
|
11
|
+
*
|
|
12
|
+
* Why a ledger and not the exit code or the result text: the BAPI-1292 probe
|
|
13
|
+
* measured that a clean exit and a `result` event both happen while delegated or
|
|
14
|
+
* background work is still running (or was just killed by CLI teardown). Only
|
|
15
|
+
* the per-task lifecycle, keyed by the FULL `tool_use_id`, says whether the work
|
|
16
|
+
* the worker started actually finished.
|
|
17
|
+
*
|
|
18
|
+
* Two places deliberately differ from the probe reducer (`reducer-v2.mjs`), on
|
|
19
|
+
* the user-approved ticket's terms:
|
|
20
|
+
* - A REQUIRED task that ended `failed`, `stopped`, or `withdrawn` (the worker
|
|
21
|
+
* ran its own `TaskStop`) stays UNRESOLVED. The probe counted a worker
|
|
22
|
+
* withdrawal as complete; a worker giving up on required work is not success.
|
|
23
|
+
* - A denial links to an obligation only on the exact OPERATION and TARGET (for
|
|
24
|
+
* example the prepared branch on `origin`), never on the effect class alone.
|
|
25
|
+
*
|
|
26
|
+
* Purity: no I/O, no real timers (the supervisor takes injected timer deps), no
|
|
27
|
+
* retained prompt, command, argument, URL, or path. Tool inputs and denial
|
|
28
|
+
* targets are kept only as SHA-256 digests.
|
|
29
|
+
*/
|
|
30
|
+
import { MAX_DENIALS, MAX_EVENT_BYTES, MAX_TASKS, TASK_RESOLVING_STATES, boundEnvelope, boundedIdentifier, deriveCompletionCause, digestStructured, runtimeMatchesSupported, successRefusalReason, COMPLETION_CONTRACT_VERSION, COMPLETION_CONTRACT_VERSION_V2, } from "./completion-contract.js";
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Bounds and small pure helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** Tool-use index bound. Oldest entries are evicted; a denial on an evicted id resolves to `unknown`. */
|
|
35
|
+
export const MAX_TOOL_USE_INDEX = MAX_TASKS * 8;
|
|
36
|
+
/** Resolved (`succeeded`) obligations evicted to make room are remembered this far, for duplicate/contradiction and replacement checks. */
|
|
37
|
+
const MAX_EVICTED = MAX_TASKS * 4;
|
|
38
|
+
const MAX_WORKER_STOPS = MAX_TASKS * 2;
|
|
39
|
+
/**
|
|
40
|
+
* The digest an obligation's input is identified by, for optional-watcher
|
|
41
|
+
* matching and exact re-issue replacement.
|
|
42
|
+
*
|
|
43
|
+
* BAPI-1301: the top-level `description` is dropped before hashing. It is a
|
|
44
|
+
* worker-authored label, not the work: a server-owned watcher declaration could
|
|
45
|
+
* never predict it, and "similar description" must never decide identity. Every
|
|
46
|
+
* other field (the command, `run_in_background`, the agent prompt) is hashed
|
|
47
|
+
* exactly, so only a byte-identical re-issue of the same work matches.
|
|
48
|
+
*/
|
|
49
|
+
export function obligationInputDigest(input) {
|
|
50
|
+
if (input && typeof input === "object" && !Array.isArray(input)) {
|
|
51
|
+
const { description: _label, ...rest } = input;
|
|
52
|
+
return digestStructured(rest);
|
|
53
|
+
}
|
|
54
|
+
return digestStructured(input ?? null);
|
|
55
|
+
}
|
|
56
|
+
function isRecord(v) {
|
|
57
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
58
|
+
}
|
|
59
|
+
/** Real stream-json nests content under `message`; the recorded fixtures flatten it. Accept both. */
|
|
60
|
+
function contentOf(e) {
|
|
61
|
+
const msg = e["message"];
|
|
62
|
+
if (isRecord(msg) && "content" in msg)
|
|
63
|
+
return msg["content"];
|
|
64
|
+
return e["content"];
|
|
65
|
+
}
|
|
66
|
+
/** A `tool_result` block's text, from either `text` or `content` (string or text blocks). Never retained. */
|
|
67
|
+
function toolResultText(b) {
|
|
68
|
+
if (typeof b["text"] === "string")
|
|
69
|
+
return b["text"];
|
|
70
|
+
const c = b["content"];
|
|
71
|
+
if (typeof c === "string")
|
|
72
|
+
return c;
|
|
73
|
+
if (Array.isArray(c)) {
|
|
74
|
+
return c
|
|
75
|
+
.map((x) => (isRecord(x) && typeof x["text"] === "string" ? x["text"] : ""))
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
/** The measured PreToolUse hook denial and permission-denied shapes (reducer-v2 R8). */
|
|
81
|
+
const DENIAL_TEXT_RE = /^PreToolUse:\S+ hook error|permission to use .* (was|has been) denied|denied by/i;
|
|
82
|
+
/**
|
|
83
|
+
* A minimal, conservative shell-word splitter. Any expansion, substitution,
|
|
84
|
+
* redirection, compound operator, glob, or escape makes the command
|
|
85
|
+
* non-structured (null): its target is then unresolved, never guessed.
|
|
86
|
+
*/
|
|
87
|
+
function plainShellWords(cmd) {
|
|
88
|
+
const words = [];
|
|
89
|
+
let cur = "";
|
|
90
|
+
let inWord = false;
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < cmd.length) {
|
|
93
|
+
const c = cmd[i];
|
|
94
|
+
if (c === "'") {
|
|
95
|
+
const j = cmd.indexOf("'", i + 1);
|
|
96
|
+
if (j < 0)
|
|
97
|
+
return null;
|
|
98
|
+
cur += cmd.slice(i + 1, j);
|
|
99
|
+
inWord = true;
|
|
100
|
+
i = j + 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === '"') {
|
|
104
|
+
let j = i + 1;
|
|
105
|
+
while (j < cmd.length && cmd[j] !== '"') {
|
|
106
|
+
if (cmd[j] === "$" || cmd[j] === "`" || cmd[j] === "\\")
|
|
107
|
+
return null;
|
|
108
|
+
j++;
|
|
109
|
+
}
|
|
110
|
+
if (j >= cmd.length)
|
|
111
|
+
return null;
|
|
112
|
+
cur += cmd.slice(i + 1, j);
|
|
113
|
+
inWord = true;
|
|
114
|
+
i = j + 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (c === " " || c === "\t") {
|
|
118
|
+
if (inWord) {
|
|
119
|
+
words.push(cur);
|
|
120
|
+
cur = "";
|
|
121
|
+
inWord = false;
|
|
122
|
+
}
|
|
123
|
+
i++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (/[;&|<>()$`\\\n\r*?{}[\]~#!]/.test(c))
|
|
127
|
+
return null;
|
|
128
|
+
cur += c;
|
|
129
|
+
inWord = true;
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
if (inWord)
|
|
133
|
+
words.push(cur);
|
|
134
|
+
return words;
|
|
135
|
+
}
|
|
136
|
+
const REF_NAME_RE = /^[A-Za-z0-9._/-]+$/;
|
|
137
|
+
function normalizeRef(ref) {
|
|
138
|
+
const r = ref.trim();
|
|
139
|
+
if (!r || r === "HEAD" || !REF_NAME_RE.test(r))
|
|
140
|
+
return null;
|
|
141
|
+
return r.startsWith("refs/") ? r : `refs/heads/${r}`;
|
|
142
|
+
}
|
|
143
|
+
function normalizeBranch(name) {
|
|
144
|
+
let n = name.trim();
|
|
145
|
+
// `gh pr create --head owner:branch` names the branch after the owner prefix.
|
|
146
|
+
const colon = n.lastIndexOf(":");
|
|
147
|
+
if (colon >= 0)
|
|
148
|
+
n = n.slice(colon + 1);
|
|
149
|
+
if (n.startsWith("refs/heads/"))
|
|
150
|
+
n = n.slice("refs/heads/".length);
|
|
151
|
+
if (!n || n === "HEAD" || !REF_NAME_RE.test(n))
|
|
152
|
+
return null;
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
function normalizeRepository(repo) {
|
|
156
|
+
let r = repo.trim().toLowerCase();
|
|
157
|
+
r = r.replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/^github\.com\//, "");
|
|
158
|
+
return /^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(r) ? r : null;
|
|
159
|
+
}
|
|
160
|
+
function pushLinkKey(remote, ref) {
|
|
161
|
+
return digestStructured({ operation: "push", remote, ref });
|
|
162
|
+
}
|
|
163
|
+
function prLinkKey(head, base, repository) {
|
|
164
|
+
return repository === null
|
|
165
|
+
? digestStructured({ operation: "pr_create", head, base })
|
|
166
|
+
: digestStructured({ operation: "pr_create", head, base, repository });
|
|
167
|
+
}
|
|
168
|
+
const PUSH_TARGET_NEUTRAL_FLAGS = new Set(["-u", "--set-upstream", "--no-verify", "-q", "--quiet"]);
|
|
169
|
+
/** `git push <remote> <refspec>` with at most target-neutral flags; anything else is unresolved. */
|
|
170
|
+
function resolvePushTarget(words) {
|
|
171
|
+
if (!words || words[0] !== "git" || words[1] !== "push")
|
|
172
|
+
return null;
|
|
173
|
+
const positional = [];
|
|
174
|
+
for (const w of words.slice(2)) {
|
|
175
|
+
if (w.startsWith("-")) {
|
|
176
|
+
if (!PUSH_TARGET_NEUTRAL_FLAGS.has(w))
|
|
177
|
+
return null;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
positional.push(w);
|
|
181
|
+
}
|
|
182
|
+
// Bare `git push` (or remote-only) pushes an implicit upstream: unresolved.
|
|
183
|
+
if (positional.length !== 2)
|
|
184
|
+
return null;
|
|
185
|
+
const [remote, refspecRaw] = positional;
|
|
186
|
+
const refspec = refspecRaw.startsWith("+") ? refspecRaw.slice(1) : refspecRaw;
|
|
187
|
+
const colon = refspec.indexOf(":");
|
|
188
|
+
let dst;
|
|
189
|
+
if (colon >= 0) {
|
|
190
|
+
if (colon === 0)
|
|
191
|
+
return null; // `:ref` deletes a ref — not the push obligation.
|
|
192
|
+
dst = refspec.slice(colon + 1);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
dst = refspec;
|
|
196
|
+
}
|
|
197
|
+
const ref = normalizeRef(dst);
|
|
198
|
+
if (!ref || !remote)
|
|
199
|
+
return null;
|
|
200
|
+
return { remote, ref };
|
|
201
|
+
}
|
|
202
|
+
/** `gh pr create` with explicit `--head` and `--base` (and optional `--repo`). */
|
|
203
|
+
function resolvePrTarget(words) {
|
|
204
|
+
if (!words || words[0] !== "gh" || words[1] !== "pr" || words[2] !== "create")
|
|
205
|
+
return null;
|
|
206
|
+
const found = {
|
|
207
|
+
head: undefined,
|
|
208
|
+
base: undefined,
|
|
209
|
+
repo: undefined,
|
|
210
|
+
};
|
|
211
|
+
const flagFor = (w) => w === "--head" || w === "-H" ? "head" : w === "--base" || w === "-B" ? "base" : w === "--repo" || w === "-R" ? "repo" : null;
|
|
212
|
+
for (let i = 3; i < words.length; i++) {
|
|
213
|
+
const w = words[i];
|
|
214
|
+
let key = null;
|
|
215
|
+
let value;
|
|
216
|
+
const eq = w.indexOf("=");
|
|
217
|
+
if (w.startsWith("--") && eq > 0) {
|
|
218
|
+
key = flagFor(w.slice(0, eq));
|
|
219
|
+
value = w.slice(eq + 1);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
key = flagFor(w);
|
|
223
|
+
if (key) {
|
|
224
|
+
value = words[i + 1];
|
|
225
|
+
i++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!key)
|
|
229
|
+
continue;
|
|
230
|
+
if (value === undefined || value.startsWith("-"))
|
|
231
|
+
return null;
|
|
232
|
+
if (found[key] !== undefined)
|
|
233
|
+
return null; // repeated flag: ambiguous.
|
|
234
|
+
found[key] = value;
|
|
235
|
+
}
|
|
236
|
+
if (found.head === undefined || found.base === undefined)
|
|
237
|
+
return null;
|
|
238
|
+
const head = normalizeBranch(found.head);
|
|
239
|
+
const base = normalizeBranch(found.base);
|
|
240
|
+
if (!head || !base)
|
|
241
|
+
return null;
|
|
242
|
+
let repository = null;
|
|
243
|
+
if (found.repo !== undefined) {
|
|
244
|
+
repository = normalizeRepository(found.repo);
|
|
245
|
+
if (!repository)
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return { head, base, repository };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Resolve a tool use's denial operation and target from its STRUCTURED input.
|
|
252
|
+
* The raw command is read here and then discarded; only digests are returned.
|
|
253
|
+
*/
|
|
254
|
+
function resolveDenialOperation(name, input) {
|
|
255
|
+
if (name === "Agent" || name === "Task")
|
|
256
|
+
return { operation: "delegate", targetDigest: null, linkKeys: [] };
|
|
257
|
+
if (name !== "Bash")
|
|
258
|
+
return { operation: "other", targetDigest: null, linkKeys: [] };
|
|
259
|
+
const command = isRecord(input) && typeof input["command"] === "string" ? input["command"] : "";
|
|
260
|
+
const words = plainShellWords(command.trim());
|
|
261
|
+
if (/\bgit\b[^;&|\n]*?\bpush\b/.test(command)) {
|
|
262
|
+
const t = resolvePushTarget(words);
|
|
263
|
+
if (!t)
|
|
264
|
+
return { operation: "push", targetDigest: null, linkKeys: [] };
|
|
265
|
+
const key = pushLinkKey(t.remote, t.ref);
|
|
266
|
+
return { operation: "push", targetDigest: key, linkKeys: [key] };
|
|
267
|
+
}
|
|
268
|
+
if (/\bgh\s+pr\s+create\b/.test(command)) {
|
|
269
|
+
const t = resolvePrTarget(words);
|
|
270
|
+
if (!t)
|
|
271
|
+
return { operation: "pr_create", targetDigest: null, linkKeys: [] };
|
|
272
|
+
const noRepo = prLinkKey(t.head, t.base, null);
|
|
273
|
+
if (t.repository === null)
|
|
274
|
+
return { operation: "pr_create", targetDigest: noRepo, linkKeys: [noRepo] };
|
|
275
|
+
const full = prLinkKey(t.head, t.base, t.repository);
|
|
276
|
+
// A call naming a repository also satisfies an obligation that declared none.
|
|
277
|
+
return { operation: "pr_create", targetDigest: full, linkKeys: [full, noRepo] };
|
|
278
|
+
}
|
|
279
|
+
if (/\bcurl\b[^|;&\n]*-X\s*(POST|PUT|PATCH|DELETE)\b/i.test(command) ||
|
|
280
|
+
/\bgh\s+api\b[^|;&\n]*(-X|--method)\s*(POST|PUT|PATCH|DELETE)\b/i.test(command)) {
|
|
281
|
+
return { operation: "external_mutation", targetDigest: null, linkKeys: [] };
|
|
282
|
+
}
|
|
283
|
+
return { operation: "other", targetDigest: null, linkKeys: [] };
|
|
284
|
+
}
|
|
285
|
+
function normalizeObligation(o) {
|
|
286
|
+
if (o.operation === "push") {
|
|
287
|
+
const ref = normalizeRef(o.target.ref);
|
|
288
|
+
const remote = typeof o.target.remote === "string" ? o.target.remote.trim() : "";
|
|
289
|
+
if (!ref || !remote)
|
|
290
|
+
return { linkKey: "", satisfactionKey: "", valid: false };
|
|
291
|
+
const linkKey = pushLinkKey(remote, ref);
|
|
292
|
+
return {
|
|
293
|
+
linkKey,
|
|
294
|
+
satisfactionKey: digestStructured({ linkKey, head: o.target.head ?? null }),
|
|
295
|
+
valid: true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const head = normalizeBranch(o.target.head);
|
|
299
|
+
const base = normalizeBranch(o.target.base);
|
|
300
|
+
const repository = o.target.repository === undefined ? null : normalizeRepository(o.target.repository);
|
|
301
|
+
if (!head || !base || (o.target.repository !== undefined && repository === null)) {
|
|
302
|
+
return { linkKey: "", satisfactionKey: "", valid: false };
|
|
303
|
+
}
|
|
304
|
+
const linkKey = prLinkKey(head, base, repository);
|
|
305
|
+
return { linkKey, satisfactionKey: linkKey, valid: true };
|
|
306
|
+
}
|
|
307
|
+
const TERMINAL_STATES = new Set([
|
|
308
|
+
"succeeded",
|
|
309
|
+
"failed",
|
|
310
|
+
"stopped",
|
|
311
|
+
"withdrawn",
|
|
312
|
+
"conflict",
|
|
313
|
+
]);
|
|
314
|
+
const KNOWN_TASK_STATUSES = new Set(["pending", "running", "completed", "failed", "stopped", "killed"]);
|
|
315
|
+
export function createRequiredWorkLedger(init) {
|
|
316
|
+
const binding = { ...init.binding };
|
|
317
|
+
const lifecycleSupported = init.lifecycleSupported === true;
|
|
318
|
+
const optionalWatchers = (init.optionalWatchers ?? []).slice(0, 16).map((w) => ({ ...w }));
|
|
319
|
+
// An obligation whose target cannot be normalized links nothing: a denial of
|
|
320
|
+
// it then reads `unlinked`/`ambiguous`, and unmet finalization still refuses
|
|
321
|
+
// success on its own, so dropping it can never manufacture a success.
|
|
322
|
+
const declared = (init.obligations ?? []).slice(0, 8).map(normalizeObligation).filter((d) => d.valid);
|
|
323
|
+
let sessionId = null;
|
|
324
|
+
let sawInit = false;
|
|
325
|
+
const insufficient = new Set();
|
|
326
|
+
const obligations = new Map();
|
|
327
|
+
const evicted = new Map();
|
|
328
|
+
const toolUses = new Map();
|
|
329
|
+
const workerStops = new Set();
|
|
330
|
+
const denials = [];
|
|
331
|
+
let latestSnapshot = null;
|
|
332
|
+
let materialSeq = 0;
|
|
333
|
+
let activitySeq = 0;
|
|
334
|
+
let orderSeq = 0;
|
|
335
|
+
let resultsSeen = 0;
|
|
336
|
+
let lastResultSeq = -1;
|
|
337
|
+
/** No material event since the latest result. */
|
|
338
|
+
let latestIsResult = false;
|
|
339
|
+
/** An init/assistant/user turn event arrived after the latest result. */
|
|
340
|
+
let turnOpen = false;
|
|
341
|
+
let stdinClosed = false;
|
|
342
|
+
let streamEnd = null;
|
|
343
|
+
let processExit = null;
|
|
344
|
+
const isOptional = (kind, inputDigest) => inputDigest !== null && optionalWatchers.some((w) => w.kind === kind && w.input_digest === inputDigest);
|
|
345
|
+
// --- obligation tracking --------------------------------------------------
|
|
346
|
+
/** Evict the oldest resolved obligation to make room. Unresolved obligations are never evicted. */
|
|
347
|
+
const evictOneResolved = () => {
|
|
348
|
+
if (evicted.size >= MAX_EVICTED)
|
|
349
|
+
return false;
|
|
350
|
+
for (const o of obligations.values()) {
|
|
351
|
+
if (o.state !== "succeeded")
|
|
352
|
+
continue;
|
|
353
|
+
if (latestSnapshot?.includes(o.taskId ?? "\u0000"))
|
|
354
|
+
continue;
|
|
355
|
+
evicted.set(o.key, { state: o.state, taskId: o.taskId, kind: o.kind, inputDigest: o.inputDigest, order: o.order });
|
|
356
|
+
obligations.delete(o.key);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
};
|
|
361
|
+
const track = (key, spec) => {
|
|
362
|
+
const existing = obligations.get(key);
|
|
363
|
+
if (existing)
|
|
364
|
+
return existing;
|
|
365
|
+
if (evicted.has(key))
|
|
366
|
+
return null; // already resolved and remembered.
|
|
367
|
+
if (obligations.size >= MAX_TASKS && !evictOneResolved()) {
|
|
368
|
+
insufficient.add("ledger_capacity_exhausted");
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
const o = {
|
|
372
|
+
key,
|
|
373
|
+
state: "pending",
|
|
374
|
+
order: ++orderSeq,
|
|
375
|
+
...spec,
|
|
376
|
+
required: spec.required ?? !isOptional(spec.kind, spec.inputDigest),
|
|
377
|
+
};
|
|
378
|
+
obligations.set(key, o);
|
|
379
|
+
return o;
|
|
380
|
+
};
|
|
381
|
+
const findByTaskId = (taskId) => {
|
|
382
|
+
for (const o of obligations.values())
|
|
383
|
+
if (o.taskId === taskId && !o.placeholder)
|
|
384
|
+
return o;
|
|
385
|
+
return obligations.get(`t:${taskId}`);
|
|
386
|
+
};
|
|
387
|
+
/** Apply a terminal state. Identical duplicates are idempotent; a contradiction is `conflict`. */
|
|
388
|
+
const applyTerminal = (o, next) => {
|
|
389
|
+
if (!TERMINAL_STATES.has(o.state)) {
|
|
390
|
+
o.state = next;
|
|
391
|
+
return "applied";
|
|
392
|
+
}
|
|
393
|
+
if (o.state === next || o.state === "conflict")
|
|
394
|
+
return o.state === "conflict" ? "conflict" : "applied";
|
|
395
|
+
// A stop (CLI teardown or worker TaskStop) after the task already ended is
|
|
396
|
+
// measured noise (reducer-v2 R3), not a contradiction of the recorded end.
|
|
397
|
+
if ((next === "stopped" || next === "withdrawn") && (o.state === "succeeded" || o.state === "failed"))
|
|
398
|
+
return "applied";
|
|
399
|
+
// A killed foreground call reports `stopped` and then its own error
|
|
400
|
+
// tool_result (measured C5k, "Exit code 137"): one ending, not two.
|
|
401
|
+
if (next === "failed" && (o.state === "stopped" || o.state === "withdrawn"))
|
|
402
|
+
return "applied";
|
|
403
|
+
// `stopped` then `withdrawn` for the same stop (task_updated `killed` then a
|
|
404
|
+
// notification after the worker's TaskStop) describe one event.
|
|
405
|
+
if ((o.state === "stopped" && next === "withdrawn") || (o.state === "withdrawn" && next === "stopped")) {
|
|
406
|
+
o.state = "withdrawn";
|
|
407
|
+
return "applied";
|
|
408
|
+
}
|
|
409
|
+
o.state = "conflict";
|
|
410
|
+
insufficient.add("event_conflict");
|
|
411
|
+
return "conflict";
|
|
412
|
+
};
|
|
413
|
+
/** Fold a placeholder (snapshot-only or orphan terminal keyed by task id) into the real obligation. */
|
|
414
|
+
const mergePlaceholder = (real, taskId) => {
|
|
415
|
+
const ph = obligations.get(`t:${taskId}`);
|
|
416
|
+
if (!ph || ph === real)
|
|
417
|
+
return "applied";
|
|
418
|
+
obligations.delete(ph.key);
|
|
419
|
+
if (TERMINAL_STATES.has(ph.state))
|
|
420
|
+
return applyTerminal(real, ph.state);
|
|
421
|
+
return "applied";
|
|
422
|
+
};
|
|
423
|
+
const terminalOnEvicted = (key, next) => {
|
|
424
|
+
const ev = evicted.get(key);
|
|
425
|
+
if (!ev)
|
|
426
|
+
return "ignored";
|
|
427
|
+
if (ev.state === next || next === "stopped" || next === "withdrawn")
|
|
428
|
+
return "applied";
|
|
429
|
+
insufficient.add("event_conflict");
|
|
430
|
+
return "conflict";
|
|
431
|
+
};
|
|
432
|
+
// --- denials --------------------------------------------------------------
|
|
433
|
+
const addDenial = (toolUseId, seq, viaResult) => {
|
|
434
|
+
const existing = denials.find((d) => d.toolUseId === toolUseId);
|
|
435
|
+
if (existing) {
|
|
436
|
+
if (viaResult)
|
|
437
|
+
existing.viaResult = true;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (denials.length >= MAX_DENIALS) {
|
|
441
|
+
insufficient.add("denial_capacity_exhausted");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const entry = toolUses.get(toolUseId);
|
|
445
|
+
const resolution = entry
|
|
446
|
+
? entry.denial
|
|
447
|
+
: { operation: "unknown", targetDigest: null, linkKeys: [] };
|
|
448
|
+
denials.push({ toolUseId, resolution, seq, viaResult });
|
|
449
|
+
};
|
|
450
|
+
/** A denied tracked obligation never ran: it is `failed` (and stays unresolved if required). */
|
|
451
|
+
const failDeniedObligation = (toolUseId) => {
|
|
452
|
+
const o = obligations.get(toolUseId);
|
|
453
|
+
if (o)
|
|
454
|
+
applyTerminal(o, "failed");
|
|
455
|
+
};
|
|
456
|
+
// --- event handlers -------------------------------------------------------
|
|
457
|
+
const onTurnEvent = () => {
|
|
458
|
+
turnOpen = true;
|
|
459
|
+
if (stdinClosed)
|
|
460
|
+
insufficient.add("late_activity_after_close");
|
|
461
|
+
};
|
|
462
|
+
const handleInit = (e) => {
|
|
463
|
+
const sid = boundedIdentifier(e["session_id"]);
|
|
464
|
+
onTurnEvent();
|
|
465
|
+
if (!sid) {
|
|
466
|
+
insufficient.add("event_unknown");
|
|
467
|
+
return "unknown_shape";
|
|
468
|
+
}
|
|
469
|
+
sawInit = true;
|
|
470
|
+
return "applied";
|
|
471
|
+
};
|
|
472
|
+
const handleAssistant = (e) => {
|
|
473
|
+
onTurnEvent();
|
|
474
|
+
const content = contentOf(e);
|
|
475
|
+
if (!Array.isArray(content)) {
|
|
476
|
+
// A string or absent content carries no tool use; only a non-array object is malformed.
|
|
477
|
+
if (content === undefined || typeof content === "string")
|
|
478
|
+
return "applied";
|
|
479
|
+
insufficient.add("event_unknown");
|
|
480
|
+
return "unknown_shape";
|
|
481
|
+
}
|
|
482
|
+
let outcome = "applied";
|
|
483
|
+
for (const b of content) {
|
|
484
|
+
if (!isRecord(b) || b["type"] !== "tool_use")
|
|
485
|
+
continue;
|
|
486
|
+
const id = boundedIdentifier(b["id"]);
|
|
487
|
+
const name = typeof b["name"] === "string" ? b["name"] : null;
|
|
488
|
+
if (!id || !name) {
|
|
489
|
+
insufficient.add("event_unknown");
|
|
490
|
+
outcome = "unknown_shape";
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const input = b["input"];
|
|
494
|
+
const inputDigest = obligationInputDigest(input);
|
|
495
|
+
// Bounded index; the input itself is dropped here, only digests remain.
|
|
496
|
+
if (!toolUses.has(id)) {
|
|
497
|
+
if (toolUses.size >= MAX_TOOL_USE_INDEX) {
|
|
498
|
+
const oldest = toolUses.keys().next().value;
|
|
499
|
+
if (oldest !== undefined)
|
|
500
|
+
toolUses.delete(oldest);
|
|
501
|
+
}
|
|
502
|
+
toolUses.set(id, { name, inputDigest, denial: resolveDenialOperation(name, input) });
|
|
503
|
+
}
|
|
504
|
+
const bg = isRecord(input) && input["run_in_background"] === true;
|
|
505
|
+
if (name === "Agent" || name === "Task") {
|
|
506
|
+
track(id, { toolUseId: id, taskId: null, kind: bg ? "delegate_bg" : "delegate", inputDigest, background: bg, placeholder: false });
|
|
507
|
+
}
|
|
508
|
+
else if (name === "Bash" && bg) {
|
|
509
|
+
track(id, { toolUseId: id, taskId: null, kind: "bash_bg", inputDigest, background: true, placeholder: false });
|
|
510
|
+
}
|
|
511
|
+
else if (name === "Monitor") {
|
|
512
|
+
track(id, { toolUseId: id, taskId: null, kind: "monitor", inputDigest, background: true, placeholder: false });
|
|
513
|
+
}
|
|
514
|
+
else if (name === "TaskStop") {
|
|
515
|
+
// R13: the worker's OWN stop request. It turns a later stop into
|
|
516
|
+
// `withdrawn` for the record — still unresolved when the task was required.
|
|
517
|
+
const tid = isRecord(input) ? boundedIdentifier(input["task_id"]) : null;
|
|
518
|
+
if (tid && workerStops.size < MAX_WORKER_STOPS)
|
|
519
|
+
workerStops.add(tid);
|
|
520
|
+
}
|
|
521
|
+
// Synchronous foreground Bash and every other tool are exploratory: not
|
|
522
|
+
// obligations. Their denials are still recorded from the tool_result.
|
|
523
|
+
}
|
|
524
|
+
return outcome;
|
|
525
|
+
};
|
|
526
|
+
const handleUser = (e, seq) => {
|
|
527
|
+
onTurnEvent();
|
|
528
|
+
const content = contentOf(e);
|
|
529
|
+
if (!Array.isArray(content))
|
|
530
|
+
return "applied"; // a plain user text message.
|
|
531
|
+
let outcome = "applied";
|
|
532
|
+
for (const b of content) {
|
|
533
|
+
if (!isRecord(b) || b["type"] !== "tool_result")
|
|
534
|
+
continue;
|
|
535
|
+
const id = boundedIdentifier(b["tool_use_id"]);
|
|
536
|
+
if (!id) {
|
|
537
|
+
insufficient.add("event_unknown");
|
|
538
|
+
outcome = "unknown_shape";
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const isError = b["is_error"] === true;
|
|
542
|
+
const denied = isError && DENIAL_TEXT_RE.test(toolResultText(b));
|
|
543
|
+
if (denied) {
|
|
544
|
+
addDenial(id, seq, false);
|
|
545
|
+
failDeniedObligation(id);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const o = obligations.get(id);
|
|
549
|
+
if (!o || o.background)
|
|
550
|
+
continue; // background: a launch acknowledgement, not a result.
|
|
551
|
+
const r = applyTerminal(o, isError ? "failed" : "succeeded"); // foreground delegate result.
|
|
552
|
+
if (r === "conflict")
|
|
553
|
+
outcome = "conflict";
|
|
554
|
+
}
|
|
555
|
+
return outcome;
|
|
556
|
+
};
|
|
557
|
+
const handleTaskStarted = (e) => {
|
|
558
|
+
onTurnEvent();
|
|
559
|
+
const taskId = boundedIdentifier(e["task_id"]);
|
|
560
|
+
if (!taskId) {
|
|
561
|
+
insufficient.add("event_unknown");
|
|
562
|
+
return "unknown_shape";
|
|
563
|
+
}
|
|
564
|
+
const toolUseId = e["tool_use_id"] === undefined || e["tool_use_id"] === null ? null : boundedIdentifier(e["tool_use_id"]);
|
|
565
|
+
if (e["tool_use_id"] !== undefined && e["tool_use_id"] !== null && !toolUseId) {
|
|
566
|
+
insufficient.add("event_unknown");
|
|
567
|
+
return "unknown_shape";
|
|
568
|
+
}
|
|
569
|
+
const bg = e["is_backgrounded"] === true;
|
|
570
|
+
const kind = e["task_type"] === "local_agent" ? (bg ? "delegate_bg" : "delegate") : e["owned_by_subagent"] === true ? "subagent_bash" : "bash_task";
|
|
571
|
+
if (!toolUseId) {
|
|
572
|
+
track(`t:${taskId}`, { toolUseId: taskId, taskId, kind, inputDigest: null, background: bg, placeholder: true, required: true });
|
|
573
|
+
return "applied";
|
|
574
|
+
}
|
|
575
|
+
const entry = toolUses.get(toolUseId);
|
|
576
|
+
const o = track(toolUseId, {
|
|
577
|
+
toolUseId,
|
|
578
|
+
taskId,
|
|
579
|
+
kind,
|
|
580
|
+
inputDigest: entry?.inputDigest ?? null,
|
|
581
|
+
background: bg,
|
|
582
|
+
placeholder: false,
|
|
583
|
+
});
|
|
584
|
+
if (!o)
|
|
585
|
+
return "applied";
|
|
586
|
+
if (o.taskId !== null && o.taskId !== taskId) {
|
|
587
|
+
insufficient.add("event_conflict");
|
|
588
|
+
return "conflict";
|
|
589
|
+
}
|
|
590
|
+
o.taskId = taskId;
|
|
591
|
+
return mergePlaceholder(o, taskId);
|
|
592
|
+
};
|
|
593
|
+
const handleTaskTerminal = (e, status) => {
|
|
594
|
+
const taskId = boundedIdentifier(e["task_id"]);
|
|
595
|
+
if (!taskId || typeof status !== "string") {
|
|
596
|
+
insufficient.add("event_unknown");
|
|
597
|
+
return "unknown_shape";
|
|
598
|
+
}
|
|
599
|
+
if (!KNOWN_TASK_STATUSES.has(status)) {
|
|
600
|
+
insufficient.add("event_unknown");
|
|
601
|
+
return "unknown_shape";
|
|
602
|
+
}
|
|
603
|
+
let next = status === "completed" ? "succeeded" : status === "failed" ? "failed" : status === "stopped" || status === "killed" ? "stopped" : null;
|
|
604
|
+
if (next === null)
|
|
605
|
+
return "applied"; // pending/running: non-terminal progress.
|
|
606
|
+
if (next === "stopped" && workerStops.has(taskId))
|
|
607
|
+
next = "withdrawn";
|
|
608
|
+
const rawToolUse = e["tool_use_id"];
|
|
609
|
+
const toolUseId = rawToolUse === undefined || rawToolUse === null ? null : boundedIdentifier(rawToolUse);
|
|
610
|
+
if (rawToolUse !== undefined && rawToolUse !== null && !toolUseId) {
|
|
611
|
+
insufficient.add("event_unknown");
|
|
612
|
+
return "unknown_shape";
|
|
613
|
+
}
|
|
614
|
+
let o;
|
|
615
|
+
if (toolUseId) {
|
|
616
|
+
const byTask = findByTaskId(taskId);
|
|
617
|
+
if (byTask && !byTask.placeholder && byTask.key !== toolUseId) {
|
|
618
|
+
insufficient.add("event_conflict");
|
|
619
|
+
return "conflict";
|
|
620
|
+
}
|
|
621
|
+
if (evicted.has(toolUseId))
|
|
622
|
+
return terminalOnEvicted(toolUseId, next);
|
|
623
|
+
// R4: a terminal without a start is retained, not dropped.
|
|
624
|
+
o = track(toolUseId, {
|
|
625
|
+
toolUseId,
|
|
626
|
+
taskId,
|
|
627
|
+
kind: "task",
|
|
628
|
+
inputDigest: toolUses.get(toolUseId)?.inputDigest ?? null,
|
|
629
|
+
background: true,
|
|
630
|
+
placeholder: false,
|
|
631
|
+
});
|
|
632
|
+
if (!o)
|
|
633
|
+
return "applied";
|
|
634
|
+
if (o.taskId === null)
|
|
635
|
+
o.taskId = taskId;
|
|
636
|
+
const merged = mergePlaceholder(o, taskId);
|
|
637
|
+
if (merged === "conflict")
|
|
638
|
+
return merged;
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
o = findByTaskId(taskId);
|
|
642
|
+
if (!o) {
|
|
643
|
+
for (const [k, ev] of evicted)
|
|
644
|
+
if (ev.taskId === taskId)
|
|
645
|
+
return terminalOnEvicted(k, next);
|
|
646
|
+
o = track(`t:${taskId}`, { toolUseId: taskId, taskId, kind: "task", inputDigest: null, background: true, placeholder: true, required: true });
|
|
647
|
+
if (!o)
|
|
648
|
+
return "applied";
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return applyTerminal(o, next);
|
|
652
|
+
};
|
|
653
|
+
const handleSnapshot = (e) => {
|
|
654
|
+
const tasks = e["tasks"];
|
|
655
|
+
if (!Array.isArray(tasks)) {
|
|
656
|
+
insufficient.add("event_unknown");
|
|
657
|
+
return "unknown_shape";
|
|
658
|
+
}
|
|
659
|
+
const ids = [];
|
|
660
|
+
let outcome = "applied";
|
|
661
|
+
for (const t of tasks.slice(0, MAX_TASKS * 2)) {
|
|
662
|
+
const tid = isRecord(t) ? boundedIdentifier(t["task_id"]) : null;
|
|
663
|
+
if (!tid) {
|
|
664
|
+
insufficient.add("event_unknown");
|
|
665
|
+
outcome = "unknown_shape";
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
ids.push(tid);
|
|
669
|
+
}
|
|
670
|
+
if (tasks.length > MAX_TASKS * 2)
|
|
671
|
+
insufficient.add("ledger_capacity_exhausted");
|
|
672
|
+
latestSnapshot = ids; // latest wins.
|
|
673
|
+
// A task the snapshot lists but no start/tool use explains yet is an
|
|
674
|
+
// UNKNOWN obligation, required until a lifecycle event resolves it.
|
|
675
|
+
for (const tid of ids) {
|
|
676
|
+
if (findByTaskId(tid))
|
|
677
|
+
continue;
|
|
678
|
+
let wasEvicted = false;
|
|
679
|
+
for (const ev of evicted.values())
|
|
680
|
+
if (ev.taskId === tid)
|
|
681
|
+
wasEvicted = true;
|
|
682
|
+
if (wasEvicted)
|
|
683
|
+
continue;
|
|
684
|
+
track(`t:${tid}`, { toolUseId: tid, taskId: tid, kind: "task", inputDigest: null, background: true, placeholder: true, required: true });
|
|
685
|
+
}
|
|
686
|
+
return outcome;
|
|
687
|
+
};
|
|
688
|
+
const handleResult = (e, seq) => {
|
|
689
|
+
resultsSeen++;
|
|
690
|
+
lastResultSeq = seq;
|
|
691
|
+
turnOpen = false;
|
|
692
|
+
const pd = e["permission_denials"];
|
|
693
|
+
if (pd === undefined || pd === null)
|
|
694
|
+
return "applied";
|
|
695
|
+
if (!Array.isArray(pd)) {
|
|
696
|
+
insufficient.add("event_unknown");
|
|
697
|
+
return "unknown_shape";
|
|
698
|
+
}
|
|
699
|
+
let outcome = "applied";
|
|
700
|
+
for (const d of pd) {
|
|
701
|
+
const id = isRecord(d) ? boundedIdentifier(d["tool_use_id"]) : null;
|
|
702
|
+
if (!id) {
|
|
703
|
+
insufficient.add("event_unknown");
|
|
704
|
+
outcome = "unknown_shape";
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
// The result that reports the denial is itself a completed turn after it.
|
|
708
|
+
addDenial(id, seq, true);
|
|
709
|
+
failDeniedObligation(id);
|
|
710
|
+
}
|
|
711
|
+
return outcome;
|
|
712
|
+
};
|
|
713
|
+
const dispatch = (e) => {
|
|
714
|
+
const type = e["type"];
|
|
715
|
+
const subtype = e["subtype"];
|
|
716
|
+
const material = type === "assistant" ||
|
|
717
|
+
type === "user" ||
|
|
718
|
+
type === "result" ||
|
|
719
|
+
(type === "system" &&
|
|
720
|
+
(subtype === "init" ||
|
|
721
|
+
subtype === "task_started" ||
|
|
722
|
+
subtype === "task_notification" ||
|
|
723
|
+
subtype === "task_updated" ||
|
|
724
|
+
subtype === "background_tasks_changed"));
|
|
725
|
+
if (!material)
|
|
726
|
+
return "not_material";
|
|
727
|
+
// Session binding: one session per attempt. A different session id is a
|
|
728
|
+
// stream that does not belong to this binding.
|
|
729
|
+
const sidRaw = e["session_id"];
|
|
730
|
+
if (sidRaw !== undefined && sidRaw !== null) {
|
|
731
|
+
const sid = boundedIdentifier(sidRaw);
|
|
732
|
+
if (!sid) {
|
|
733
|
+
insufficient.add("event_unknown");
|
|
734
|
+
return "unknown_shape";
|
|
735
|
+
}
|
|
736
|
+
if (sessionId === null && type === "system" && subtype === "init")
|
|
737
|
+
sessionId = sid;
|
|
738
|
+
else if (sessionId !== null && sid !== sessionId) {
|
|
739
|
+
insufficient.add("event_conflict");
|
|
740
|
+
return "conflict";
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const seq = ++materialSeq;
|
|
744
|
+
latestIsResult = type === "result";
|
|
745
|
+
if (type === "result")
|
|
746
|
+
return handleResult(e, seq);
|
|
747
|
+
if (type === "assistant")
|
|
748
|
+
return handleAssistant(e);
|
|
749
|
+
if (type === "user")
|
|
750
|
+
return handleUser(e, seq);
|
|
751
|
+
switch (subtype) {
|
|
752
|
+
case "init":
|
|
753
|
+
return handleInit(e);
|
|
754
|
+
case "task_started":
|
|
755
|
+
return handleTaskStarted(e);
|
|
756
|
+
case "task_notification":
|
|
757
|
+
return handleTaskTerminal(e, e["status"]);
|
|
758
|
+
case "task_updated": {
|
|
759
|
+
const patch = e["patch"];
|
|
760
|
+
if (!isRecord(patch)) {
|
|
761
|
+
insufficient.add("event_unknown");
|
|
762
|
+
return "unknown_shape";
|
|
763
|
+
}
|
|
764
|
+
// A patch without a status (end_time, progress) is not a lifecycle change.
|
|
765
|
+
if (patch["status"] === undefined)
|
|
766
|
+
return "applied";
|
|
767
|
+
return handleTaskTerminal(e, patch["status"]);
|
|
768
|
+
}
|
|
769
|
+
case "background_tasks_changed":
|
|
770
|
+
return handleSnapshot(e);
|
|
771
|
+
default:
|
|
772
|
+
return "ignored";
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
// --- derived views --------------------------------------------------------
|
|
776
|
+
const isLive = (o) => o.state === "pending";
|
|
777
|
+
/** Task ids in the latest snapshot that no terminal lifecycle event has resolved. */
|
|
778
|
+
const liveSnapshotIds = () => {
|
|
779
|
+
const required = [];
|
|
780
|
+
const optional = [];
|
|
781
|
+
for (const tid of latestSnapshot ?? []) {
|
|
782
|
+
const o = findByTaskId(tid);
|
|
783
|
+
if (!o) {
|
|
784
|
+
let evictedResolved = false;
|
|
785
|
+
for (const ev of evicted.values())
|
|
786
|
+
if (ev.taskId === tid)
|
|
787
|
+
evictedResolved = true;
|
|
788
|
+
if (!evictedResolved)
|
|
789
|
+
required.push(tid);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (!isLive(o))
|
|
793
|
+
continue;
|
|
794
|
+
(o.required ? required : optional).push(tid);
|
|
795
|
+
}
|
|
796
|
+
return { required, optional };
|
|
797
|
+
};
|
|
798
|
+
/** Effective states with exact re-issue replacement applied (never mutates the record). */
|
|
799
|
+
const effectiveTasks = () => {
|
|
800
|
+
const all = [...obligations.values()];
|
|
801
|
+
const succeededLater = (o) => {
|
|
802
|
+
if (o.inputDigest === null)
|
|
803
|
+
return false;
|
|
804
|
+
for (const other of all) {
|
|
805
|
+
if (other !== o && other.kind === o.kind && other.inputDigest === o.inputDigest && other.state === "succeeded" && other.order > o.order)
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
for (const ev of evicted.values()) {
|
|
809
|
+
if (ev.kind === o.kind && ev.inputDigest === o.inputDigest && ev.state === "succeeded" && ev.order > o.order)
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
return false;
|
|
813
|
+
};
|
|
814
|
+
return all.map((o) => {
|
|
815
|
+
// Only a terminal non-success is replaceable: live work is not superseded,
|
|
816
|
+
// and a `conflict` is contradictory evidence, not a failure to retry.
|
|
817
|
+
const replaceable = o.state === "failed" || o.state === "stopped" || o.state === "withdrawn";
|
|
818
|
+
return { o, state: replaceable && succeededLater(o) ? "replaced" : o.state };
|
|
819
|
+
});
|
|
820
|
+
};
|
|
821
|
+
// --- public API -----------------------------------------------------------
|
|
822
|
+
const ingestLine = (line) => {
|
|
823
|
+
if (typeof line !== "string" || line.trim().length === 0)
|
|
824
|
+
return "blank";
|
|
825
|
+
activitySeq++;
|
|
826
|
+
if (Buffer.byteLength(line, "utf8") > MAX_EVENT_BYTES) {
|
|
827
|
+
// Never parsed: an event this large cannot be trusted to be complete.
|
|
828
|
+
insufficient.add("stream_oversize");
|
|
829
|
+
latestIsResult = false;
|
|
830
|
+
return "oversize";
|
|
831
|
+
}
|
|
832
|
+
let parsed;
|
|
833
|
+
try {
|
|
834
|
+
parsed = JSON.parse(line);
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
insufficient.add("stream_parse_error");
|
|
838
|
+
latestIsResult = false;
|
|
839
|
+
return "parse_error";
|
|
840
|
+
}
|
|
841
|
+
if (!isRecord(parsed) || typeof parsed["type"] !== "string") {
|
|
842
|
+
insufficient.add("stream_parse_error");
|
|
843
|
+
latestIsResult = false;
|
|
844
|
+
return "parse_error";
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
const r = dispatch(parsed);
|
|
848
|
+
return r === "not_material" ? "ignored" : r;
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
// A reducer defect must degrade to "not proven", never to success.
|
|
852
|
+
insufficient.add("event_unknown");
|
|
853
|
+
latestIsResult = false;
|
|
854
|
+
return "unknown_shape";
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
const closureState = () => {
|
|
858
|
+
const liveRequired = [];
|
|
859
|
+
const liveOptional = [];
|
|
860
|
+
for (const o of obligations.values()) {
|
|
861
|
+
if (!isLive(o))
|
|
862
|
+
continue;
|
|
863
|
+
if (o.required)
|
|
864
|
+
liveRequired.push(o.toolUseId);
|
|
865
|
+
else
|
|
866
|
+
liveOptional.push(o.taskId ?? o.toolUseId);
|
|
867
|
+
}
|
|
868
|
+
const snap = liveSnapshotIds();
|
|
869
|
+
// A snapshot id whose obligation is already listed (by its tool_use_id or as
|
|
870
|
+
// a placeholder) is the same work: list it once, not under both handles.
|
|
871
|
+
for (const tid of snap.required)
|
|
872
|
+
if (!liveRequired.includes(tid) && !findByTaskId(tid))
|
|
873
|
+
liveRequired.push(tid);
|
|
874
|
+
for (const tid of snap.optional)
|
|
875
|
+
if (!liveOptional.includes(tid))
|
|
876
|
+
liveOptional.push(tid);
|
|
877
|
+
const readyToClose = resultsSeen > 0 && latestIsResult && !turnOpen && liveRequired.length === 0 && snap.required.length === 0;
|
|
878
|
+
return { readyToClose, liveRequired, liveOptional, activitySeq, latestIsResult };
|
|
879
|
+
};
|
|
880
|
+
const buildEnvelope = (facts) => {
|
|
881
|
+
const reasons = new Set(insufficient);
|
|
882
|
+
if (!lifecycleSupported || !runtimeMatchesSupported(facts.runtime))
|
|
883
|
+
reasons.add("unsupported_runtime");
|
|
884
|
+
if (!sawInit || sessionId === null)
|
|
885
|
+
reasons.add("no_init_event");
|
|
886
|
+
if (resultsSeen === 0)
|
|
887
|
+
reasons.add("no_result_turn");
|
|
888
|
+
else if (turnOpen)
|
|
889
|
+
reasons.add("active_turn_at_close");
|
|
890
|
+
if (streamEnd === null || streamEnd === "drain_deadline")
|
|
891
|
+
reasons.add("stdout_not_read_to_eof");
|
|
892
|
+
else if (streamEnd === "stream_error")
|
|
893
|
+
reasons.add("stream_lost");
|
|
894
|
+
if (processExit === null)
|
|
895
|
+
reasons.add("no_process_exit");
|
|
896
|
+
else if (processExit.signal === "SIGKILL")
|
|
897
|
+
reasons.add("forced_kill_no_cli_teardown");
|
|
898
|
+
const effective = effectiveTasks();
|
|
899
|
+
// R6: a required task with no terminal after the stream ended is missing evidence.
|
|
900
|
+
if (effective.some(({ o, state }) => o.required && state === "pending"))
|
|
901
|
+
reasons.add("live_required_work");
|
|
902
|
+
// R6b: a stale snapshot, or a snapshot-only task nothing ever explained.
|
|
903
|
+
const snap = liveSnapshotIds();
|
|
904
|
+
const unexplainedPlaceholder = effective.some(({ o, state }) => o.placeholder && state === "pending");
|
|
905
|
+
if (snap.required.length > 0 || unexplainedPlaceholder)
|
|
906
|
+
reasons.add("stale_task_snapshot");
|
|
907
|
+
const sufficient = reasons.size === 0;
|
|
908
|
+
const unresolvedRequired = effective.some(({ o, state }) => o.required && !TASK_RESOLVING_STATES.has(state));
|
|
909
|
+
const anyConflict = effective.some(({ state }) => state === "conflict");
|
|
910
|
+
let execution = unresolvedRequired || anyConflict || snap.required.length > 0 || unexplainedPlaceholder ? "incomplete" : "complete";
|
|
911
|
+
if (!sufficient && execution === "complete")
|
|
912
|
+
execution = "unknown";
|
|
913
|
+
const tasks = effective.slice(0, MAX_TASKS).map(({ o, state }) => ({
|
|
914
|
+
tool_use_id: o.toolUseId,
|
|
915
|
+
task_id: o.taskId,
|
|
916
|
+
kind: o.kind,
|
|
917
|
+
// A task still pending when the verdict is taken never reached a terminal.
|
|
918
|
+
state: state === "pending" ? "unknown" : state,
|
|
919
|
+
required: o.required,
|
|
920
|
+
input_digest: o.inputDigest,
|
|
921
|
+
}));
|
|
922
|
+
const satisfied = new Set((facts.satisfiedObligations ?? [])
|
|
923
|
+
.map(normalizeObligation)
|
|
924
|
+
.filter((n) => n.valid)
|
|
925
|
+
.map((n) => n.satisfactionKey));
|
|
926
|
+
const denialRows = denials.map((d) => {
|
|
927
|
+
const { operation, targetDigest, linkKeys } = d.resolution;
|
|
928
|
+
let state;
|
|
929
|
+
if (operation === "unknown" || ((operation === "push" || operation === "pr_create") && linkKeys.length === 0)) {
|
|
930
|
+
// The target cannot be established from structure: it may or may not be
|
|
931
|
+
// the required effect. Never guessed into blocking or historical.
|
|
932
|
+
state = "ambiguous";
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
const linked = declared.filter((n) => linkKeys.includes(n.linkKey));
|
|
936
|
+
if (linked.length === 0)
|
|
937
|
+
state = "unlinked";
|
|
938
|
+
else if (linked.some((n) => !satisfied.has(n.satisfactionKey)))
|
|
939
|
+
state = "blocking";
|
|
940
|
+
else
|
|
941
|
+
state = "historical";
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
tool_use_id: d.toolUseId,
|
|
945
|
+
operation,
|
|
946
|
+
target_digest: targetDigest,
|
|
947
|
+
state,
|
|
948
|
+
result_after_denial: d.viaResult || lastResultSeq > d.seq,
|
|
949
|
+
};
|
|
950
|
+
});
|
|
951
|
+
const candidate = {
|
|
952
|
+
evidence_version: facts.verificationFacts ? COMPLETION_CONTRACT_VERSION_V2 : COMPLETION_CONTRACT_VERSION,
|
|
953
|
+
runtime: { ...facts.runtime },
|
|
954
|
+
binding: { ...binding, final_head_sha: facts.finalHeadSha },
|
|
955
|
+
session_id: sessionId,
|
|
956
|
+
evidence: sufficient ? "sufficient" : "insufficient",
|
|
957
|
+
insufficient_reasons: [...reasons],
|
|
958
|
+
execution,
|
|
959
|
+
verification: facts.verification,
|
|
960
|
+
finalization: facts.finalization,
|
|
961
|
+
cleanup: { ...facts.cleanup, reasons: [...facts.cleanup.reasons], residual_not_covered: [...facts.cleanup.residual_not_covered] },
|
|
962
|
+
tasks,
|
|
963
|
+
denials: denialRows,
|
|
964
|
+
missing_capabilities: [...facts.missingCapabilities],
|
|
965
|
+
results_seen: resultsSeen,
|
|
966
|
+
...(facts.verificationFacts ? { verification_facts: facts.verificationFacts } : {}),
|
|
967
|
+
};
|
|
968
|
+
const withNullCause = { ...candidate, cause: null };
|
|
969
|
+
const cause = successRefusalReason(withNullCause) === null ? null : deriveCompletionCause(candidate);
|
|
970
|
+
return boundEnvelope({ ...candidate, cause });
|
|
971
|
+
};
|
|
972
|
+
return {
|
|
973
|
+
ingestLine,
|
|
974
|
+
closureState,
|
|
975
|
+
recordStdinClosed: () => {
|
|
976
|
+
stdinClosed = true;
|
|
977
|
+
},
|
|
978
|
+
recordStreamEnd: (outcome) => {
|
|
979
|
+
// The first non-clean outcome wins; a later clean EOF cannot repair a lost stream.
|
|
980
|
+
if (streamEnd === null || (streamEnd === "eof" && outcome !== "eof"))
|
|
981
|
+
streamEnd = outcome;
|
|
982
|
+
},
|
|
983
|
+
recordProcessExit: (exit) => {
|
|
984
|
+
if (processExit === null)
|
|
985
|
+
processExit = { code: exit.code, signal: exit.signal };
|
|
986
|
+
},
|
|
987
|
+
recordTransportFailure: (beforeWork) => {
|
|
988
|
+
insufficient.add(beforeWork ? "transport_failed_before_work" : "transport_failed_after_progress");
|
|
989
|
+
},
|
|
990
|
+
recordAllowanceExhausted: () => {
|
|
991
|
+
insufficient.add("allowance_exhausted");
|
|
992
|
+
},
|
|
993
|
+
recordDeclarationRefused: () => {
|
|
994
|
+
insufficient.add("declaration_refused");
|
|
995
|
+
},
|
|
996
|
+
buildEnvelope,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
/** The measured settle window after the closure rule first holds (BAPI-1292 C6an-hold2). */
|
|
1000
|
+
export const CLOSURE_DEBOUNCE_MS = 1500;
|
|
1001
|
+
/**
|
|
1002
|
+
* Hold stdin open until the closure rule holds for a full debounce window, then
|
|
1003
|
+
* close it exactly once.
|
|
1004
|
+
*
|
|
1005
|
+
* Why the debounce: the first probe rule closed stdin the moment a result
|
|
1006
|
+
* arrived and cut off a notification-triggered turn that was about to start.
|
|
1007
|
+
* Every new activity resets the window, and closing is never the verdict: the
|
|
1008
|
+
* caller drains to EOF and the ledger recomputes, so a late turn still
|
|
1009
|
+
* invalidates the apparent closure (`late_activity_after_close`).
|
|
1010
|
+
*
|
|
1011
|
+
* The nudge: at most one, only when a turn ended while ONLY server-declared
|
|
1012
|
+
* optional watchers are live. It is never sent while required work is live and
|
|
1013
|
+
* never asks for a broad stop — cancelling required work is not the executor's
|
|
1014
|
+
* call.
|
|
1015
|
+
*/
|
|
1016
|
+
export function createClosureSupervisor(opts) {
|
|
1017
|
+
const { ledger, deps } = opts;
|
|
1018
|
+
const debounceMs = opts.debounceMs ?? CLOSURE_DEBOUNCE_MS;
|
|
1019
|
+
let closed = false;
|
|
1020
|
+
let closedReason = null;
|
|
1021
|
+
let nudged = false;
|
|
1022
|
+
let nudgeAwaitingReply = false;
|
|
1023
|
+
let disposed = false;
|
|
1024
|
+
let debounceTimer = null;
|
|
1025
|
+
let armedSeq = -1;
|
|
1026
|
+
let pending = Promise.resolve();
|
|
1027
|
+
const clearDebounce = () => {
|
|
1028
|
+
if (debounceTimer !== null)
|
|
1029
|
+
deps.clearTimer(debounceTimer);
|
|
1030
|
+
debounceTimer = null;
|
|
1031
|
+
armedSeq = -1;
|
|
1032
|
+
};
|
|
1033
|
+
const close = (reason) => {
|
|
1034
|
+
if (closed || disposed)
|
|
1035
|
+
return;
|
|
1036
|
+
closed = true;
|
|
1037
|
+
closedReason = reason;
|
|
1038
|
+
clearDebounce();
|
|
1039
|
+
if (deadlineTimer !== null)
|
|
1040
|
+
deps.clearTimer(deadlineTimer);
|
|
1041
|
+
deadlineTimer = null;
|
|
1042
|
+
ledger.recordStdinClosed();
|
|
1043
|
+
pending = pending.then(async () => {
|
|
1044
|
+
try {
|
|
1045
|
+
await opts.closeStdin();
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
// A failed close is observed by the caller as a missing EOF; the ledger
|
|
1049
|
+
// then reports `stdout_not_read_to_eof`, never success.
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
};
|
|
1053
|
+
let deadlineTimer = deps.setTimer(() => {
|
|
1054
|
+
deadlineTimer = null;
|
|
1055
|
+
close("deadline");
|
|
1056
|
+
}, Math.max(0, opts.deadlineAtMs - deps.now()));
|
|
1057
|
+
const onActivity = () => {
|
|
1058
|
+
if (closed || disposed)
|
|
1059
|
+
return;
|
|
1060
|
+
const s = ledger.closureState();
|
|
1061
|
+
if (armedSeq !== s.activitySeq)
|
|
1062
|
+
clearDebounce();
|
|
1063
|
+
if (!s.latestIsResult) {
|
|
1064
|
+
// The nudge was answered (or other activity began): the next result decides again.
|
|
1065
|
+
nudgeAwaitingReply = false;
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (!s.readyToClose)
|
|
1069
|
+
return;
|
|
1070
|
+
if (!nudged && s.liveOptional.length > 0) {
|
|
1071
|
+
nudged = true;
|
|
1072
|
+
nudgeAwaitingReply = true;
|
|
1073
|
+
const line = opts.buildNudge([...s.liveOptional]);
|
|
1074
|
+
pending = pending.then(async () => {
|
|
1075
|
+
let ok = false;
|
|
1076
|
+
try {
|
|
1077
|
+
ok = (await opts.writeMessage(line)).ok;
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
ok = false;
|
|
1081
|
+
}
|
|
1082
|
+
// An undelivered nudge will never be answered: fall back to the rule.
|
|
1083
|
+
if (!ok && !closed && !disposed) {
|
|
1084
|
+
nudgeAwaitingReply = false;
|
|
1085
|
+
onActivity();
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
// While the worker has a delivered nudge to answer, do not close under it;
|
|
1091
|
+
// the absolute deadline still bounds the wait.
|
|
1092
|
+
if (nudgeAwaitingReply)
|
|
1093
|
+
return;
|
|
1094
|
+
if (debounceTimer !== null)
|
|
1095
|
+
return;
|
|
1096
|
+
armedSeq = s.activitySeq;
|
|
1097
|
+
const seqAtArm = s.activitySeq;
|
|
1098
|
+
debounceTimer = deps.setTimer(() => {
|
|
1099
|
+
debounceTimer = null;
|
|
1100
|
+
armedSeq = -1;
|
|
1101
|
+
if (closed || disposed)
|
|
1102
|
+
return;
|
|
1103
|
+
const now = ledger.closureState();
|
|
1104
|
+
if (now.readyToClose && now.activitySeq === seqAtArm)
|
|
1105
|
+
close("rule");
|
|
1106
|
+
}, debounceMs);
|
|
1107
|
+
};
|
|
1108
|
+
return {
|
|
1109
|
+
onActivity,
|
|
1110
|
+
abort: () => close("abort"),
|
|
1111
|
+
state: () => ({ closed, closedReason, nudged }),
|
|
1112
|
+
settled: () => pending,
|
|
1113
|
+
dispose: () => {
|
|
1114
|
+
clearDebounce();
|
|
1115
|
+
if (deadlineTimer !== null)
|
|
1116
|
+
deps.clearTimer(deadlineTimer);
|
|
1117
|
+
deadlineTimer = null;
|
|
1118
|
+
disposed = true;
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
}
|