brainclaw 1.17.0 → 1.19.0
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 +5 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +223 -43
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/install-hooks.js +184 -27
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-claims.js +57 -0
- package/dist/commands/mcp-write-coordination.js +216 -57
- package/dist/commands/mcp-write-entities.js +11 -0
- package/dist/commands/mcp.js +29 -2
- package/dist/commands/session-end.js +15 -0
- package/dist/commands/session-start.js +19 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/claim-conformity.js +193 -0
- package/dist/core/claim-scope.js +155 -0
- package/dist/core/claims.js +127 -2
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +176 -22
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/facade-schema.js +32 -0
- package/dist/core/guidance-telemetry.js +197 -0
- package/dist/core/ideation-loop-close.js +152 -0
- package/dist/core/instruction-templates.js +11 -3
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +49 -4
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/next-actions.js +157 -0
- package/dist/core/review-loop-close.js +27 -6
- package/dist/core/review-loop-turn-dispatch.js +290 -28
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +64 -0
- package/dist/core/surface-freshness.js +150 -0
- package/dist/core/warnings.js +98 -0
- package/dist/core/worktree.js +24 -0
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +24 -0
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/concepts/plans-and-claims.md +57 -0
- package/docs/integrations/claude-code.md +53 -0
- package/docs/integrations/mcp.md +45 -0
- package/docs/mcp-schema-changelog.md +118 -2
- package/package.json +1 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifying a spawned worker: always `bclaw_dispatch_status`, never
|
|
3
|
+
* `bclaw_find(agent_run)` + a pid check. On Windows an ack-wrapped spawn runs
|
|
4
|
+
* under cmd.exe, so `agent_run.pid` is the wrapper (which exits by design) and
|
|
5
|
+
* reads dead while the worker is alive (trp_7fc3e3c4). `dispatch_status`
|
|
6
|
+
* returns a sentinel-based verdict instead.
|
|
7
|
+
*/
|
|
8
|
+
export function verifyDispatchAction(targetId, note) {
|
|
9
|
+
return {
|
|
10
|
+
tool: 'bclaw_dispatch_status',
|
|
11
|
+
args: { target_id: targetId },
|
|
12
|
+
when: note
|
|
13
|
+
? `${note} — sentinel-based liveness verdict (do NOT judge from agent_run.pid)`
|
|
14
|
+
: 'verify the spawned worker is actually alive — sentinel-based verdict (do NOT judge from agent_run.pid)',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Cap on repeated per-target actions, so a wide fan-out cannot flood the field. */
|
|
18
|
+
const FANOUT_CAP = 3;
|
|
19
|
+
function verifyActions(targetIds, note) {
|
|
20
|
+
const shown = targetIds.slice(0, FANOUT_CAP);
|
|
21
|
+
const actions = shown.map((id) => verifyDispatchAction(id, note));
|
|
22
|
+
if (targetIds.length > shown.length) {
|
|
23
|
+
// Say what was dropped rather than silently truncating.
|
|
24
|
+
actions.push({
|
|
25
|
+
tool: 'bclaw_dispatch_status',
|
|
26
|
+
args: { target_id: '<one of the remaining targets>' },
|
|
27
|
+
when: `${targetIds.length - shown.length} further target(s) were dispatched — verify each one the same way`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return actions;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* After a release, the follow-up depends entirely on what the cascade decided:
|
|
34
|
+
* a blocked plan transition needs the other claim holders inspected, a
|
|
35
|
+
* completed plan is ready for review, and a plain release has no next step at
|
|
36
|
+
* all.
|
|
37
|
+
*/
|
|
38
|
+
export function releaseClaimNextActions(outcome) {
|
|
39
|
+
const actions = [];
|
|
40
|
+
if (outcome.planWarning && outcome.planId) {
|
|
41
|
+
// The cascade refused: other claims still hold the plan. Both the diagnosis
|
|
42
|
+
// and the eventual manual transition are real MCP calls.
|
|
43
|
+
actions.push({
|
|
44
|
+
tool: 'bclaw_find',
|
|
45
|
+
args: { entity: 'claim', filter: { plan_id: outcome.planId, status: 'active' } },
|
|
46
|
+
when: 'the plan was NOT transitioned because other claims are still active — see who else holds it',
|
|
47
|
+
});
|
|
48
|
+
actions.push({
|
|
49
|
+
tool: 'bclaw_transition',
|
|
50
|
+
args: { entity: 'plan', id: outcome.planId, to: outcome.requestedPlanStatus ?? 'done' },
|
|
51
|
+
when: 'once the other claims are released, transition the plan yourself',
|
|
52
|
+
});
|
|
53
|
+
return actions;
|
|
54
|
+
}
|
|
55
|
+
if (outcome.planTransitioned && outcome.planId) {
|
|
56
|
+
// Documented workflow: implement → release → review.
|
|
57
|
+
actions.push({
|
|
58
|
+
tool: 'bclaw_coordinate',
|
|
59
|
+
args: {
|
|
60
|
+
intent: 'review',
|
|
61
|
+
task: `Review the work delivered under plan ${outcome.planId}`,
|
|
62
|
+
open_loop: true,
|
|
63
|
+
},
|
|
64
|
+
when: 'the plan is done — the next workflow stage is review',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return actions;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Only two transitions imply an unambiguous next call. Everything else
|
|
71
|
+
* (candidate accepted, plan done, trap retired, …) is terminal for the caller,
|
|
72
|
+
* so it returns nothing rather than inventing busywork.
|
|
73
|
+
*/
|
|
74
|
+
export function transitionNextActions(outcome) {
|
|
75
|
+
if (outcome.entity === 'plan' && outcome.to === 'in_progress') {
|
|
76
|
+
return [{
|
|
77
|
+
tool: 'bclaw_work',
|
|
78
|
+
args: { intent: 'execute', planId: outcome.id, scope: '<scope you are about to edit>' },
|
|
79
|
+
when: 'the plan is in progress — claim the scope before editing',
|
|
80
|
+
}];
|
|
81
|
+
}
|
|
82
|
+
if (outcome.entity === 'plan' && outcome.to === 'blocked') {
|
|
83
|
+
return [{
|
|
84
|
+
tool: 'bclaw_quick_capture',
|
|
85
|
+
args: { text: '<what blocks this plan>', type: 'trap' },
|
|
86
|
+
when: 'record WHY it is blocked so the next agent does not rediscover it',
|
|
87
|
+
}];
|
|
88
|
+
}
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Coordinate's follow-up is driven by whether anything actually spawned, not by
|
|
93
|
+
* the intent alone: the same `intent='assign'` needs verification when it
|
|
94
|
+
* spawned and nothing MCP-callable when it produced manual commands.
|
|
95
|
+
*/
|
|
96
|
+
export function coordinateNextActions(outcome) {
|
|
97
|
+
const actions = [];
|
|
98
|
+
const spawned = outcome.executionStatus === 'delivered_and_started';
|
|
99
|
+
if (spawned && outcome.assignmentIds.length > 0) {
|
|
100
|
+
actions.push(...verifyActions(outcome.assignmentIds));
|
|
101
|
+
}
|
|
102
|
+
if (outcome.loopId) {
|
|
103
|
+
actions.push({
|
|
104
|
+
tool: 'bclaw_loop',
|
|
105
|
+
args: { intent: 'get', loop_id: outcome.loopId },
|
|
106
|
+
when: spawned
|
|
107
|
+
? 'inspect loop state — its `next_expected` names the turn the loop is waiting on'
|
|
108
|
+
: 'the loop is open but nothing spawned — inspect it and drive the turn yourself',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// Manual-handoff spawning intents: the launch commands are in the text body
|
|
112
|
+
// (not MCP-callable), so the only real MCP follow-up is verification AFTER
|
|
113
|
+
// the operator runs them.
|
|
114
|
+
if (!spawned && outcome.executionStatus === 'command_ready_manual' && outcome.assignmentIds.length > 0) {
|
|
115
|
+
actions.push(verifyActions(outcome.assignmentIds, 'once you have run the launch command(s) printed above')[0]);
|
|
116
|
+
}
|
|
117
|
+
return actions;
|
|
118
|
+
}
|
|
119
|
+
export function dispatchNextActions(outcome) {
|
|
120
|
+
if (outcome.dryRun) {
|
|
121
|
+
return [{
|
|
122
|
+
tool: 'bclaw_dispatch',
|
|
123
|
+
args: { intent: 'execute' },
|
|
124
|
+
when: 'this was a dry run — nothing was dispatched; re-run without dryRun to actually spawn',
|
|
125
|
+
}];
|
|
126
|
+
}
|
|
127
|
+
const actions = [];
|
|
128
|
+
if (outcome.spawnedTargets.length > 0) {
|
|
129
|
+
actions.push(...verifyActions(outcome.spawnedTargets));
|
|
130
|
+
}
|
|
131
|
+
if (outcome.blockedCount > 0) {
|
|
132
|
+
actions.push({
|
|
133
|
+
tool: 'bclaw_dispatch',
|
|
134
|
+
args: { intent: 'analysis' },
|
|
135
|
+
when: `${outcome.blockedCount} lane(s) are blocked — analysis explains which gate holds each one`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return actions;
|
|
139
|
+
}
|
|
140
|
+
export function createEntityNextActions(outcome) {
|
|
141
|
+
if (outcome.entity === 'plan') {
|
|
142
|
+
return [{
|
|
143
|
+
tool: 'bclaw_add_step',
|
|
144
|
+
args: { planId: outcome.id, data: { text: '<first unit of work>' } },
|
|
145
|
+
when: 'break the plan into steps so progress is trackable',
|
|
146
|
+
}];
|
|
147
|
+
}
|
|
148
|
+
if (outcome.entity === 'sequence') {
|
|
149
|
+
return [{
|
|
150
|
+
tool: 'bclaw_dispatch',
|
|
151
|
+
args: { intent: 'analysis' },
|
|
152
|
+
when: 'inspect lane readiness before dispatching the sequence',
|
|
153
|
+
}];
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=next-actions.js.map
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { getLoop } from './loops/store.js';
|
|
2
2
|
import { complete_turn, advance } from './loops/verbs.js';
|
|
3
3
|
import { withLoopLock } from './loops/lock.js';
|
|
4
|
+
import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './loops/types.js';
|
|
4
5
|
/** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
|
|
5
6
|
const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
|
|
6
7
|
const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
|
|
7
|
-
/**
|
|
8
|
-
function
|
|
8
|
+
/** Keep the loop-facing verdict valid while the full worker body stays durable in harvest metadata. */
|
|
9
|
+
function capVerdictBody(prefix, detail) {
|
|
10
|
+
const full = `${prefix}${detail ? `: ${detail}` : ''}`;
|
|
11
|
+
if (Buffer.byteLength(full, 'utf8') <= LOOP_ARTIFACT_BODY_MAX_BYTES)
|
|
12
|
+
return full;
|
|
13
|
+
const marker = '…[truncated; full body retained in lane harvest event]';
|
|
14
|
+
const room = LOOP_ARTIFACT_BODY_MAX_BYTES - Buffer.byteLength(prefix, 'utf8') - Buffer.byteLength(': ', 'utf8') - Buffer.byteLength(marker, 'utf8');
|
|
15
|
+
return `${prefix}: ${Buffer.from(detail, 'utf8').subarray(0, Math.max(0, room)).toString('utf8').replace(/�+$/, '')}${marker}`;
|
|
16
|
+
}
|
|
17
|
+
/** Build the fix+re-review brief for a request_changes cycle turn (symmetric).
|
|
18
|
+
* Exported so the turn-owned reconcile path (pln#630 PR3b) reuses the identical
|
|
19
|
+
* wording — the reviewer contract must not drift between the legacy and turn-owned
|
|
20
|
+
* fix cycles. */
|
|
21
|
+
export function buildFixCycleTask(summary, iteration) {
|
|
9
22
|
return (`The reviewer requested changes (fix cycle round ${iteration}). `
|
|
10
23
|
+ 'Apply the requested changes DIRECTLY in this worktree (it is the same '
|
|
11
24
|
+ 'checkout, kept across turns so your commits accumulate), then RE-REVIEW '
|
|
@@ -75,14 +88,21 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
|
|
|
75
88
|
return noop(`loop already ${loop.status}`, loop.status);
|
|
76
89
|
const slot = resolveReviewerSlot(loop, assignment);
|
|
77
90
|
const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
|
|
78
|
-
const
|
|
91
|
+
const detail = (lane.body ?? lane.review_summary ?? '').trim();
|
|
79
92
|
// ── approve → close on reviewer_green ───────────────────────────────
|
|
80
93
|
if (verdict === 'approve') {
|
|
81
94
|
if (slot) {
|
|
82
95
|
// isVerdictAccepted fires reviewer_green ONLY on an "accepted…" body.
|
|
83
96
|
complete_turn({
|
|
84
97
|
id: loopId, slot_id: slot.slot_id, actor,
|
|
85
|
-
|
|
98
|
+
// pln#639 BUG-2 — the phase the slot was DISPATCHED in, not the
|
|
99
|
+
// loop's phase at close time. Same defect as the ideation closer;
|
|
100
|
+
// fixed here too because this is the far more travelled path.
|
|
101
|
+
// Safe for the approve flow: `reviewer_green` scans every artifact
|
|
102
|
+
// via isVerdictAccepted regardless of phase, and no gate in the
|
|
103
|
+
// engine keys on `type: 'verdict'` — so this changes attribution
|
|
104
|
+
// truth without changing a single gate outcome.
|
|
105
|
+
artifact: { phase: slot.phase ?? loop.current_phase, type: 'verdict', body: capVerdictBody('accepted', detail) },
|
|
86
106
|
}, cwd);
|
|
87
107
|
}
|
|
88
108
|
else if (!acceptedVerdictExists) {
|
|
@@ -127,7 +147,8 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
|
|
|
127
147
|
const symmetric = loop.protocol?.review_mode === 'symmetric';
|
|
128
148
|
complete_turn({
|
|
129
149
|
id: loopId, slot_id: slot.slot_id, actor,
|
|
130
|
-
|
|
150
|
+
// pln#639 BUG-2 — dispatch phase, not close-time phase (see above).
|
|
151
|
+
artifact: { phase: slot.phase ?? loop.current_phase, type: 'verdict', body: capVerdictBody('changes-requested', detail) },
|
|
131
152
|
}, cwd);
|
|
132
153
|
if (!symmetric) {
|
|
133
154
|
const advancedAsym = advance({ id: loopId, actor }, cwd);
|
|
@@ -171,7 +192,7 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
|
|
|
171
192
|
agent_id: slot.agent_id,
|
|
172
193
|
phase: advanced.loop.current_phase,
|
|
173
194
|
iteration: advanced.loop.iteration_count,
|
|
174
|
-
task: buildFixCycleTask(
|
|
195
|
+
task: buildFixCycleTask(detail, advanced.loop.iteration_count),
|
|
175
196
|
},
|
|
176
197
|
};
|
|
177
198
|
},
|
|
@@ -19,12 +19,205 @@
|
|
|
19
19
|
* here imports harvest or review-loop-close, so no import cycle is introduced.
|
|
20
20
|
*/
|
|
21
21
|
import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
|
|
22
|
-
import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
|
|
22
|
+
import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId, loadAssignment } from './assignments.js';
|
|
23
23
|
import { turn } from './loops/verbs.js';
|
|
24
|
+
import { getLoop } from './loops/store.js';
|
|
24
25
|
import { generateDispatchBrief } from './dispatcher.js';
|
|
25
26
|
import { sendMessage } from './messaging.js';
|
|
26
27
|
import { buildInvokeCommand, resolveModel } from './agent-capability.js';
|
|
27
28
|
import { attemptExecution } from './execution.js';
|
|
29
|
+
import { createAgentRun, loadAgentRun, transitionAgentRun } from './agentruns.js';
|
|
30
|
+
import { reserve, commitReservation, armLaunch, consumeLaunchGrant, launchGrant, deriveTurnId, deriveChildIds, ReservationStateError, LaunchFenceError, } from './loops/attempt-reservation.js';
|
|
31
|
+
/**
|
|
32
|
+
* pln#630 — gate for the turn-owned (exactly-once) review dispatch path.
|
|
33
|
+
*
|
|
34
|
+
* NOW DEFAULT ON (the shipped default): the finalization (PR3a), autonomous fix-cycle (PR3b),
|
|
35
|
+
* strand self-heal (PR4), and revoked-grant recovery (R1) are all merged; the §9 conformance
|
|
36
|
+
* harness + a live end-to-end (real spawn → turn-keyed sentinel → harvest → reconcileTurn →
|
|
37
|
+
* close-on-approve) validated the path. `BRAINCLAW_TURN_OWNED_REVIEW=0` is the explicit
|
|
38
|
+
* KILL-SWITCH that reverts to the legacy closer (byte-identical) if a problem surfaces in prod.
|
|
39
|
+
* Note the routing is doubly-gated: even ON, harvest only reconcile-turns a lane that OWNS a
|
|
40
|
+
* turn reservation — a legacy-dispatched lane (no reservation) still uses the legacy path.
|
|
41
|
+
*/
|
|
42
|
+
export function turnOwnedReviewEnabled() {
|
|
43
|
+
// Normalized kill-switch (review Finding 4): any of 0/false/off/no (case/space-insensitive)
|
|
44
|
+
// disables — so an operator reaching for it under pressure can't mis-set it. Anything else
|
|
45
|
+
// (including unset) keeps the shipped default ON.
|
|
46
|
+
const v = (process.env.BRAINCLAW_TURN_OWNED_REVIEW ?? '').trim().toLowerCase();
|
|
47
|
+
return !['0', 'false', 'off', 'no'].includes(v);
|
|
48
|
+
}
|
|
49
|
+
/** GRANT lease: bounds ONE launch generation's reserve→arm→consume→spawn→run-`running`
|
|
50
|
+
* window. Long enough that a genuinely launching worker never expires under the PR2c-lease
|
|
51
|
+
* pre-run reconciler; a live `running` run is out of that reconciler's scope. */
|
|
52
|
+
const TURN_OWNED_LEASE_MS = 10 * 60_000;
|
|
53
|
+
/** DISPATCH lease: how long the committed RESERVATION stays re-dispatchable. Strictly LONGER
|
|
54
|
+
* than the grant lease (pln#630 dec#149 R1 / review Finding 1): a reserved_never_launched
|
|
55
|
+
* round (grant lease expired → the reconciler revokes the grant) then still has a RECOVERY
|
|
56
|
+
* WINDOW to re-arm a fresh generation before the reservation itself expires. armLaunch
|
|
57
|
+
* enforces this bound, so a re-dispatch past it refuses arm and stays correctly stranded —
|
|
58
|
+
* no phantom-spawn-after-lease. Decoupling the two is what makes R1 recovery actually reachable
|
|
59
|
+
* (with a single shared lease, the grant is revoked exactly when the dispatch lease is already
|
|
60
|
+
* expired, so re-arm was always denied). */
|
|
61
|
+
const TURN_OWNED_DISPATCH_LEASE_MS = 30 * 60_000;
|
|
62
|
+
/**
|
|
63
|
+
* Prepare a turn-owned review dispatch (pln#630 PR2c-b, design dec#144). Runs the
|
|
64
|
+
* exactly-once machine INLINE in the coordinator (which has store access, unlike
|
|
65
|
+
* the sandboxed worktree — trp_26e9634b): deterministic turn_id → reserve/adopt →
|
|
66
|
+
* commit → arm/adopt → consume, spawning ONLY on the winning consume.
|
|
67
|
+
*
|
|
68
|
+
* FAIL-CLOSED after `reserve`: once identity is claimed, any error aborts as
|
|
69
|
+
* `denied` (never legacy) so an ungated legacy worker can never spawn beside a
|
|
70
|
+
* live reservation (the adversarial double-spawn hole, dec#144 MUST-FIX 1). Only
|
|
71
|
+
* a failure BEFORE identity is reserved degrades to `legacy`.
|
|
72
|
+
*/
|
|
73
|
+
export function prepareTurnOwnedReviewDispatch(input) {
|
|
74
|
+
const { loopId, slotId, claimId, cwd } = input;
|
|
75
|
+
// ── Snapshot the loop BEFORE turn() bumps its version (dec#139 item 3). ──
|
|
76
|
+
let iteration;
|
|
77
|
+
let version;
|
|
78
|
+
try {
|
|
79
|
+
const thread = getLoop(loopId, cwd);
|
|
80
|
+
if (!thread)
|
|
81
|
+
return { kind: 'legacy' }; // loop not found — pre-identity, safe to degrade
|
|
82
|
+
iteration = thread.iteration_count;
|
|
83
|
+
version = thread.version;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { kind: 'legacy' };
|
|
87
|
+
}
|
|
88
|
+
const turnId = deriveTurnId(loopId, slotId, iteration);
|
|
89
|
+
const { assignment_id: assignmentId, run_id: runId } = deriveChildIds(turnId);
|
|
90
|
+
// Decoupled leases (dec#149 R1): the reservation dispatch lease is longer than each grant's
|
|
91
|
+
// lease, giving a revoked (reserved_never_launched) round a window to re-arm. reserve() adopts
|
|
92
|
+
// on a re-dispatch, so the ORIGINAL (longer) dispatch lease governs re-arm eligibility.
|
|
93
|
+
const dispatchLease = new Date(Date.now() + TURN_OWNED_DISPATCH_LEASE_MS).toISOString();
|
|
94
|
+
const grantLease = new Date(Date.now() + TURN_OWNED_LEASE_MS).toISOString();
|
|
95
|
+
// ── Phase 1: claim identity. Fail-OPEN allowed ONLY here (nothing reserved yet). ──
|
|
96
|
+
try {
|
|
97
|
+
reserve({
|
|
98
|
+
turn_id: turnId,
|
|
99
|
+
loop_id: loopId,
|
|
100
|
+
slot_id: slotId,
|
|
101
|
+
target_slot_generation: iteration, // LoopSlot has no generation field — observational proxy (dec#144 #8)
|
|
102
|
+
loop_version_at_reserve: version,
|
|
103
|
+
agent: input.agent,
|
|
104
|
+
agent_id: input.agentId,
|
|
105
|
+
claim_id: claimId,
|
|
106
|
+
phase: input.phase,
|
|
107
|
+
iteration,
|
|
108
|
+
store_root: cwd,
|
|
109
|
+
cwd,
|
|
110
|
+
lease_deadline: dispatchLease,
|
|
111
|
+
}, cwd);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (err instanceof ReservationStateError && err.code === 'reservation_exists') {
|
|
115
|
+
// A concurrent dispatch already OWNS this turn_id — adopt it and fall
|
|
116
|
+
// through to the fail-closed consume path (we may still legitimately win
|
|
117
|
+
// the fence if the owner reserved-but-never-crossed; otherwise denied).
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// FAIL-CLOSED (review Finding 1): any other reserve outcome is
|
|
121
|
+
// INDETERMINATE — a lock timeout (a live holder mid-critical-section),
|
|
122
|
+
// lock-lost, or unknown error does NOT prove that no identity was claimed.
|
|
123
|
+
// Degrading to `legacy` here would spawn an ungated worker beside a
|
|
124
|
+
// reservation that may well exist — the exact concurrent-dispatch
|
|
125
|
+
// double-spawn MUST-FIX 1 closes. (A definitively pre-identity error is
|
|
126
|
+
// unreachable here: the lease we build is always parseable, and
|
|
127
|
+
// loop-not-found is handled before reserve.)
|
|
128
|
+
return { kind: 'denied', reason: `reserve indeterminate — fail-closed (no legacy fallback): ${err instanceof Error ? err.message : String(err)}` };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ── Phase 2: FAIL-CLOSED. Identity is reserved; never legacy-spawn from here. ──
|
|
132
|
+
try {
|
|
133
|
+
commitReservation(turnId, cwd);
|
|
134
|
+
// Arm-or-adopt the launch grant. Arm when none exists OR when a prior generation was
|
|
135
|
+
// REVOKED (reserved_never_launched — a crash between arm and consume, then the expiry
|
|
136
|
+
// sweep): re-arm a FRESH generation at a strictly-higher epoch so a revoked round becomes
|
|
137
|
+
// re-dispatchable (pln#630 dec#149 R1 strand recovery). armLaunch permits re-arming a
|
|
138
|
+
// revoked grant and still enforces the dispatch lease, so a lease-expired reservation
|
|
139
|
+
// refuses arm and stays correctly stranded (never a phantom-spawn-after-lease). A
|
|
140
|
+
// concurrent arm surfaces as `already_armed` → adopt the incumbent grant.
|
|
141
|
+
let grant = launchGrant(turnId, cwd);
|
|
142
|
+
if (!grant || grant.status === 'revoked') {
|
|
143
|
+
const priorEpoch = grant?.epoch ?? -1; // fresh → epoch 0 (unchanged); revoked → epoch+1
|
|
144
|
+
try {
|
|
145
|
+
armLaunch(turnId, { epoch: priorEpoch + 1, lease_deadline: grantLease }, cwd);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (!(err instanceof LaunchFenceError && err.code === 'already_armed')) {
|
|
149
|
+
// dispatch_lease_expired / lease_invalid / not_committed / epoch_mismatch → do-not-spawn.
|
|
150
|
+
return { kind: 'denied', reason: `arm_refused: ${err instanceof Error ? err.message : String(err)}` };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
grant = launchGrant(turnId, cwd);
|
|
154
|
+
}
|
|
155
|
+
// A crossed grant = launch_attempted_unknown (worker already invoked, never re-spawn);
|
|
156
|
+
// still-revoked = re-arm refused (lease expired) → never-launch; absent → all do-not-spawn.
|
|
157
|
+
if (!grant || grant.status !== 'armed') {
|
|
158
|
+
return { kind: 'denied', reason: `launch_denied: grant is ${grant?.status ?? 'absent'} (not armed)` };
|
|
159
|
+
}
|
|
160
|
+
// Consume the grant — the atomic exactly-once SPAWN authority.
|
|
161
|
+
let wonTransition;
|
|
162
|
+
try {
|
|
163
|
+
({ wonTransition } = consumeLaunchGrant(turnId, grant.token, grant.epoch, cwd));
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return { kind: 'denied', reason: `launch_denied: consume refused (${err instanceof Error ? err.message : String(err)})` };
|
|
167
|
+
}
|
|
168
|
+
if (!wonTransition) {
|
|
169
|
+
// Adopted — another invocation crossed the fence. MUST NOT spawn.
|
|
170
|
+
return { kind: 'denied', reason: 'launch_denied: grant already crossed by a concurrent dispatch' };
|
|
171
|
+
}
|
|
172
|
+
// ── WON: this dispatch is the SOLE spawner. Bind slot + run to MY live claim
|
|
173
|
+
// (claimId), NOT the reservation's first-reserver claim (dec#144 #3) — else a
|
|
174
|
+
// recovery-winner would bind the slot to a dead claim and break complete_turn
|
|
175
|
+
// auth. Mints are idempotent (save overwrites, so guard on load). ──
|
|
176
|
+
if (!loadAssignment(assignmentId, cwd)) {
|
|
177
|
+
createAssignment({
|
|
178
|
+
id: assignmentId,
|
|
179
|
+
short_label: assignmentId,
|
|
180
|
+
claim_id: claimId,
|
|
181
|
+
agent: input.agent,
|
|
182
|
+
dispatcher_agent: input.dispatcherAgent,
|
|
183
|
+
dispatcher_session_id: input.sessionId,
|
|
184
|
+
scope: input.scope,
|
|
185
|
+
description: input.description,
|
|
186
|
+
tags: ['coordinate', 'review', 'loop', 'turn-owned', input.isReviewer ? 're-review' : 'author-fix'],
|
|
187
|
+
}, cwd);
|
|
188
|
+
}
|
|
189
|
+
if (!loadAgentRun(runId, cwd)) {
|
|
190
|
+
createAgentRun({
|
|
191
|
+
id: runId,
|
|
192
|
+
short_label: runId,
|
|
193
|
+
assignment_id: assignmentId,
|
|
194
|
+
claim_id: claimId,
|
|
195
|
+
agent: input.agent,
|
|
196
|
+
agent_id: input.agentId,
|
|
197
|
+
transport: 'cli_spawn',
|
|
198
|
+
status: 'created',
|
|
199
|
+
scope: input.scope,
|
|
200
|
+
description: input.description,
|
|
201
|
+
worktree_path: input.worktreePath,
|
|
202
|
+
tags: ['turn-owned', 'review', 'loop'],
|
|
203
|
+
}, cwd);
|
|
204
|
+
}
|
|
205
|
+
turn({
|
|
206
|
+
id: loopId,
|
|
207
|
+
slot_id: slotId,
|
|
208
|
+
actor: input.dispatcherAgentId ?? input.dispatcherAgent,
|
|
209
|
+
input: input.task,
|
|
210
|
+
turn_id: turnId,
|
|
211
|
+
assignment_id: assignmentId,
|
|
212
|
+
claim_id: claimId,
|
|
213
|
+
}, cwd);
|
|
214
|
+
return { kind: 'won', assignmentId, runId, turnId, nonce: grant.token };
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
// FAIL-CLOSED: identity reserved; degrade to denied, NEVER legacy.
|
|
218
|
+
return { kind: 'denied', reason: `turn-owned prep aborted after reserve: ${err instanceof Error ? err.message : String(err)}` };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
28
221
|
/**
|
|
29
222
|
* The structured signal a reviewer must emit in LANE-RESULT.json so harvest can
|
|
30
223
|
* map its lane back onto the loop. Shared by the initial dispatch and every
|
|
@@ -75,37 +268,94 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
75
268
|
result.claim_id = claimResult.claimId;
|
|
76
269
|
result.worktree_path = claimResult.worktreePath;
|
|
77
270
|
let assignmentId;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
271
|
+
let turnEcho;
|
|
272
|
+
let runLegacyProjection = true;
|
|
273
|
+
// pln#630 — turn-owned (exactly-once) dispatch, now the DEFAULT + FAIL-CLOSED after
|
|
274
|
+
// reserve. Kill-switch off (BRAINCLAW_TURN_OWNED_REVIEW=0) → runLegacyProjection stays
|
|
275
|
+
// true and this branch is a byte-identical no-op (the legacy projection below runs).
|
|
276
|
+
if (turnOwnedReviewEnabled()) {
|
|
277
|
+
const prep = prepareTurnOwnedReviewDispatch({
|
|
278
|
+
loopId,
|
|
279
|
+
slotId: slot.slot_id,
|
|
84
280
|
agent,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
281
|
+
agentId: slot.agent_id,
|
|
282
|
+
phase,
|
|
283
|
+
task: input.task,
|
|
88
284
|
description,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
285
|
+
scope,
|
|
286
|
+
claimId: claimResult.claimId,
|
|
287
|
+
worktreePath: claimResult.worktreePath,
|
|
288
|
+
dispatcherAgent: input.dispatcherAgent,
|
|
289
|
+
dispatcherAgentId: input.dispatcherAgentId,
|
|
290
|
+
sessionId: input.sessionId,
|
|
291
|
+
isReviewer,
|
|
292
|
+
cwd,
|
|
293
|
+
});
|
|
294
|
+
if (prep.kind === 'denied') {
|
|
295
|
+
// The exactly-once fence says this dispatch is NOT the spawner (adopted /
|
|
296
|
+
// crossed / revoked / lease-expired). MUST NOT spawn AND MUST NOT fall back
|
|
297
|
+
// to legacy — a legacy spawn beside the live reservation is the double-spawn
|
|
298
|
+
// hole the fence exists to close (dec#144 MUST-FIX 1).
|
|
299
|
+
//
|
|
300
|
+
// Do NOT release the coordinator claim here (review Finding 2, round 2):
|
|
301
|
+
// createCoordinatorClaim dedups on scope+agent, so a same-turn concurrent
|
|
302
|
+
// dispatch SHARES one claim C1 — and claim-creation order is uncoupled from
|
|
303
|
+
// fence-crossing order (different locks), so the loser can be C1's creator
|
|
304
|
+
// while the WINNER merely reused it and bound its slot/run/assignment to C1.
|
|
305
|
+
// releaseClaim() runs unauthenticated with no active-binding guard, so ANY
|
|
306
|
+
// release in the denied path can flip a claim a live winner depends on to
|
|
307
|
+
// `released` (re-opening the slot.claim_id divergence MUST-FIX 3 closed). A
|
|
308
|
+
// genuine orphan — the rare double-failure where no dispatch wins — is
|
|
309
|
+
// low-harm and reaped by the claim staleness sweep (auto_release_after_hours);
|
|
310
|
+
// sabotaging a live winner is high-harm and not self-healing. So we leave it.
|
|
311
|
+
result.execution_status = 'inbox_only';
|
|
312
|
+
result.error = prep.reason;
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
if (prep.kind === 'won') {
|
|
316
|
+
// Deterministic assignment mint + slot binding already happened inside
|
|
317
|
+
// prepare; skip the legacy projection and carry the turn-keyed echo so the
|
|
318
|
+
// ack-wrapper writes a turn-keyed completion sentinel.
|
|
319
|
+
assignmentId = prep.assignmentId;
|
|
320
|
+
result.assignment_id = prep.assignmentId;
|
|
321
|
+
turnEcho = { turn_id: prep.turnId, run_id: prep.runId, nonce: prep.nonce };
|
|
322
|
+
runLegacyProjection = false;
|
|
323
|
+
}
|
|
324
|
+
// prep.kind === 'legacy' (fail-open BEFORE identity) → fall through unchanged.
|
|
93
325
|
}
|
|
94
|
-
|
|
95
|
-
|
|
326
|
+
if (runLegacyProjection) {
|
|
327
|
+
try {
|
|
328
|
+
const preId = generateAssignmentId(cwd);
|
|
329
|
+
const assignment = createAssignment({
|
|
330
|
+
id: preId.id,
|
|
331
|
+
short_label: preId.short_label,
|
|
332
|
+
claim_id: claimResult.claimId,
|
|
333
|
+
agent,
|
|
334
|
+
dispatcher_agent: input.dispatcherAgent,
|
|
335
|
+
dispatcher_session_id: input.sessionId,
|
|
336
|
+
scope,
|
|
337
|
+
description,
|
|
338
|
+
tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
|
|
339
|
+
}, cwd);
|
|
340
|
+
assignmentId = assignment.id;
|
|
341
|
+
result.assignment_id = assignment.id;
|
|
342
|
+
}
|
|
343
|
+
catch (asgErr) {
|
|
344
|
+
result.error = `assignment creation failed: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`;
|
|
345
|
+
}
|
|
346
|
+
// Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
|
|
347
|
+
// later harvest must resolve THIS slot by assignment_id, not by agent name
|
|
348
|
+
// (which is ambiguous under symmetric multi-reviewer loops). Runs even if
|
|
349
|
+
// assignment creation failed (undefined id → legacy agent-match fallback).
|
|
350
|
+
turn({
|
|
351
|
+
id: loopId,
|
|
352
|
+
slot_id: slot.slot_id,
|
|
353
|
+
actor: input.dispatcherAgentId ?? input.dispatcherAgent,
|
|
354
|
+
input: input.task,
|
|
355
|
+
assignment_id: assignmentId,
|
|
356
|
+
claim_id: claimResult.claimId,
|
|
357
|
+
}, cwd);
|
|
96
358
|
}
|
|
97
|
-
// Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
|
|
98
|
-
// later harvest must resolve THIS slot by assignment_id, not by agent name
|
|
99
|
-
// (which is ambiguous under symmetric multi-reviewer loops). Runs even if
|
|
100
|
-
// assignment creation failed (undefined id → legacy agent-match fallback).
|
|
101
|
-
turn({
|
|
102
|
-
id: loopId,
|
|
103
|
-
slot_id: slot.slot_id,
|
|
104
|
-
actor: input.dispatcherAgentId ?? input.dispatcherAgent,
|
|
105
|
-
input: input.task,
|
|
106
|
-
assignment_id: assignmentId,
|
|
107
|
-
claim_id: claimResult.claimId,
|
|
108
|
-
}, cwd);
|
|
109
359
|
// Reviewer turns must carry the verdict contract; author-fix turns must not
|
|
110
360
|
// (an author lane has no verdict — it's mapped by scope+slot instead).
|
|
111
361
|
const briefTask = isReviewer ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX : input.task;
|
|
@@ -167,12 +417,24 @@ export async function dispatchReviewLoopTurn(input) {
|
|
|
167
417
|
dispatcherAgentId: input.dispatcherAgentId,
|
|
168
418
|
cwd,
|
|
169
419
|
requireWorktree: true, // never spawn a worker in the integration repo (pln#531)
|
|
420
|
+
turnEcho, // pln#630 PR2c-b — undefined on the legacy path (wrapper unchanged)
|
|
170
421
|
});
|
|
171
422
|
result.execution_status = execResult.execution_status;
|
|
172
423
|
result.command = execResult.command;
|
|
173
424
|
result.shell = execResult.shell;
|
|
174
425
|
if (execResult.error && !result.error)
|
|
175
426
|
result.error = execResult.error;
|
|
427
|
+
// pln#630 PR2c-b — a turn-owned run was preallocated `created`; once the
|
|
428
|
+
// worker actually spawned, move it to `running` so it leaves the PR2c-lease
|
|
429
|
+
// pre-run lease scope (created/launching) and is governed by the heartbeat
|
|
430
|
+
// reconciler instead. If it did NOT start, leave it `created` → the pre-run
|
|
431
|
+
// reconciler converges it (crossed → launch_attempted_unknown) at lease.
|
|
432
|
+
if (turnEcho && execResult.execution_status === 'delivered_and_started') {
|
|
433
|
+
try {
|
|
434
|
+
transitionAgentRun(turnEcho.run_id, 'running', { actor: input.dispatcherAgent, status_reason: 'turn-owned worker spawned' }, cwd);
|
|
435
|
+
}
|
|
436
|
+
catch { /* best-effort — the reconciler converges if this races */ }
|
|
437
|
+
}
|
|
176
438
|
return result;
|
|
177
439
|
}
|
|
178
440
|
catch (err) {
|
|
@@ -114,6 +114,74 @@ export function readHeartbeat(root, assignmentId, worktreePath) {
|
|
|
114
114
|
return projectInfo;
|
|
115
115
|
return (worktreeInfo.mtimeMs ?? 0) > (projectInfo.mtimeMs ?? 0) ? worktreeInfo : projectInfo;
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Write a turn-keyed completion/failed sentinel body. Used when brainclaw itself
|
|
119
|
+
* (wrapper/reconcile) writes the sentinel; the shell `&& completed` fallback
|
|
120
|
+
* still produces a legacy presence-only marker, which stays a valid life-sign
|
|
121
|
+
* via signalExists but is NOT accepted as turn-owned evidence (PR2b-c).
|
|
122
|
+
*/
|
|
123
|
+
export function writeCompletionSignal(root, assignmentId, body) {
|
|
124
|
+
const p = getRuntimeSignalPath(root, assignmentId, body.status);
|
|
125
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
126
|
+
fs.writeFileSync(p, JSON.stringify(body), 'utf-8');
|
|
127
|
+
}
|
|
128
|
+
/** Parse ONE turn-keyed sentinel body, or undefined if absent / legacy
|
|
129
|
+
* presence-only / non-JSON / missing correlation keys. Never throws. */
|
|
130
|
+
function readOneCompletionSignal(root, assignmentId, status) {
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = fs.readFileSync(getRuntimeSignalPath(root, assignmentId, status), 'utf-8').trim();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return undefined; // sentinel absent
|
|
137
|
+
}
|
|
138
|
+
if (!raw)
|
|
139
|
+
return undefined; // legacy presence-only (empty) marker
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(raw);
|
|
142
|
+
if (typeof parsed.turn_id === 'string' &&
|
|
143
|
+
typeof parsed.run_id === 'string' &&
|
|
144
|
+
typeof parsed.nonce === 'string' &&
|
|
145
|
+
(parsed.status === 'completed' || parsed.status === 'failed')) {
|
|
146
|
+
return {
|
|
147
|
+
turn_id: parsed.turn_id,
|
|
148
|
+
run_id: parsed.run_id,
|
|
149
|
+
nonce: parsed.nonce,
|
|
150
|
+
status: parsed.status,
|
|
151
|
+
at: typeof parsed.at === 'string' ? parsed.at : '',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch { /* non-JSON legacy body */ }
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Read BOTH turn-keyed completion sentinels for an attempt. This is the
|
|
160
|
+
* authoritative reader for the read-strict acceptance path: it surfaces a
|
|
161
|
+
* `completed`+`failed` contradiction so the caller can raise a conflict event
|
|
162
|
+
* and WITHHOLD an irreversible auto-stop (spec §13 R4), rather than silently
|
|
163
|
+
* collapsing to one. Legacy presence-only markers read as absent here.
|
|
164
|
+
*/
|
|
165
|
+
export function readCompletionSignals(root, assignmentId) {
|
|
166
|
+
const out = {};
|
|
167
|
+
const completed = readOneCompletionSignal(root, assignmentId, 'completed');
|
|
168
|
+
const failed = readOneCompletionSignal(root, assignmentId, 'failed');
|
|
169
|
+
if (completed)
|
|
170
|
+
out.completed = completed;
|
|
171
|
+
if (failed)
|
|
172
|
+
out.failed = failed;
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Convenience single-body reader (`completed` preferred over `failed`). Returns
|
|
177
|
+
* undefined for absent / legacy presence-only / non-JSON / missing-keys.
|
|
178
|
+
* CALLERS THAT ACT IRREVERSIBLY must use {@link readCompletionSignals} instead
|
|
179
|
+
* so a completed+failed contradiction is not hidden (spec §13 R4).
|
|
180
|
+
*/
|
|
181
|
+
export function readCompletionSignal(root, assignmentId) {
|
|
182
|
+
const both = readCompletionSignals(root, assignmentId);
|
|
183
|
+
return both.completed ?? both.failed;
|
|
184
|
+
}
|
|
117
185
|
/**
|
|
118
186
|
* can_c39f0961 — CP850 high-byte table (0x80–0xFF). Windows-native console
|
|
119
187
|
* tools write redirected stdout/stderr in the OEM codepage (cp850 on western
|