agentxchain 2.146.0 → 2.148.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/package.json +1 -1
- package/scripts/publish-npm.sh +16 -0
- package/scripts/sync-homebrew.sh +14 -1
- package/scripts/verify-post-publish.sh +55 -4
- package/src/commands/reissue-turn.js +16 -0
- package/src/commands/reject-turn.js +14 -1
- package/src/commands/restart.js +15 -0
- package/src/commands/resume.js +61 -66
- package/src/commands/run.js +67 -10
- package/src/commands/schedule.js +34 -7
- package/src/commands/status.js +20 -0
- package/src/commands/step.js +100 -34
- package/src/lib/adapters/api-proxy-adapter.js +8 -0
- package/src/lib/adapters/local-cli-adapter.js +271 -16
- package/src/lib/adapters/manual-adapter.js +9 -10
- package/src/lib/adapters/mcp-adapter.js +3 -5
- package/src/lib/adapters/remote-agent-adapter.js +3 -5
- package/src/lib/continuous-run.js +71 -6
- package/src/lib/dispatch-bundle.js +1 -1
- package/src/lib/dispatch-progress.js +5 -3
- package/src/lib/governed-state.js +258 -17
- package/src/lib/intake.js +10 -1
- package/src/lib/normalized-config.js +51 -1
- package/src/lib/recent-event-summary.js +11 -0
- package/src/lib/run-events.js +4 -0
- package/src/lib/run-loop.js +67 -2
- package/src/lib/runner-interface.js +1 -0
- package/src/lib/schema.js +7 -0
- package/src/lib/schemas/agentxchain-config.schema.json +15 -1
- package/src/lib/schemas/turn-result.schema.json +8 -2
- package/src/lib/staged-result-proof.js +43 -0
- package/src/lib/stale-turn-watchdog.js +218 -90
- package/src/lib/turn-checkpoint.js +65 -1
- package/src/lib/turn-result-shape.js +38 -0
- package/src/lib/turn-result-validator.js +15 -3
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two-tier lazy idle-threshold detection:
|
|
5
5
|
*
|
|
6
|
-
* 1. **Fast startup watchdog (BUG-51):** if an active turn has been
|
|
7
|
-
* for >30 seconds with NO
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 1. **Fast startup watchdog (BUG-51):** if an active turn has been
|
|
7
|
+
* `dispatched`/`starting`/`running` for >30 seconds with NO startup proof
|
|
8
|
+
* (no first-byte output recorded on the turn or in dispatch-progress) and
|
|
9
|
+
* NO staged result, it is a "ghost turn" — the subprocess never reached a
|
|
10
|
+
* healthy running state. Transitions to `failed_start` immediately.
|
|
10
11
|
*
|
|
11
|
-
* Design note: the watchdog intentionally keys on
|
|
12
|
-
* dispatch-progress rather than `stdout.log`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* coupling the watchdog to adapter-specific log-attachment details.
|
|
12
|
+
* Design note: the watchdog intentionally keys on first-output proof from
|
|
13
|
+
* the framework-owned dispatch-progress contract rather than `stdout.log`
|
|
14
|
+
* existence. `stdout.log` is adapter-authored visibility output and may be
|
|
15
|
+
* absent even when the adapter is wired correctly. First-output timestamps
|
|
16
|
+
* and output-line counters are the stable health contract across runtime
|
|
17
|
+
* wiring.
|
|
18
18
|
*
|
|
19
19
|
* 2. **Stale turn watchdog (BUG-47):** if an active turn has status "running"
|
|
20
20
|
* for >N minutes with no event log activity AND no staged result file,
|
|
@@ -36,6 +36,7 @@ import { safeWriteJson } from './safe-write.js';
|
|
|
36
36
|
import { emitRunEvent, readRunEvents } from './run-events.js';
|
|
37
37
|
import { getTurnStagingResultPath } from './turn-paths.js';
|
|
38
38
|
import { getDispatchProgressRelativePath } from './dispatch-progress.js';
|
|
39
|
+
import { hasMeaningfulStagedResult } from './staged-result-proof.js';
|
|
39
40
|
|
|
40
41
|
const DEFAULT_LOCAL_CLI_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
|
41
42
|
const DEFAULT_API_PROXY_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
@@ -103,12 +104,11 @@ export function detectStaleTurns(root, state, config) {
|
|
|
103
104
|
/**
|
|
104
105
|
* BUG-51: Detect ghost-dispatched turns — subprocess never started.
|
|
105
106
|
*
|
|
106
|
-
* A ghost turn is one that has been in
|
|
107
|
-
* longer than the startup watchdog threshold (default 30s) AND has:
|
|
108
|
-
* - no
|
|
109
|
-
*
|
|
107
|
+
* A ghost turn is one that has been in `dispatched`, `starting`, `running`, or
|
|
108
|
+
* `retrying` longer than the startup watchdog threshold (default 30s) AND has:
|
|
109
|
+
* - no startup proof (no `first_output_at` on the turn or dispatch-progress,
|
|
110
|
+
* and no recorded output line counts)
|
|
110
111
|
* - no staged result file
|
|
111
|
-
* - no recent turn-scoped events (beyond the initial turn_dispatched)
|
|
112
112
|
*
|
|
113
113
|
* This is a stricter, faster check than detectStaleTurns (BUG-47).
|
|
114
114
|
* Ghost turns transition to "failed_start" rather than "stalled".
|
|
@@ -125,31 +125,22 @@ export function detectGhostTurns(root, state, config) {
|
|
|
125
125
|
const startupThreshold = resolveStartupThreshold(config);
|
|
126
126
|
|
|
127
127
|
for (const [turnId, turn] of Object.entries(activeTurns)) {
|
|
128
|
-
if (
|
|
129
|
-
if (!turn.started_at) continue;
|
|
128
|
+
if (!['dispatched', 'starting', 'running', 'retrying'].includes(turn.status)) continue;
|
|
130
129
|
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
130
|
+
const lifecycleStart = parseGhostLifecycleStart(turn);
|
|
131
|
+
if (!Number.isFinite(lifecycleStart)) continue;
|
|
133
132
|
|
|
134
|
-
const runningMs = now -
|
|
133
|
+
const runningMs = now - lifecycleStart;
|
|
135
134
|
if (runningMs < startupThreshold) continue;
|
|
136
135
|
|
|
137
|
-
// Ghost detection: NO dispatch-progress file means subprocess never attached
|
|
138
136
|
const progressPath = join(root, getDispatchProgressRelativePath(turnId));
|
|
139
|
-
const
|
|
137
|
+
const progress = readDispatchProgressSafe(progressPath);
|
|
140
138
|
|
|
141
|
-
// If dispatch-progress exists, subprocess started — this is NOT a ghost turn.
|
|
142
|
-
// The regular stale-turn watchdog (BUG-47) will handle it if it goes silent.
|
|
143
|
-
if (hasProgress) continue;
|
|
144
|
-
|
|
145
|
-
// Also check for staged result (unlikely without progress, but be safe)
|
|
146
139
|
if (hasTurnScopedStagedResult(root, turnId)) continue;
|
|
147
|
-
|
|
148
|
-
// Check for any turn-scoped events beyond the initial dispatch event
|
|
149
|
-
if (hasRecentTurnEventActivity(root, turnId, startedAt, startupThreshold, now)) continue;
|
|
140
|
+
if (hasStartupProof(turn, progress)) continue;
|
|
150
141
|
|
|
151
142
|
const runningSeconds = Math.floor(runningMs / 1000);
|
|
152
|
-
const failureType =
|
|
143
|
+
const failureType = classifyStartupFailureType(turn, progress);
|
|
153
144
|
ghosts.push({
|
|
154
145
|
turn_id: turnId,
|
|
155
146
|
role: turn.assigned_role || 'unknown',
|
|
@@ -200,37 +191,11 @@ export function reconcileStaleTurns(root, state, config) {
|
|
|
200
191
|
|
|
201
192
|
// Process ghost turns (BUG-51) — transition to failed_start
|
|
202
193
|
for (const entry of ghosts) {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
status: 'failed_start',
|
|
209
|
-
failed_start_at: nowIso,
|
|
210
|
-
failed_start_reason: entry.failure_type,
|
|
211
|
-
failed_start_previous_status: turn.status,
|
|
212
|
-
failed_start_threshold_ms: entry.threshold_ms,
|
|
213
|
-
failed_start_running_ms: entry.running_ms,
|
|
214
|
-
recovery_command: `agentxchain reissue-turn --turn ${entry.turn_id} --reason ghost`,
|
|
215
|
-
};
|
|
216
|
-
changed = true;
|
|
217
|
-
|
|
218
|
-
// BUG-51 fix #6: Release budget reservation for ghost turns
|
|
219
|
-
delete budgetReservations[entry.turn_id];
|
|
220
|
-
|
|
221
|
-
emitRunEvent(root, 'turn_start_failed', {
|
|
222
|
-
run_id: state?.run_id || null,
|
|
223
|
-
phase: state?.phase || null,
|
|
224
|
-
status: 'blocked',
|
|
225
|
-
turn: { turn_id: entry.turn_id, role_id: entry.role },
|
|
226
|
-
payload: {
|
|
227
|
-
running_ms: entry.running_ms,
|
|
228
|
-
threshold_ms: entry.threshold_ms,
|
|
229
|
-
runtime_id: entry.runtime_id,
|
|
230
|
-
failure_type: entry.failure_type,
|
|
231
|
-
recommendation: entry.recommendation,
|
|
232
|
-
},
|
|
233
|
-
});
|
|
194
|
+
const applied = applyStartupFailureToActiveTurn(activeTurns, budgetReservations, entry, nowIso);
|
|
195
|
+
if (applied) {
|
|
196
|
+
emitStartupFailureEvent(root, state, entry);
|
|
197
|
+
changed = true;
|
|
198
|
+
}
|
|
234
199
|
}
|
|
235
200
|
|
|
236
201
|
// Process stale turns (BUG-47) — transition to stalled
|
|
@@ -271,32 +236,9 @@ export function reconcileStaleTurns(root, state, config) {
|
|
|
271
236
|
return { stale_turns: stale, ghost_turns: ghosts, state, changed: false };
|
|
272
237
|
}
|
|
273
238
|
|
|
274
|
-
const
|
|
275
|
-
const primary =
|
|
239
|
+
const nextState = buildBlockedStateFromEntries(state, activeTurns, budgetReservations, ghosts, stale, nowIso);
|
|
240
|
+
const primary = [...ghosts, ...stale][0];
|
|
276
241
|
const category = ghosts.length > 0 ? 'ghost_turn' : 'stale_turn';
|
|
277
|
-
const blockedOn = allDetected.length === 1
|
|
278
|
-
? `turn:${primary.failure_type ? 'failed_start' : 'stalled'}:${primary.turn_id}`
|
|
279
|
-
: ghosts.length > 0 ? 'turns:failed_start' : 'turns:stalled';
|
|
280
|
-
|
|
281
|
-
const nextState = {
|
|
282
|
-
...state,
|
|
283
|
-
status: 'blocked',
|
|
284
|
-
active_turns: activeTurns,
|
|
285
|
-
budget_reservations: budgetReservations,
|
|
286
|
-
blocked_on: blockedOn,
|
|
287
|
-
blocked_reason: {
|
|
288
|
-
category,
|
|
289
|
-
blocked_at: nowIso,
|
|
290
|
-
turn_id: primary.turn_id,
|
|
291
|
-
recovery: {
|
|
292
|
-
typed_reason: category,
|
|
293
|
-
owner: 'human',
|
|
294
|
-
recovery_action: primary.recommendation,
|
|
295
|
-
turn_retained: true,
|
|
296
|
-
detail: primary.recommendation,
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
242
|
|
|
301
243
|
safeWriteJson(join(root, '.agentxchain', 'state.json'), nextState);
|
|
302
244
|
emitRunEvent(root, 'run_blocked', {
|
|
@@ -340,13 +282,63 @@ function resolveStartupThreshold(config) {
|
|
|
340
282
|
return DEFAULT_STARTUP_WATCHDOG_MS;
|
|
341
283
|
}
|
|
342
284
|
|
|
285
|
+
export function failTurnStartup(root, state, config, turnId, details = {}) {
|
|
286
|
+
if (!state || typeof state !== 'object') {
|
|
287
|
+
return { ok: false, error: 'No governed state found' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const turn = state.active_turns?.[turnId];
|
|
291
|
+
if (!turn) {
|
|
292
|
+
return { ok: false, error: `Turn ${turnId} not found in active turns` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const nowIso = new Date().toISOString();
|
|
296
|
+
const activeTurns = { ...(state.active_turns || {}) };
|
|
297
|
+
const budgetReservations = { ...(state.budget_reservations || {}) };
|
|
298
|
+
const entry = {
|
|
299
|
+
turn_id: turnId,
|
|
300
|
+
role: turn.assigned_role || 'unknown',
|
|
301
|
+
runtime_id: turn.runtime_id || 'unknown',
|
|
302
|
+
running_ms: details.running_ms ?? computeLifecycleAgeMs(turn),
|
|
303
|
+
threshold_ms: details.threshold_ms ?? resolveStartupThreshold(config),
|
|
304
|
+
failure_type: classifyStartupFailureType(turn, null, details.failure_type || 'no_subprocess_output'),
|
|
305
|
+
recommendation: details.recommendation
|
|
306
|
+
|| `Turn ${turnId} failed to start cleanly. Run \`agentxchain reissue-turn --turn ${turnId} --reason ghost\` to recover.`,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (!applyStartupFailureToActiveTurn(activeTurns, budgetReservations, entry, nowIso)) {
|
|
310
|
+
return { ok: false, error: `Turn ${turnId} is not eligible for startup failure transition` };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const nextState = buildBlockedStateFromEntries(state, activeTurns, budgetReservations, [entry], [], nowIso);
|
|
314
|
+
safeWriteJson(join(root, '.agentxchain', 'state.json'), nextState);
|
|
315
|
+
emitStartupFailureEvent(root, state, entry);
|
|
316
|
+
emitRunEvent(root, 'run_blocked', {
|
|
317
|
+
run_id: nextState.run_id || null,
|
|
318
|
+
phase: nextState.phase || null,
|
|
319
|
+
status: 'blocked',
|
|
320
|
+
turn: { turn_id: entry.turn_id, role_id: entry.role },
|
|
321
|
+
payload: {
|
|
322
|
+
category: 'ghost_turn',
|
|
323
|
+
ghost_turn_ids: [entry.turn_id],
|
|
324
|
+
stalled_turn_ids: [],
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
return { ok: true, state: nextState, turn: nextState.active_turns?.[turnId] || null };
|
|
328
|
+
}
|
|
329
|
+
|
|
343
330
|
function hasRecentTurnEventActivity(root, turnId, startedAt, threshold, now) {
|
|
344
331
|
try {
|
|
345
332
|
const events = readRunEvents(root, { limit: 200 });
|
|
346
333
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
347
334
|
const event = events[i];
|
|
348
335
|
if (event?.turn?.turn_id !== turnId) continue;
|
|
349
|
-
if (
|
|
336
|
+
if (
|
|
337
|
+
event.event_type === 'turn_stalled'
|
|
338
|
+
|| event.event_type === 'turn_start_failed'
|
|
339
|
+
|| event.event_type === 'runtime_spawn_failed'
|
|
340
|
+
|| event.event_type === 'stdout_attach_failed'
|
|
341
|
+
) continue;
|
|
350
342
|
const timestamp = Date.parse(event.timestamp || '');
|
|
351
343
|
if (!Number.isFinite(timestamp)) continue;
|
|
352
344
|
if (timestamp < startedAt) continue;
|
|
@@ -360,9 +352,145 @@ function hasRecentTurnEventActivity(root, turnId, startedAt, threshold, now) {
|
|
|
360
352
|
return false;
|
|
361
353
|
}
|
|
362
354
|
|
|
355
|
+
function applyStartupFailureToActiveTurn(activeTurns, budgetReservations, entry, nowIso) {
|
|
356
|
+
const turn = activeTurns[entry.turn_id];
|
|
357
|
+
if (!turn || !['dispatched', 'starting', 'running', 'retrying'].includes(turn.status)) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
activeTurns[entry.turn_id] = {
|
|
362
|
+
...turn,
|
|
363
|
+
status: 'failed_start',
|
|
364
|
+
failed_start_at: nowIso,
|
|
365
|
+
failed_start_reason: entry.failure_type,
|
|
366
|
+
failed_start_previous_status: turn.status,
|
|
367
|
+
failed_start_threshold_ms: entry.threshold_ms,
|
|
368
|
+
failed_start_running_ms: entry.running_ms,
|
|
369
|
+
recovery_command: `agentxchain reissue-turn --turn ${entry.turn_id} --reason ghost`,
|
|
370
|
+
};
|
|
371
|
+
delete budgetReservations[entry.turn_id];
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function emitStartupFailureEvent(root, state, entry) {
|
|
376
|
+
const payload = {
|
|
377
|
+
running_ms: entry.running_ms,
|
|
378
|
+
threshold_ms: entry.threshold_ms,
|
|
379
|
+
runtime_id: entry.runtime_id,
|
|
380
|
+
failure_type: entry.failure_type,
|
|
381
|
+
recommendation: entry.recommendation,
|
|
382
|
+
};
|
|
383
|
+
const details = {
|
|
384
|
+
run_id: state?.run_id || null,
|
|
385
|
+
phase: state?.phase || null,
|
|
386
|
+
status: 'blocked',
|
|
387
|
+
turn: { turn_id: entry.turn_id, role_id: entry.role },
|
|
388
|
+
payload,
|
|
389
|
+
};
|
|
390
|
+
emitRunEvent(root, 'turn_start_failed', details);
|
|
391
|
+
const failureEventType = mapStartupFailureEventType(entry.failure_type);
|
|
392
|
+
if (failureEventType) {
|
|
393
|
+
emitRunEvent(root, failureEventType, details);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildBlockedStateFromEntries(state, activeTurns, budgetReservations, ghosts, stale, nowIso) {
|
|
398
|
+
const allDetected = [...ghosts, ...stale];
|
|
399
|
+
const primary = allDetected[0];
|
|
400
|
+
const category = ghosts.length > 0 ? 'ghost_turn' : 'stale_turn';
|
|
401
|
+
const blockedOn = allDetected.length === 1
|
|
402
|
+
? `turn:${primary.failure_type ? 'failed_start' : 'stalled'}:${primary.turn_id}`
|
|
403
|
+
: ghosts.length > 0 ? 'turns:failed_start' : 'turns:stalled';
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
...state,
|
|
407
|
+
status: 'blocked',
|
|
408
|
+
active_turns: activeTurns,
|
|
409
|
+
budget_reservations: budgetReservations,
|
|
410
|
+
blocked_on: blockedOn,
|
|
411
|
+
blocked_reason: {
|
|
412
|
+
category,
|
|
413
|
+
blocked_at: nowIso,
|
|
414
|
+
turn_id: primary.turn_id,
|
|
415
|
+
recovery: {
|
|
416
|
+
typed_reason: category,
|
|
417
|
+
owner: 'human',
|
|
418
|
+
recovery_action: primary.recommendation,
|
|
419
|
+
turn_retained: true,
|
|
420
|
+
detail: primary.recommendation,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function parseGhostLifecycleStart(turn) {
|
|
427
|
+
if (turn.status === 'dispatched') {
|
|
428
|
+
return Date.parse(turn.dispatched_at || turn.assigned_at || '');
|
|
429
|
+
}
|
|
430
|
+
return Date.parse(turn.started_at || turn.dispatched_at || turn.assigned_at || '');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function computeLifecycleAgeMs(turn) {
|
|
434
|
+
const start = parseGhostLifecycleStart(turn);
|
|
435
|
+
if (!Number.isFinite(start)) return 0;
|
|
436
|
+
return Math.max(0, Date.now() - start);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function readDispatchProgressSafe(progressPath) {
|
|
440
|
+
if (!existsSync(progressPath)) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse(readFileSync(progressPath, 'utf8'));
|
|
445
|
+
} catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function classifyStartupFailureType(turn, progress, fallback = 'no_subprocess_output') {
|
|
451
|
+
if (fallback === 'runtime_spawn_failed' || fallback === 'stdout_attach_failed') {
|
|
452
|
+
return fallback;
|
|
453
|
+
}
|
|
454
|
+
if (turn?.status === 'dispatched') {
|
|
455
|
+
return 'runtime_spawn_failed';
|
|
456
|
+
}
|
|
457
|
+
const hasWorkerAttachProof = Boolean(
|
|
458
|
+
turn?.worker_attached_at
|
|
459
|
+
|| turn?.worker_pid != null
|
|
460
|
+
|| progress?.pid != null,
|
|
461
|
+
);
|
|
462
|
+
if (turn?.status === 'starting' || hasWorkerAttachProof) {
|
|
463
|
+
return 'stdout_attach_failed';
|
|
464
|
+
}
|
|
465
|
+
return fallback;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function mapStartupFailureEventType(failureType) {
|
|
469
|
+
if (failureType === 'runtime_spawn_failed') {
|
|
470
|
+
return 'runtime_spawn_failed';
|
|
471
|
+
}
|
|
472
|
+
if (failureType === 'stdout_attach_failed') {
|
|
473
|
+
return 'stdout_attach_failed';
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function hasStartupProof(turn, progress) {
|
|
479
|
+
if (turn.first_output_at) {
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
if (!progress || typeof progress !== 'object') {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
if (progress.first_output_at) {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
return Number(progress.output_lines || 0) > 0 || Number(progress.stderr_lines || 0) > 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
363
491
|
function hasTurnScopedStagedResult(root, turnId) {
|
|
364
492
|
const turnScopedPath = join(root, getTurnStagingResultPath(turnId));
|
|
365
|
-
if (
|
|
493
|
+
if (hasMeaningfulStagedResult(turnScopedPath)) {
|
|
366
494
|
return true;
|
|
367
495
|
}
|
|
368
496
|
|
|
@@ -139,6 +139,53 @@ function buildCheckpointCommit(entry) {
|
|
|
139
139
|
return { subject, body: bodyLines.join('\n') };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
function diffMissingDeclaredPaths(declaredFiles, stagedFiles) {
|
|
143
|
+
const stagedSet = new Set((Array.isArray(stagedFiles) ? stagedFiles : []).map((value) => value.trim()).filter(Boolean));
|
|
144
|
+
return (Array.isArray(declaredFiles) ? declaredFiles : []).filter((filePath) => !stagedSet.has(filePath));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Partition paths that were missing from the staged-diff into
|
|
149
|
+
* (a) paths genuinely absent from git (untracked or dirty without staging)
|
|
150
|
+
* (b) paths already committed upstream (tracked in HEAD, no pending diff)
|
|
151
|
+
*
|
|
152
|
+
* BUG-55A completeness must only fail on (a). An actor that committed a
|
|
153
|
+
* declared file before `checkpoint-turn` ran (see BUG-23 scenario) is
|
|
154
|
+
* already-checkpointed-upstream; treating that as "missing from checkpoint"
|
|
155
|
+
* is a false positive from the completeness gate.
|
|
156
|
+
*/
|
|
157
|
+
function partitionDeclaredPathsByUpstreamPresence(root, missingPaths) {
|
|
158
|
+
const genuinelyMissing = [];
|
|
159
|
+
const alreadyCommittedUpstream = [];
|
|
160
|
+
for (const filePath of missingPaths) {
|
|
161
|
+
let tracked = false;
|
|
162
|
+
try {
|
|
163
|
+
git(root, ['ls-files', '--error-unmatch', '--', filePath]);
|
|
164
|
+
tracked = true;
|
|
165
|
+
} catch {
|
|
166
|
+
tracked = false;
|
|
167
|
+
}
|
|
168
|
+
if (!tracked) {
|
|
169
|
+
genuinelyMissing.push(filePath);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
let hasDivergence = false;
|
|
173
|
+
try {
|
|
174
|
+
const headDiff = git(root, ['diff', 'HEAD', '--', filePath]);
|
|
175
|
+
const cachedDiff = git(root, ['diff', '--cached', '--', filePath]);
|
|
176
|
+
hasDivergence = Boolean(headDiff) || Boolean(cachedDiff);
|
|
177
|
+
} catch {
|
|
178
|
+
hasDivergence = true;
|
|
179
|
+
}
|
|
180
|
+
if (hasDivergence) {
|
|
181
|
+
genuinelyMissing.push(filePath);
|
|
182
|
+
} else {
|
|
183
|
+
alreadyCommittedUpstream.push(filePath);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { genuinelyMissing, alreadyCommittedUpstream };
|
|
187
|
+
}
|
|
188
|
+
|
|
142
189
|
export function detectPendingCheckpoint(root, dirtyFiles = []) {
|
|
143
190
|
const actorDirtyFiles = normalizeFilesChanged(dirtyFiles);
|
|
144
191
|
if (actorDirtyFiles.length === 0) return { required: false };
|
|
@@ -233,12 +280,29 @@ export function checkpointAcceptedTurn(root, opts = {}) {
|
|
|
233
280
|
};
|
|
234
281
|
}
|
|
235
282
|
|
|
283
|
+
const rawMissingFromStage = diffMissingDeclaredPaths(filesChanged, staged);
|
|
284
|
+
const { genuinelyMissing, alreadyCommittedUpstream } =
|
|
285
|
+
partitionDeclaredPathsByUpstreamPresence(root, rawMissingFromStage);
|
|
286
|
+
if (genuinelyMissing.length > 0) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
turn: entry,
|
|
290
|
+
error: `Checkpoint completeness failure: accepted turn ${entry.turn_id} declared ${filesChanged.length} checkpointable file(s), but Git staged only ${staged.length} and ${genuinelyMissing.length} declared path(s) are absent from git. Missing from checkpoint: ${genuinelyMissing.join(', ')}.`,
|
|
291
|
+
missing_declared_paths: genuinelyMissing,
|
|
292
|
+
already_committed_upstream: alreadyCommittedUpstream,
|
|
293
|
+
staged_paths: staged,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
236
297
|
if (staged.length === 0) {
|
|
237
298
|
return {
|
|
238
299
|
ok: true,
|
|
239
300
|
skipped: true,
|
|
240
301
|
turn: entry,
|
|
241
|
-
reason:
|
|
302
|
+
reason: alreadyCommittedUpstream.length > 0
|
|
303
|
+
? `Accepted turn ${entry.turn_id} has no staged repo changes to checkpoint; all ${alreadyCommittedUpstream.length} declared file(s) already present in HEAD.`
|
|
304
|
+
: `Accepted turn ${entry.turn_id} has no staged repo changes to checkpoint.`,
|
|
305
|
+
already_committed_upstream: alreadyCommittedUpstream,
|
|
242
306
|
};
|
|
243
307
|
}
|
|
244
308
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight staged turn-result shape guard.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally weaker than full acceptance validation. It exists for
|
|
5
|
+
* adapter pre-stage checks so obviously incomplete payloads (`{}`,
|
|
6
|
+
* `{"turn_id":"t1"}`, etc.) are rejected before they can be written into the
|
|
7
|
+
* governed staging path and mistaken for meaningful execution output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function isNonEmptyString(value) {
|
|
11
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns true when `value` has the minimum governed turn-result envelope:
|
|
16
|
+
* - `schema_version`
|
|
17
|
+
* - at least one identity field (`run_id` or `turn_id`)
|
|
18
|
+
* - at least one lifecycle field (`status`, `role`, or `runtime_id`)
|
|
19
|
+
*
|
|
20
|
+
* Full schema validation still happens later via `validateStagedTurnResult`.
|
|
21
|
+
*
|
|
22
|
+
* @param {unknown} value
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
export function hasMinimumTurnResultShape(value) {
|
|
26
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const candidate = /** @type {Record<string, unknown>} */ (value);
|
|
31
|
+
const hasSchemaVersion = isNonEmptyString(candidate.schema_version);
|
|
32
|
+
const hasIdentity = isNonEmptyString(candidate.run_id) || isNonEmptyString(candidate.turn_id);
|
|
33
|
+
const hasLifecycle = isNonEmptyString(candidate.status)
|
|
34
|
+
|| isNonEmptyString(candidate.role)
|
|
35
|
+
|| isNonEmptyString(candidate.runtime_id);
|
|
36
|
+
|
|
37
|
+
return hasSchemaVersion && hasIdentity && hasLifecycle;
|
|
38
|
+
}
|
|
@@ -75,7 +75,10 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
|
|
|
75
75
|
const normContext = {};
|
|
76
76
|
if (state) {
|
|
77
77
|
normContext.phase = state.phase;
|
|
78
|
-
//
|
|
78
|
+
// Prefer active_turns (the persisted schema field); fall back to the
|
|
79
|
+
// current_turn compatibility alias for callers that pass a state shape
|
|
80
|
+
// built outside loadProjectState() (e.g. raw fixtures). Both surfaces are
|
|
81
|
+
// live per DEC-CURRENT-TURN-COMPAT-ALIAS-001 — current_turn is not legacy.
|
|
79
82
|
const activeTurn = getActiveTurn(state) || state.current_turn;
|
|
80
83
|
if (activeTurn) {
|
|
81
84
|
const roleKey = activeTurn.assigned_role || activeTurn.role;
|
|
@@ -597,6 +600,15 @@ function validateVerification(tr) {
|
|
|
597
600
|
}
|
|
598
601
|
}
|
|
599
602
|
|
|
603
|
+
if (Array.isArray(v.commands)) {
|
|
604
|
+
for (let i = 0; i < v.commands.length; i++) {
|
|
605
|
+
const command = v.commands[i];
|
|
606
|
+
if (typeof command !== 'string' || command.trim().length === 0) {
|
|
607
|
+
errors.push(`verification.commands[${i}] must be a non-empty string.`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
600
612
|
// machine_evidence exit codes should be consistent with status
|
|
601
613
|
if (Array.isArray(v.machine_evidence)) {
|
|
602
614
|
for (let i = 0; i < v.machine_evidence.length; i++) {
|
|
@@ -605,8 +617,8 @@ function validateVerification(tr) {
|
|
|
605
617
|
errors.push(`verification.machine_evidence[${i}] must be an object.`);
|
|
606
618
|
continue;
|
|
607
619
|
}
|
|
608
|
-
if (typeof entry.command !== 'string') {
|
|
609
|
-
errors.push(`verification.machine_evidence[${i}].command must be a string.`);
|
|
620
|
+
if (typeof entry.command !== 'string' || entry.command.trim().length === 0) {
|
|
621
|
+
errors.push(`verification.machine_evidence[${i}].command must be a non-empty string.`);
|
|
610
622
|
}
|
|
611
623
|
if (typeof entry.exit_code !== 'number' || !Number.isInteger(entry.exit_code)) {
|
|
612
624
|
errors.push(`verification.machine_evidence[${i}].exit_code must be an integer.`);
|