brainclaw 1.28.0 → 1.28.2
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +12 -0
- package/dist/commands/code-map.js +2 -0
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/harvest.js +32 -43
- package/dist/commands/loop.js +12 -0
- package/dist/commands/loops-handlers.js +284 -17
- package/dist/commands/mcp-catalog.js +6 -3
- package/dist/commands/mcp-write-claims.js +55 -8
- package/dist/commands/mcp-write-coordination.js +413 -137
- package/dist/commands/mcp.js +32 -4
- package/dist/core/actions.js +17 -3
- package/dist/core/agentrun-reconciler.js +138 -4
- package/dist/core/claims.js +4 -1
- package/dist/core/code-map/backend.js +8 -0
- package/dist/core/execution-adapters.js +15 -7
- package/dist/core/hygiene-policy.js +2 -1
- package/dist/core/loop-turn-dispatch.js +18 -1
- package/dist/core/loops/attempt-authority.js +22 -4
- package/dist/core/loops/attempt-generations.js +17 -4
- package/dist/core/loops/attempt-reservation.js +14 -1
- package/dist/core/loops/attempt-takeover.js +173 -76
- package/dist/core/loops/continuation.js +337 -0
- package/dist/core/loops/facade-schema.js +15 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/reconcile-turn.js +224 -26
- package/dist/core/loops/result-reducers.js +8 -8
- package/dist/core/loops/turn-execution.js +38 -19
- package/dist/core/loops/types.js +9 -0
- package/dist/core/loops/verbs.js +1 -1
- package/dist/core/reviewer-policy.js +39 -0
- package/dist/core/schema.js +16 -1
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/cli.md +4 -2
- package/docs/code-map.md +10 -0
- package/docs/concepts/loop-engine.md +30 -0
- package/docs/mcp-schema-changelog.md +6 -1
- package/package.json +1 -1
package/dist/commands/mcp.js
CHANGED
|
@@ -39,7 +39,7 @@ import { ensureTrust, resolveMutationIdentity, explicitSessionIdFromEnv, project
|
|
|
39
39
|
// point: it imports the extracted modules for its own use and re-exports the
|
|
40
40
|
// full historical surface below so external importers are unaffected.
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
|
-
import {
|
|
42
|
+
import { MCP_PROTOCOL_VERSIONS, MCP_SERVER_NOT_INITIALIZED, toolResponse, createToolErrorResponse, } from './mcp-contract.js';
|
|
43
43
|
import { MCP_READ_TOOLS, PUBLISHED_TOOLS, DEFAULT_PUBLISHED_TOOLS, UNINITIALIZED_TOOL_NAMES, UNINITIALIZED_PUBLISHED_TOOLS, buildUninitializedStateMessage, LEGACY_READ_TOOL_HANDLERS, } from './mcp-catalog.js';
|
|
44
44
|
// Claim/assignment write handlers extracted in pln#622 PR3b. The shared write
|
|
45
45
|
// helpers (ensureTrust, resolveExecutionWriteTarget, …) stay here and are
|
|
@@ -227,7 +227,10 @@ export function createInitializeResult(protocolVersion, options) {
|
|
|
227
227
|
const uninitialized = options?.uninitialized === true;
|
|
228
228
|
return {
|
|
229
229
|
protocolVersion,
|
|
230
|
-
serverInfo
|
|
230
|
+
// MCP serverInfo.version is the running PRODUCT version. SCHEMA_VERSION
|
|
231
|
+
// belongs to tool envelopes and made stale processes impossible to
|
|
232
|
+
// distinguish during the DGX 1.28.0→1.28.1 dogfood.
|
|
233
|
+
serverInfo: { name: 'brainclaw', version: getInstalledBrainclawVersion() },
|
|
231
234
|
// listChanged is only advertised in setup mode, where the catalog flips
|
|
232
235
|
// to the full set once the project memory is initialized.
|
|
233
236
|
capabilities: { tools: { listChanged: uninitialized } },
|
|
@@ -1095,10 +1098,21 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1095
1098
|
const be = new JsonlBackend();
|
|
1096
1099
|
if (name === 'bclaw_code_status') {
|
|
1097
1100
|
const status = await be.status({ cwd, cascade: args.cascade === true });
|
|
1101
|
+
const diskVersion = readDiskBrainclawVersion();
|
|
1098
1102
|
return {
|
|
1099
1103
|
response: toolResponse({
|
|
1100
1104
|
content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.freshness}` }],
|
|
1101
|
-
structuredContent: {
|
|
1105
|
+
structuredContent: {
|
|
1106
|
+
...status,
|
|
1107
|
+
freshness_badge: status.freshness_badge,
|
|
1108
|
+
mcp_resolution: {
|
|
1109
|
+
active_source: scopeInfo.active_source,
|
|
1110
|
+
resolved_project: scopeInfo.resolved_project,
|
|
1111
|
+
server_version: getInstalledBrainclawVersion(),
|
|
1112
|
+
disk_version: diskVersion,
|
|
1113
|
+
restart_required: diskVersion !== '0.0.0' && diskVersion !== getInstalledBrainclawVersion(),
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1102
1116
|
}),
|
|
1103
1117
|
};
|
|
1104
1118
|
}
|
|
@@ -1457,10 +1471,24 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1457
1471
|
const full = candidateIds
|
|
1458
1472
|
.map((id) => loadAssignment(id, targetCwd))
|
|
1459
1473
|
.filter((a) => a !== undefined);
|
|
1460
|
-
sweepAssignmentsAtReadPath(full, targetCwd, {
|
|
1474
|
+
const sweep = sweepAssignmentsAtReadPath(full, targetCwd, {
|
|
1461
1475
|
actor: 'bclaw_work-readpath',
|
|
1462
1476
|
policy,
|
|
1463
1477
|
});
|
|
1478
|
+
// The context snapshot was built before the lazy sweep. Reflect
|
|
1479
|
+
// the same-pass mutations so bclaw_work never serves a fossil as
|
|
1480
|
+
// active once it has just expired/timed it out on disk.
|
|
1481
|
+
const parked = new Set([
|
|
1482
|
+
...sweep.expired.map((item) => item.assignment_id),
|
|
1483
|
+
...sweep.timed_out.map((item) => item.assignment_id),
|
|
1484
|
+
]);
|
|
1485
|
+
const advanced = new Map(sweep.implicitly_advanced.map((item) => [item.assignment_id, item.to]));
|
|
1486
|
+
contextResult.open_work.active_assignments = openAssignments
|
|
1487
|
+
.filter((assignment) => !parked.has(assignment.id))
|
|
1488
|
+
.map((assignment) => {
|
|
1489
|
+
const status = advanced.get(assignment.id);
|
|
1490
|
+
return status ? { ...assignment, status } : assignment;
|
|
1491
|
+
});
|
|
1464
1492
|
}
|
|
1465
1493
|
const registry = loadServeRegistry(targetCwd);
|
|
1466
1494
|
const aged = ageStaleWarnings(contextResult.stale_warnings ?? [], targetCwd, { policy, registry });
|
package/dist/core/actions.js
CHANGED
|
@@ -11,6 +11,7 @@ import { emitRegistryPostImage, registryFaultPoint } from './events/registry-pos
|
|
|
11
11
|
import { createRuntimeEvent } from './events.js';
|
|
12
12
|
import { loadAssignment, transitionAssignment } from './assignments.js';
|
|
13
13
|
import { loadAgentRun, transitionAgentRun } from './agentruns.js';
|
|
14
|
+
import { denyContinuation } from './loops/continuation.js';
|
|
14
15
|
function actionsDir(cwd, mode = 'read') {
|
|
15
16
|
return resolveEntityDir('actions', cwd ?? process.cwd(), mode);
|
|
16
17
|
}
|
|
@@ -90,7 +91,7 @@ function expireStaleActions(actions, cwd) {
|
|
|
90
91
|
}
|
|
91
92
|
catch { /* best-effort */ }
|
|
92
93
|
try {
|
|
93
|
-
const assignment = loadAssignment(action.assignment_id, cwd);
|
|
94
|
+
const assignment = action.assignment_id ? loadAssignment(action.assignment_id, cwd) : undefined;
|
|
94
95
|
if (assignment && assignment.status === 'blocked') {
|
|
95
96
|
transitionAssignment(assignment.id, 'failed', {
|
|
96
97
|
actor: action.agent,
|
|
@@ -102,6 +103,12 @@ function expireStaleActions(actions, cwd) {
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
catch { /* best-effort */ }
|
|
106
|
+
try {
|
|
107
|
+
if (action.target?.kind === 'continuation') {
|
|
108
|
+
denyContinuation(action.target.continuation_id, `approval ${action.id} expired`, action.agent, action.agent_id, cwd);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch { /* best-effort */ }
|
|
105
112
|
try {
|
|
106
113
|
appendAuditEntry({
|
|
107
114
|
actor: action.agent,
|
|
@@ -184,11 +191,15 @@ function saveActionRequired(action, cwd) {
|
|
|
184
191
|
export function createActionRequired(options, cwd) {
|
|
185
192
|
const generated = generateIdWithLabel('actions', cwd);
|
|
186
193
|
const now = nowISO();
|
|
194
|
+
const target = options.target ?? (options.assignment_id
|
|
195
|
+
? { kind: 'assignment', assignment_id: options.assignment_id }
|
|
196
|
+
: undefined);
|
|
187
197
|
const action = ActionRequiredSchema.parse({
|
|
188
198
|
schema_version: 1,
|
|
189
199
|
id: generated.id,
|
|
190
200
|
short_label: generated.short_label,
|
|
191
201
|
assignment_id: options.assignment_id,
|
|
202
|
+
target,
|
|
192
203
|
run_id: options.run_id,
|
|
193
204
|
claim_id: options.claim_id,
|
|
194
205
|
message_id: options.message_id,
|
|
@@ -216,7 +227,7 @@ export function createActionRequired(options, cwd) {
|
|
|
216
227
|
action: 'create',
|
|
217
228
|
item_id: action.id,
|
|
218
229
|
item_type: 'state',
|
|
219
|
-
after: { kind: action.kind, assignment_id: action.assignment_id, run_id: action.run_id },
|
|
230
|
+
after: { kind: action.kind, target: action.target, assignment_id: action.assignment_id, run_id: action.run_id },
|
|
220
231
|
scope: action.scope,
|
|
221
232
|
session_id: action.session_id,
|
|
222
233
|
}, cwd);
|
|
@@ -268,6 +279,9 @@ export function resolveActionRequired(id, options, cwd) {
|
|
|
268
279
|
responded_at: now,
|
|
269
280
|
};
|
|
270
281
|
saveActionRequired(action, cwd);
|
|
282
|
+
if (action.target?.kind === 'continuation' && options.outcome !== 'resolved') {
|
|
283
|
+
denyContinuation(action.target.continuation_id, `approval ${action.id} ${options.outcome}`, options.responded_by, options.responded_by_id, cwd);
|
|
284
|
+
}
|
|
271
285
|
appendAuditEntry({
|
|
272
286
|
actor: options.responded_by,
|
|
273
287
|
actor_id: options.responded_by_id,
|
|
@@ -301,7 +315,7 @@ export function resolveActionRequired(id, options, cwd) {
|
|
|
301
315
|
}
|
|
302
316
|
}
|
|
303
317
|
}
|
|
304
|
-
const assignment = loadAssignment(action.assignment_id, cwd);
|
|
318
|
+
const assignment = action.assignment_id ? loadAssignment(action.assignment_id, cwd) : undefined;
|
|
305
319
|
if (assignment) {
|
|
306
320
|
if (options.outcome === 'resolved' && assignment.status === 'blocked') {
|
|
307
321
|
transitionAssignment(assignment.id, 'started', {
|
|
@@ -36,7 +36,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
36
36
|
import fs from 'node:fs';
|
|
37
37
|
import { loadAgentRun, recordExecutionContractAnomaly, transitionAgentRun, listAgentRuns } from './agentruns.js';
|
|
38
38
|
import { loadClaim, releaseClaim } from './claims.js';
|
|
39
|
-
import { loadAssignment } from './assignments.js';
|
|
39
|
+
import { convergeAssignmentToTerminal, loadAssignment, transitionAssignment } from './assignments.js';
|
|
40
40
|
import { createRuntimeEvent } from './events.js';
|
|
41
41
|
import { nowISO } from './ids.js';
|
|
42
42
|
import { readContractAck, readHeartbeat, readLogTail, signalExists, latestActivityMs, readCompletionSignals } from './runtime-signals.js';
|
|
@@ -44,7 +44,7 @@ import { validateWorkerContractAcceptance } from './execution-contract.js';
|
|
|
44
44
|
import { findReservationByRunId, evidenceMatchesAttempt, launchGrant, revokeLaunchGrant } from './loops/attempt-reservation.js';
|
|
45
45
|
import { executionContractForGeneration } from './loops/attempt-authority.js';
|
|
46
46
|
import { readLaunchDecision, resolveTurnGenerationChain } from './loops/attempt-generations.js';
|
|
47
|
-
import { reconcileFailedTurn } from './loops/reconcile-turn.js';
|
|
47
|
+
import { reconcileFailedTurn, reconcileLaneResultForRun } from './loops/reconcile-turn.js';
|
|
48
48
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
49
49
|
/**
|
|
50
50
|
* Minimum age before a run is eligible for reconciliation. Below this, the
|
|
@@ -76,6 +76,47 @@ export const DEFAULT_HEARTBEAT_STALE_MS = 10 * 60_000;
|
|
|
76
76
|
const TERMINAL_STATUSES = new Set([
|
|
77
77
|
'completed', 'failed', 'cancelled', 'timed_out', 'interrupted',
|
|
78
78
|
]);
|
|
79
|
+
/** Shared business-result bridge for every lazy AgentRun read path. */
|
|
80
|
+
function reconcileOwnedLaneResultAtRead(run, evidence, cwd, actor) {
|
|
81
|
+
if (!evidence.turn_owned)
|
|
82
|
+
return undefined;
|
|
83
|
+
const lane = reconcileLaneResultForRun(run, cwd ?? process.cwd(), actor);
|
|
84
|
+
if (!lane.found)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!lane.valid || !lane.result?.reconciled) {
|
|
87
|
+
return { withheld_reason: `lane_result_withheld: ${lane.reason}` };
|
|
88
|
+
}
|
|
89
|
+
const after = loadAgentRun(run.id, cwd) ?? run;
|
|
90
|
+
return { result: {
|
|
91
|
+
run_id: run.id,
|
|
92
|
+
action: 'reconciled_turn',
|
|
93
|
+
reason: `lane_result_reconciled: ${lane.reason}`,
|
|
94
|
+
evidence,
|
|
95
|
+
previous_status: run.status,
|
|
96
|
+
current_status: after.status,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** A malformed/foreign business result withholds success, but it must not mask
|
|
101
|
+
* independent, conclusive failure evidence forever. */
|
|
102
|
+
function settleWithheldLaneFailure(run, evidence, actor, cwd, reason) {
|
|
103
|
+
try {
|
|
104
|
+
transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
|
|
105
|
+
convergeAssignmentFromRun(run, 'failed', evidence, actor, cwd);
|
|
106
|
+
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
107
|
+
return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
run_id: run.id,
|
|
112
|
+
action: 'no_op',
|
|
113
|
+
reason: `failure transition rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
114
|
+
evidence,
|
|
115
|
+
previous_status: run.status,
|
|
116
|
+
current_status: run.status,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
79
120
|
// ── Process liveness ───────────────────────────────────────────────────────
|
|
80
121
|
/**
|
|
81
122
|
* Cross-platform "is this PID alive" check. `process.kill(pid, 0)` does NOT
|
|
@@ -309,6 +350,32 @@ export function collectEvidence(run, cwd, options) {
|
|
|
309
350
|
function fsActiveWithin(evidence, windowMs) {
|
|
310
351
|
return evidence.fs_activity_age_ms !== undefined && evidence.fs_activity_age_ms < windowMs;
|
|
311
352
|
}
|
|
353
|
+
/** Align a non-turn-owned Assignment with conclusive runtime evidence. */
|
|
354
|
+
function convergeAssignmentFromRun(run, outcome, evidence, actor, cwd) {
|
|
355
|
+
if (findReservationByRunId(run.id, cwd))
|
|
356
|
+
return false;
|
|
357
|
+
const assignment = loadAssignment(run.assignment_id, cwd);
|
|
358
|
+
if (!assignment || ['completed', 'failed', 'blocked', 'timed_out', 'cancelled', 'expired', 'rerouted'].includes(assignment.status)) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
if (outcome === 'completed') {
|
|
362
|
+
// A released claim alone is not business proof of successful work.
|
|
363
|
+
if (!evidence.completed_signal && !evidence.has_post_start_commit && !evidence.assignment_completed)
|
|
364
|
+
return false;
|
|
365
|
+
return convergeAssignmentToTerminal(assignment.id, 'completed', `agent_run ${run.id} completed from authoritative runtime evidence`, cwd);
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
transitionAssignment(assignment.id, 'failed', {
|
|
369
|
+
actor,
|
|
370
|
+
syncAgentRun: false,
|
|
371
|
+
status_reason: `agent_run ${run.id} failed from liveness reconciliation`,
|
|
372
|
+
}, cwd);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
312
379
|
/**
|
|
313
380
|
* trp#433 — when a run is reconciled to `failed` (silent_death / stalled), release
|
|
314
381
|
* its linked claim so dead runs stop leaving active claims (and their worktrees)
|
|
@@ -542,6 +609,15 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
542
609
|
}
|
|
543
610
|
const previous_status = run.status;
|
|
544
611
|
const evidence = collectEvidence(run, cwd, { nowMs: options.nowMs });
|
|
612
|
+
const actor = options.actor ?? 'reconciler';
|
|
613
|
+
// pln#692 P0 — LANE-RESULT is the worker's business outcome, not merely a
|
|
614
|
+
// process-liveness hint. Reconcile it before the terminal-run short circuit
|
|
615
|
+
// and before generic sentinel completion so a replay heals every projection
|
|
616
|
+
// (slot, assignment, run, claim) and malformed/foreign results cannot leave
|
|
617
|
+
// only the run marked completed.
|
|
618
|
+
const laneResult = reconcileOwnedLaneResultAtRead(run, evidence, cwd, actor);
|
|
619
|
+
if (laneResult?.result)
|
|
620
|
+
return laneResult.result;
|
|
545
621
|
// Never touch terminal runs — they already converged.
|
|
546
622
|
if (TERMINAL_STATUSES.has(run.status)) {
|
|
547
623
|
return {
|
|
@@ -549,6 +625,33 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
549
625
|
evidence, previous_status, current_status: run.status,
|
|
550
626
|
};
|
|
551
627
|
}
|
|
628
|
+
if (laneResult?.withheld_reason) {
|
|
629
|
+
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
630
|
+
const heartbeatStale = options.heartbeatStaleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
631
|
+
const conclusiveFailure = evidence.failed_signal
|
|
632
|
+
? 'wrapper reported non-zero exit'
|
|
633
|
+
: evidence.heartbeat_exists
|
|
634
|
+
&& evidence.heartbeat_age_ms !== undefined
|
|
635
|
+
&& evidence.heartbeat_age_ms >= heartbeatStale
|
|
636
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
637
|
+
? `heartbeat stalled for ${Math.round(evidence.heartbeat_age_ms / 1000)}s`
|
|
638
|
+
: evidence.process_alive === false
|
|
639
|
+
&& evidence.age_ms >= stale
|
|
640
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
641
|
+
? 'silent termination past stale window'
|
|
642
|
+
: undefined;
|
|
643
|
+
if (conclusiveFailure) {
|
|
644
|
+
return settleWithheldLaneFailure(run, evidence, actor, cwd, `invalid_lane_result_with_conclusive_failure: ${conclusiveFailure}; ${laneResult.withheld_reason}`);
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
run_id: run.id,
|
|
648
|
+
action: 'health_check_unverified',
|
|
649
|
+
reason: laneResult.withheld_reason,
|
|
650
|
+
evidence,
|
|
651
|
+
previous_status: run.status,
|
|
652
|
+
current_status: run.status,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
552
655
|
if (evidence.contract_acceptance_anomaly) {
|
|
553
656
|
try {
|
|
554
657
|
recordExecutionContractAnomaly(runId, {
|
|
@@ -580,7 +683,6 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
580
683
|
}
|
|
581
684
|
const grace = options.healthCheckGraceMs ?? DEFAULT_HEALTH_CHECK_GRACE_MS;
|
|
582
685
|
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
583
|
-
const actor = options.actor ?? 'reconciler';
|
|
584
686
|
// Below grace window: too early to draw any conclusion. Caller should
|
|
585
687
|
// re-poll later.
|
|
586
688
|
if (evidence.age_ms < grace) {
|
|
@@ -596,6 +698,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
596
698
|
actor,
|
|
597
699
|
status_reason: `inferred=true; evidence: ${describeEvidence(evidence)}`,
|
|
598
700
|
}, cwd);
|
|
701
|
+
convergeAssignmentFromRun(run, 'completed', evidence, actor, cwd);
|
|
599
702
|
return {
|
|
600
703
|
run_id: runId, action: 'inferred_completed',
|
|
601
704
|
reason: `inferred=true; ${describeEvidence(evidence)}`,
|
|
@@ -617,6 +720,7 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
617
720
|
const failHere = (reason) => {
|
|
618
721
|
try {
|
|
619
722
|
transitionAgentRun(runId, 'failed', { actor, status_reason: reason }, cwd);
|
|
723
|
+
convergeAssignmentFromRun(run, 'failed', evidence, actor, cwd);
|
|
620
724
|
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
621
725
|
return { run_id: runId, action: 'inferred_failed', reason, evidence, previous_status, current_status: 'failed' };
|
|
622
726
|
}
|
|
@@ -814,18 +918,47 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
814
918
|
};
|
|
815
919
|
}
|
|
816
920
|
const evidence = collectEvidence(run, cwd, { nowMs: options.nowMs });
|
|
921
|
+
const actor = options.actor ?? 'reconciler';
|
|
922
|
+
const laneResult = reconcileOwnedLaneResultAtRead(run, evidence, cwd, actor);
|
|
923
|
+
if (laneResult?.result)
|
|
924
|
+
return laneResult.result;
|
|
817
925
|
if (run.status !== 'running') {
|
|
818
926
|
return {
|
|
819
927
|
run_id: run.id, action: 'no_op', reason: `run status is ${run.status}, not running`,
|
|
820
928
|
evidence, previous_status: run.status, current_status: run.status,
|
|
821
929
|
};
|
|
822
930
|
}
|
|
823
|
-
const actor = options.actor ?? 'reconciler';
|
|
824
931
|
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
825
932
|
const heartbeatStale = options.heartbeatStaleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
933
|
+
if (laneResult?.withheld_reason) {
|
|
934
|
+
const conclusiveFailure = evidence.failed_signal
|
|
935
|
+
? 'wrapper reported non-zero exit'
|
|
936
|
+
: evidence.heartbeat_exists
|
|
937
|
+
&& evidence.heartbeat_age_ms !== undefined
|
|
938
|
+
&& evidence.heartbeat_age_ms >= heartbeatStale
|
|
939
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
940
|
+
? `heartbeat stalled for ${Math.round(evidence.heartbeat_age_ms / 1000)}s`
|
|
941
|
+
: evidence.process_alive === false
|
|
942
|
+
&& evidence.age_ms >= stale
|
|
943
|
+
&& !fsActiveWithin(evidence, heartbeatStale)
|
|
944
|
+
? 'silent termination past stale window'
|
|
945
|
+
: undefined;
|
|
946
|
+
if (conclusiveFailure) {
|
|
947
|
+
return settleWithheldLaneFailure(run, evidence, actor, cwd, `invalid_lane_result_with_conclusive_failure: ${conclusiveFailure}; ${laneResult.withheld_reason}`);
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
run_id: run.id,
|
|
951
|
+
action: 'health_check_unverified',
|
|
952
|
+
reason: laneResult.withheld_reason,
|
|
953
|
+
evidence,
|
|
954
|
+
previous_status: run.status,
|
|
955
|
+
current_status: run.status,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
826
958
|
const failRun = (reason) => {
|
|
827
959
|
try {
|
|
828
960
|
transitionAgentRun(run.id, 'failed', { actor, status_reason: reason }, cwd);
|
|
961
|
+
convergeAssignmentFromRun(run, 'failed', evidence, actor, cwd);
|
|
829
962
|
cascadeReleaseOnFailure(run, actor, cwd, 'failed', reason);
|
|
830
963
|
return { run_id: run.id, action: 'inferred_failed', reason, evidence, previous_status: run.status, current_status: 'failed' };
|
|
831
964
|
}
|
|
@@ -846,6 +979,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
846
979
|
actor,
|
|
847
980
|
status_reason: `inferred=true; evidence: ${describeEvidence(evidence)}`,
|
|
848
981
|
}, cwd);
|
|
982
|
+
convergeAssignmentFromRun(run, 'completed', evidence, actor, cwd);
|
|
849
983
|
return {
|
|
850
984
|
run_id: run.id, action: 'inferred_completed',
|
|
851
985
|
reason: `inferred=true; ${describeEvidence(evidence)}`,
|
package/dist/core/claims.js
CHANGED
|
@@ -963,7 +963,10 @@ export function createCoordinatorClaim(options) {
|
|
|
963
963
|
// `.github/workflows` previously produced `feat/.github-…` (leading dot),
|
|
964
964
|
// which git rejects and the whole spawn failed.
|
|
965
965
|
const branchSlug = sanitizeBranchComponent(options.scope);
|
|
966
|
-
const
|
|
966
|
+
const branchSuffix = options.worktreeBranchSuffix
|
|
967
|
+
? `-${sanitizeBranchComponent(options.worktreeBranchSuffix)}`
|
|
968
|
+
: '';
|
|
969
|
+
const worktreeBranch = `feat/${branchSlug}${branchSuffix}`;
|
|
967
970
|
try {
|
|
968
971
|
worktreePath = createWorktree(options.cwd, worktreeBranch, {
|
|
969
972
|
sessionId: options.sessionId,
|
|
@@ -21,6 +21,7 @@ import { resolveTraversal, aggregateFind, aggregateBrief } from './aggregate.js'
|
|
|
21
21
|
import { defaultMemoryReader } from './memory-reader.js';
|
|
22
22
|
import { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
|
|
23
23
|
import { loadConfig } from '../config.js';
|
|
24
|
+
import { codeMapDir } from './paths.js';
|
|
24
25
|
/** spec §9 caps the brief reading list at 12 files. */
|
|
25
26
|
export const BRIEF_FILE_CAP = 12;
|
|
26
27
|
/**
|
|
@@ -140,10 +141,16 @@ export class JsonlBackend {
|
|
|
140
141
|
this.gitHeadReader = opts.gitHeadReader ?? readCurrentGitHead;
|
|
141
142
|
}
|
|
142
143
|
async status(input) {
|
|
144
|
+
const projectRoot = path.resolve(input.cwd ?? process.cwd());
|
|
145
|
+
const resolution = {
|
|
146
|
+
project_root: projectRoot,
|
|
147
|
+
store_path: codeMapDir(projectRoot, input.preferredDirName),
|
|
148
|
+
};
|
|
143
149
|
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
144
150
|
const result = manifest
|
|
145
151
|
? {
|
|
146
152
|
store_exists: true,
|
|
153
|
+
resolution,
|
|
147
154
|
freshness_badge: this.withHeadDrift(badge(manifest.freshness.status, {
|
|
148
155
|
stale_file_count: manifest.freshness.stale_file_count,
|
|
149
156
|
partial_reason: manifest.freshness.partial_reason,
|
|
@@ -156,6 +163,7 @@ export class JsonlBackend {
|
|
|
156
163
|
}
|
|
157
164
|
: {
|
|
158
165
|
store_exists: storeExists(input.cwd, input.preferredDirName),
|
|
166
|
+
resolution,
|
|
159
167
|
freshness_badge: badge('missing_index'),
|
|
160
168
|
stats: null,
|
|
161
169
|
};
|
|
@@ -197,13 +197,21 @@ export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = pr
|
|
|
197
197
|
args.splice(insertAt, 0, '--cd', worktreePath);
|
|
198
198
|
const quote = (value) => isWin32
|
|
199
199
|
? `"${value.replace(/"/g, '""')}"`
|
|
200
|
-
: `
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
200
|
+
: `"${value.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/`/g, '\\`').replace(/"/g, '\\"')}"`;
|
|
201
|
+
// Re-render from structured argv. bashCommand also contains the model-authored
|
|
202
|
+
// prompt, so searching it for "| codex" can mistake prompt data for the real
|
|
203
|
+
// executable boundary and splice --cd inside quoted content.
|
|
204
|
+
const command = [invoke.executable, ...args.map(quote)].join(' ');
|
|
205
|
+
const prompt = invoke.promptText ?? '';
|
|
206
|
+
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
|
207
|
+
let bashCommand = command;
|
|
208
|
+
if (!isWin32 && invoke.promptDelivery === 'stdin_pipe') {
|
|
209
|
+
bashCommand = `printf '%s' '${escapedPrompt}' | ${command}`;
|
|
210
|
+
}
|
|
211
|
+
else if (!isWin32 && invoke.promptDelivery === 'temp_file' && invoke.tempFilePath) {
|
|
212
|
+
bashCommand = `printf '%s' '${escapedPrompt}' > ${quote(invoke.tempFilePath)} && ${command}`;
|
|
213
|
+
}
|
|
214
|
+
return { ...invoke, args, bashCommand };
|
|
207
215
|
}
|
|
208
216
|
export class CliExecutionAdapter {
|
|
209
217
|
id = 'cli';
|
|
@@ -25,7 +25,8 @@ import { logger } from './logger.js';
|
|
|
25
25
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
26
|
export const DEFAULT_HYGIENE_POLICY = {
|
|
27
27
|
disabled: false,
|
|
28
|
-
|
|
28
|
+
// A never-accepted dispatch should not survive into the next day's dogfood.
|
|
29
|
+
assignment_offered_ttl_ms: 1 * DAY_MS,
|
|
29
30
|
assignment_accepted_ttl_ms: 1 * DAY_MS,
|
|
30
31
|
assignment_started_ttl_ms: 1 * DAY_MS,
|
|
31
32
|
handoff_closed_ttl_ms: 30 * DAY_MS,
|
|
@@ -10,7 +10,7 @@ import { resolveModel } from './agent-capability.js';
|
|
|
10
10
|
import { listAgentIdentities } from './agent-registry.js';
|
|
11
11
|
import { transitionAgentRun } from './agentruns.js';
|
|
12
12
|
import { loadAssignment, patchAssignmentMessageId, transitionAssignment } from './assignments.js';
|
|
13
|
-
import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, } from './claims.js';
|
|
13
|
+
import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, releaseClaimIfActive, } from './claims.js';
|
|
14
14
|
import { generateDispatchBrief } from './dispatcher.js';
|
|
15
15
|
import { search } from './search.js';
|
|
16
16
|
import { attemptExecution } from './execution.js';
|
|
@@ -21,6 +21,7 @@ import { buildIdeationBrief } from './loops/brief-assembly.js';
|
|
|
21
21
|
import { getLoop } from './loops/store.js';
|
|
22
22
|
import { prepareTurnExecution } from './loops/turn-execution.js';
|
|
23
23
|
import { sendMessage } from './messaging.js';
|
|
24
|
+
import { removeWorktree } from './worktree.js';
|
|
24
25
|
export async function dispatchLoopTurn(input) {
|
|
25
26
|
const loop = getLoop(input.loop_id, input.cwd);
|
|
26
27
|
if (!loop)
|
|
@@ -131,6 +132,22 @@ export async function dispatchLoopTurn(input) {
|
|
|
131
132
|
if (prepared.kind !== 'won') {
|
|
132
133
|
result.execution_status = 'inbox_only';
|
|
133
134
|
result.error = prepared.reason;
|
|
135
|
+
if (prepared.claim_disposition === 'release' && !claim.reusedExisting) {
|
|
136
|
+
try {
|
|
137
|
+
const released = releaseClaimIfActive(claim.claimId, input.cwd);
|
|
138
|
+
if (released.released && claim.worktreePath) {
|
|
139
|
+
try {
|
|
140
|
+
removeWorktree(input.cwd, claim.worktreePath, { force: true });
|
|
141
|
+
}
|
|
142
|
+
catch (cleanupError) {
|
|
143
|
+
result.error += `; denied-claim worktree cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (cleanupError) {
|
|
148
|
+
result.error += `; denied-claim cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
134
151
|
return result;
|
|
135
152
|
}
|
|
136
153
|
result.assignment_id = prepared.assignment_id;
|
|
@@ -219,6 +219,7 @@ export function executionContractForGeneration(reservation, generation) {
|
|
|
219
219
|
}
|
|
220
220
|
return { contract, ref };
|
|
221
221
|
}
|
|
222
|
+
const snapshot = generation.executor?.capability_snapshot ?? reservation.capability_snapshot;
|
|
222
223
|
const contract = ExecutionContractSchema.parse({
|
|
223
224
|
...reservation.execution_contract,
|
|
224
225
|
identity: {
|
|
@@ -234,7 +235,7 @@ export function executionContractForGeneration(reservation, generation) {
|
|
|
234
235
|
isolation: 'worktree',
|
|
235
236
|
},
|
|
236
237
|
});
|
|
237
|
-
const ref = executionContractRef(contract,
|
|
238
|
+
const ref = executionContractRef(contract, snapshot);
|
|
238
239
|
if (ref.hash !== generation.contract_hash) {
|
|
239
240
|
throw new AttemptGenerationError('fenced', `generation ${generation.attempt_epoch} contract hash ${generation.contract_hash} does not match derived ${ref.hash}`);
|
|
240
241
|
}
|
|
@@ -269,6 +270,12 @@ export function bootstrapAttemptAuthorityV2(input) {
|
|
|
269
270
|
workspace_path: workspacePath,
|
|
270
271
|
workspace_digest: attemptWorkspaceDigest(workspacePath, reservation.turn_id, 0),
|
|
271
272
|
launch_nonce: grant.token,
|
|
273
|
+
executor: {
|
|
274
|
+
agent: reservation.agent,
|
|
275
|
+
agent_id: reservation.agent_id,
|
|
276
|
+
claim_id: reservation.claim_id,
|
|
277
|
+
capability_snapshot: reservation.capability_snapshot,
|
|
278
|
+
},
|
|
272
279
|
});
|
|
273
280
|
if (initial.assignment_id !== reservation.child_ids.assignment_id
|
|
274
281
|
|| initial.run_id !== reservation.child_ids.run_id
|
|
@@ -331,7 +338,8 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
331
338
|
&& close.decision === expectedMode
|
|
332
339
|
&& close.cause === requestedCause
|
|
333
340
|
&& canonicalWorkspacePath(close.next_generation.workspace_path) === canonicalWorkspacePath(input.next_workspace_path)
|
|
334
|
-
&& JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)
|
|
341
|
+
&& JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)
|
|
342
|
+
&& (!input.next_executor || JSON.stringify(close.next_generation.executor) === JSON.stringify(input.next_executor))) {
|
|
335
343
|
const generationContract = executionContractForGeneration(reservation, close.next_generation);
|
|
336
344
|
return {
|
|
337
345
|
won: false,
|
|
@@ -376,9 +384,10 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
376
384
|
contract_hash: '0'.repeat(64),
|
|
377
385
|
workspace_path: nextWorkspacePath,
|
|
378
386
|
workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
|
|
387
|
+
executor: input.next_executor,
|
|
379
388
|
});
|
|
380
389
|
const baseContract = reservation.execution_contract;
|
|
381
|
-
const snapshot = reservation.capability_snapshot;
|
|
390
|
+
const snapshot = input.next_executor?.capability_snapshot ?? reservation.capability_snapshot;
|
|
382
391
|
if (!baseContract || !snapshot) {
|
|
383
392
|
throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} lacks a contracted capability snapshot`);
|
|
384
393
|
}
|
|
@@ -402,6 +411,7 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
402
411
|
contract_hash: nextRef.hash,
|
|
403
412
|
workspace_path: nextWorkspacePath,
|
|
404
413
|
workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
|
|
414
|
+
executor: input.next_executor,
|
|
405
415
|
launch_nonce: provisional.launch_nonce,
|
|
406
416
|
created_at: provisional.created_at,
|
|
407
417
|
});
|
|
@@ -419,7 +429,15 @@ export function prepareAttemptTakeoverV2(input) {
|
|
|
419
429
|
if (generationDigest(incumbent.next_generation) !== generationDigest(next)) {
|
|
420
430
|
throw new AttemptGenerationError('fenced', `a different successor already won generation ${current.attempt_epoch}`);
|
|
421
431
|
}
|
|
422
|
-
|
|
432
|
+
// The close cell above is the immutable authority boundary. `head.json` is
|
|
433
|
+
// only a rebuildable read cache, so a projection failure after a winning
|
|
434
|
+
// close must never escape as a pre-commit takeover failure: callers could
|
|
435
|
+
// otherwise roll back claims while the successor generation already owns
|
|
436
|
+
// the turn. Readers repair the cache from immutable cells on replay.
|
|
437
|
+
try {
|
|
438
|
+
rebuildAttemptGenerationHead(input.cwd, current);
|
|
439
|
+
}
|
|
440
|
+
catch { /* immutable close(epoch) remains authoritative */ }
|
|
423
441
|
return {
|
|
424
442
|
won: published.won,
|
|
425
443
|
rollout,
|
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { CapabilitySnapshotSchema } from '../execution-contract.js';
|
|
5
6
|
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
6
7
|
import { nowISO } from '../ids.js';
|
|
7
8
|
/**
|
|
@@ -31,9 +32,17 @@ export const GenerationFenceSchema = z.object({
|
|
|
31
32
|
workspace_digest: z.string().min(1),
|
|
32
33
|
authority_home: AuthorityHomeSchema,
|
|
33
34
|
}).strict();
|
|
35
|
+
export const GenerationExecutorSchema = z.object({
|
|
36
|
+
agent: z.string().min(1),
|
|
37
|
+
agent_id: z.string().min(1).optional(),
|
|
38
|
+
claim_id: z.string().min(1),
|
|
39
|
+
capability_snapshot: CapabilitySnapshotSchema,
|
|
40
|
+
}).strict();
|
|
34
41
|
export const AttemptGenerationSchema = GenerationFenceSchema.extend({
|
|
35
42
|
schema_version: z.literal(ATTEMPT_AUTHORITY_VERSION),
|
|
36
43
|
generation_kind: z.literal('attempt_generation'),
|
|
44
|
+
/** Executor ownership is generation-scoped so a reroute never inherits stale provenance. */
|
|
45
|
+
executor: GenerationExecutorSchema.optional(),
|
|
37
46
|
created_at: z.string().min(1),
|
|
38
47
|
}).strict();
|
|
39
48
|
export const LaunchDecisionCellSchema = z.object({
|
|
@@ -145,8 +154,9 @@ function stableId(prefix, material) {
|
|
|
145
154
|
return `${prefix}_${digest}`;
|
|
146
155
|
}
|
|
147
156
|
/** Compatible with the existing logical Assignment derivation. */
|
|
148
|
-
export function deriveGenerationAssignmentId(turnId) {
|
|
149
|
-
|
|
157
|
+
export function deriveGenerationAssignmentId(turnId, attemptEpoch = 0) {
|
|
158
|
+
const salt = attemptEpoch === 0 ? 'assignment' : `assignment:${attemptEpoch}`;
|
|
159
|
+
return stableId('asgn', `${turnId}:${salt}`);
|
|
150
160
|
}
|
|
151
161
|
/** Epoch zero preserves the existing run id; every later epoch is fresh. */
|
|
152
162
|
export function deriveGenerationRunId(turnId, attemptEpoch) {
|
|
@@ -161,7 +171,7 @@ export function prepareInitialGeneration(input) {
|
|
|
161
171
|
schema_version: ATTEMPT_AUTHORITY_VERSION,
|
|
162
172
|
generation_kind: 'attempt_generation',
|
|
163
173
|
turn_id: input.turn_id,
|
|
164
|
-
assignment_id: deriveGenerationAssignmentId(input.turn_id),
|
|
174
|
+
assignment_id: deriveGenerationAssignmentId(input.turn_id, 0),
|
|
165
175
|
attempt_epoch: 0,
|
|
166
176
|
run_id: deriveGenerationRunId(input.turn_id, 0),
|
|
167
177
|
launch_nonce: input.launch_nonce ?? crypto.randomUUID(),
|
|
@@ -170,6 +180,7 @@ export function prepareInitialGeneration(input) {
|
|
|
170
180
|
workspace_path: input.workspace_path,
|
|
171
181
|
workspace_digest: input.workspace_digest,
|
|
172
182
|
authority_home: AuthorityHomeSchema.parse(input.authority_home),
|
|
183
|
+
executor: input.executor,
|
|
173
184
|
created_at: input.created_at ?? nowISO(),
|
|
174
185
|
});
|
|
175
186
|
}
|
|
@@ -178,6 +189,7 @@ export function prepareNextGeneration(current, input) {
|
|
|
178
189
|
const attemptEpoch = parsed.attempt_epoch + 1;
|
|
179
190
|
return AttemptGenerationSchema.parse({
|
|
180
191
|
...parsed,
|
|
192
|
+
assignment_id: deriveGenerationAssignmentId(parsed.turn_id, attemptEpoch),
|
|
181
193
|
attempt_epoch: attemptEpoch,
|
|
182
194
|
run_id: deriveGenerationRunId(parsed.turn_id, attemptEpoch),
|
|
183
195
|
launch_nonce: input.launch_nonce ?? crypto.randomUUID(),
|
|
@@ -185,6 +197,7 @@ export function prepareNextGeneration(current, input) {
|
|
|
185
197
|
workspace_id: deriveGenerationWorkspaceId(parsed.turn_id, attemptEpoch),
|
|
186
198
|
workspace_path: input.workspace_path,
|
|
187
199
|
workspace_digest: input.workspace_digest,
|
|
200
|
+
executor: input.executor ?? parsed.executor,
|
|
188
201
|
created_at: input.created_at ?? nowISO(),
|
|
189
202
|
});
|
|
190
203
|
}
|
|
@@ -231,7 +244,7 @@ function assertValidSuccessor(current, next) {
|
|
|
231
244
|
}
|
|
232
245
|
const invalid = [
|
|
233
246
|
current.turn_id !== next.turn_id && 'turn_id',
|
|
234
|
-
|
|
247
|
+
next.assignment_id === current.assignment_id && 'assignment_id',
|
|
235
248
|
next.attempt_epoch !== current.attempt_epoch + 1 && 'attempt_epoch',
|
|
236
249
|
next.run_id === current.run_id && 'run_id',
|
|
237
250
|
next.launch_nonce === current.launch_nonce && 'launch_nonce',
|