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
|
@@ -395,6 +395,11 @@ export function validateV4Config(data, projectRoot) {
|
|
|
395
395
|
} else {
|
|
396
396
|
if (typeof data.project.id !== 'string' || !data.project.id.trim()) errors.push('project.id must be a non-empty string');
|
|
397
397
|
if (typeof data.project.name !== 'string' || !data.project.name.trim()) errors.push('project.name must be a non-empty string');
|
|
398
|
+
if ('default_branch' in data.project) {
|
|
399
|
+
if (typeof data.project.default_branch !== 'string' || !data.project.default_branch.trim()) {
|
|
400
|
+
errors.push('project.default_branch must be a non-empty string when provided');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
398
403
|
// Optional project.goal field
|
|
399
404
|
if (data.project.goal !== undefined && data.project.goal !== null) {
|
|
400
405
|
if (typeof data.project.goal !== 'string') {
|
|
@@ -480,6 +485,17 @@ export function validateV4Config(data, projectRoot) {
|
|
|
480
485
|
}
|
|
481
486
|
}
|
|
482
487
|
}
|
|
488
|
+
// Schema publishes max_output_tokens as `integer, minimum: 1`. The
|
|
489
|
+
// api-proxy adapter silently falls back to 4096 on `0` / null /
|
|
490
|
+
// undefined and passes negative/non-integer values straight through
|
|
491
|
+
// to the provider, which is the same silent-fallback defect class
|
|
492
|
+
// the run_loop watchdog knobs had (DEC-SILENT-FALLBACK-DEFECT-CLASS-001).
|
|
493
|
+
// Reject at write time so the operator sees the bad value immediately.
|
|
494
|
+
if ('max_output_tokens' in rt) {
|
|
495
|
+
if (!Number.isInteger(rt.max_output_tokens) || rt.max_output_tokens < 1) {
|
|
496
|
+
errors.push(`Runtime "${id}": max_output_tokens must be a positive integer`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
483
499
|
if ('retry_policy' in rt) {
|
|
484
500
|
validateApiProxyRetryPolicy(id, rt.retry_policy, errors);
|
|
485
501
|
}
|
|
@@ -597,6 +613,14 @@ export function validateV4Config(data, projectRoot) {
|
|
|
597
613
|
errors.push(...timeoutValidation.errors);
|
|
598
614
|
}
|
|
599
615
|
|
|
616
|
+
// Run-loop watchdog knobs (BUG-47 / BUG-51). Schema publishes both as
|
|
617
|
+
// positive integers; runtime silently falls back to defaults on bad input,
|
|
618
|
+
// which misleads operators into thinking their config --set took effect.
|
|
619
|
+
// Reject at config-write / validate time so the operator sees the problem.
|
|
620
|
+
if (data.run_loop !== undefined) {
|
|
621
|
+
errors.push(...validateRunLoopConfig(data.run_loop));
|
|
622
|
+
}
|
|
623
|
+
|
|
600
624
|
// Admission control (ADM-001..004) is handled by the validate, doctor, and
|
|
601
625
|
// run-loop paths which call runAdmissionControl() directly. Config schema
|
|
602
626
|
// validation here should not duplicate that surface.
|
|
@@ -604,6 +628,30 @@ export function validateV4Config(data, projectRoot) {
|
|
|
604
628
|
return { ok: errors.length === 0, errors, warnings };
|
|
605
629
|
}
|
|
606
630
|
|
|
631
|
+
export function validateRunLoopConfig(runLoop) {
|
|
632
|
+
const errors = [];
|
|
633
|
+
if (runLoop === null || typeof runLoop !== 'object' || Array.isArray(runLoop)) {
|
|
634
|
+
errors.push('run_loop must be an object');
|
|
635
|
+
return errors;
|
|
636
|
+
}
|
|
637
|
+
validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
|
|
638
|
+
validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
|
|
639
|
+
return errors;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function validateRunLoopPositiveInteger(path, value, errors) {
|
|
643
|
+
if (value === undefined || value === null) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
647
|
+
errors.push(`${path} must be a positive integer (milliseconds)`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (value < 1) {
|
|
651
|
+
errors.push(`${path} must be a positive integer (milliseconds)`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
607
655
|
export function validateBudgetConfig(budget) {
|
|
608
656
|
const errors = [];
|
|
609
657
|
|
|
@@ -1145,7 +1193,9 @@ export function normalizeV4(raw) {
|
|
|
1145
1193
|
id: raw.project?.id || 'unknown',
|
|
1146
1194
|
name: raw.project?.name || 'Unknown',
|
|
1147
1195
|
...(typeof raw.project?.goal === 'string' && raw.project.goal.trim() ? { goal: raw.project.goal.trim() } : {}),
|
|
1148
|
-
default_branch: raw.project?.default_branch
|
|
1196
|
+
default_branch: typeof raw.project?.default_branch === 'string' && raw.project.default_branch.trim()
|
|
1197
|
+
? raw.project.default_branch.trim()
|
|
1198
|
+
: 'main',
|
|
1149
1199
|
},
|
|
1150
1200
|
roles,
|
|
1151
1201
|
runtimes: raw.runtimes || {},
|
|
@@ -55,6 +55,8 @@ function describeEvent(eventType, entry) {
|
|
|
55
55
|
case 'turn_checkpointed':
|
|
56
56
|
case 'turn_stalled':
|
|
57
57
|
case 'turn_start_failed':
|
|
58
|
+
case 'runtime_spawn_failed':
|
|
59
|
+
case 'stdout_attach_failed':
|
|
58
60
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
59
61
|
case 'dispatch_progress':
|
|
60
62
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
@@ -77,6 +79,15 @@ function describeEvent(eventType, entry) {
|
|
|
77
79
|
case 'escalation_resolved':
|
|
78
80
|
case 'budget_exceeded_warn':
|
|
79
81
|
return `${prefix}${eventType}`;
|
|
82
|
+
case 'session_continuation': {
|
|
83
|
+
const prev = trimToNull(entry.payload?.previous_run_id);
|
|
84
|
+
const next = trimToNull(entry.payload?.next_run_id);
|
|
85
|
+
const objective = trimToNull(entry.payload?.next_objective);
|
|
86
|
+
if (prev && next) {
|
|
87
|
+
return `${prefix}${eventType} ${prev} -> ${next}${objective ? ` (${objective})` : ''}`;
|
|
88
|
+
}
|
|
89
|
+
return `${prefix}${eventType}`;
|
|
90
|
+
}
|
|
80
91
|
default:
|
|
81
92
|
if (trimToNull(entry.summary)) return entry.summary.trim();
|
|
82
93
|
return `${prefix}${eventType || 'unknown_event'}`;
|
package/src/lib/run-events.js
CHANGED
|
@@ -25,6 +25,9 @@ export const VALID_RUN_EVENTS = [
|
|
|
25
25
|
'acceptance_failed',
|
|
26
26
|
'turn_reissued',
|
|
27
27
|
'turn_stalled',
|
|
28
|
+
'turn_start_failed',
|
|
29
|
+
'runtime_spawn_failed',
|
|
30
|
+
'stdout_attach_failed',
|
|
28
31
|
'turn_checkpointed',
|
|
29
32
|
'coordinator_retry',
|
|
30
33
|
'coordinator_retry_projection_warning',
|
|
@@ -39,6 +42,7 @@ export const VALID_RUN_EVENTS = [
|
|
|
39
42
|
'human_escalation_raised',
|
|
40
43
|
'human_escalation_resolved',
|
|
41
44
|
'dispatch_progress',
|
|
45
|
+
'session_continuation',
|
|
42
46
|
];
|
|
43
47
|
|
|
44
48
|
/**
|
package/src/lib/run-loop.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
getActiveTurnCount,
|
|
32
32
|
getActiveTurns,
|
|
33
33
|
getMaxConcurrentTurns,
|
|
34
|
+
transitionActiveTurnLifecycle,
|
|
34
35
|
RUNNER_INTERFACE_VERSION,
|
|
35
36
|
} from './runner-interface.js';
|
|
36
37
|
|
|
@@ -40,6 +41,18 @@ import { join, dirname } from 'path';
|
|
|
40
41
|
import { evaluateApprovalSlaReminders } from './notification-runner.js';
|
|
41
42
|
import { validatePreemptionMarker } from './intake.js';
|
|
42
43
|
import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
|
|
44
|
+
import { hasMinimumTurnResultShape } from './turn-result-shape.js';
|
|
45
|
+
|
|
46
|
+
// Per DEC-RUN-LOOP-MIN-SHAPE-SYMMETRY-001 (Turn 33): runLoop is the SDK boundary
|
|
47
|
+
// any third-party runner can wire (see website-v2/docs/build-your-own-runner.mdx).
|
|
48
|
+
// In-repo adapters (api_proxy, mcp, local_cli, remote_agent) already validate
|
|
49
|
+
// staged-result shape before write per DEC-MINIMUM-TURN-RESULT-SHAPE-001, and
|
|
50
|
+
// run.js's dispatch callback re-validates before returning per
|
|
51
|
+
// DEC-RUN-STAGED-READ-SHAPE-GUARD-001. Third-party callbacks have no such
|
|
52
|
+
// obligation. runLoop must therefore validate dispatchResult.turnResult shape
|
|
53
|
+
// before persisting it as a governed staged-result artifact.
|
|
54
|
+
const MIN_SHAPE_REJECTION_REASON =
|
|
55
|
+
'staged result missing minimum governed envelope (schema_version + identity + lifecycle fields)';
|
|
43
56
|
|
|
44
57
|
const DEFAULT_MAX_TURNS = 50;
|
|
45
58
|
|
|
@@ -182,7 +195,7 @@ async function executeSequentialTurn(root, config, state, callbacks, emit, error
|
|
|
182
195
|
let assignState;
|
|
183
196
|
const activeTurn = getActiveTurn(state);
|
|
184
197
|
|
|
185
|
-
if (activeTurn && (activeTurn
|
|
198
|
+
if (activeTurn && isDispatchableActiveTurn(activeTurn)) {
|
|
186
199
|
turn = activeTurn;
|
|
187
200
|
assignState = state;
|
|
188
201
|
} else {
|
|
@@ -224,7 +237,7 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
224
237
|
const activeTurns = getActiveTurns(state);
|
|
225
238
|
const turnsToDispatch = [];
|
|
226
239
|
for (const turn of Object.values(activeTurns)) {
|
|
227
|
-
if (turn
|
|
240
|
+
if (isDispatchableActiveTurn(turn)) {
|
|
228
241
|
turnsToDispatch.push({ turn, state });
|
|
229
242
|
}
|
|
230
243
|
}
|
|
@@ -317,6 +330,7 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
317
330
|
errors.push(`writeDispatchBundle(${turn.assigned_role}): ${bundleResult.error}`);
|
|
318
331
|
continue;
|
|
319
332
|
}
|
|
333
|
+
transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
|
|
320
334
|
const stagingPath = getTurnStagingResultPath(turn.turn_id);
|
|
321
335
|
contexts.push({
|
|
322
336
|
turn,
|
|
@@ -362,6 +376,23 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
362
376
|
continue;
|
|
363
377
|
}
|
|
364
378
|
|
|
379
|
+
if (dispatchResult.accept && !hasMinimumTurnResultShape(dispatchResult.turnResult)) {
|
|
380
|
+
// DEC-RUN-LOOP-MIN-SHAPE-SYMMETRY-001: third-party dispatch callback claimed
|
|
381
|
+
// accept=true but returned a payload missing the minimum envelope. Refuse to
|
|
382
|
+
// stage; convert to standard rejection so the run state advances cleanly.
|
|
383
|
+
const validationResult = { stage: 'dispatch', errors: [MIN_SHAPE_REJECTION_REASON] };
|
|
384
|
+
rejectTurn(root, config, validationResult, MIN_SHAPE_REJECTION_REASON, { turnId: turn.turn_id });
|
|
385
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
|
|
386
|
+
emit({ type: 'turn_rejected', turn, role: roleId, reason: MIN_SHAPE_REJECTION_REASON });
|
|
387
|
+
const postRejectState = loadState(root, config);
|
|
388
|
+
if (postRejectState?.status === 'blocked') {
|
|
389
|
+
errors.push(`Turn rejected for ${roleId}, retries exhausted`);
|
|
390
|
+
emit({ type: 'blocked', state: postRejectState });
|
|
391
|
+
return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history, acceptedCount };
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
365
396
|
if (dispatchResult.accept) {
|
|
366
397
|
const absStaging = join(root, ctx.stagingPath);
|
|
367
398
|
mkdirSync(dirname(absStaging), { recursive: true });
|
|
@@ -409,6 +440,12 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
409
440
|
}
|
|
410
441
|
emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
|
|
411
442
|
} else {
|
|
443
|
+
if (dispatchResult?.blocked === true) {
|
|
444
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: false, blocked: true });
|
|
445
|
+
const blockedState = loadState(root, config);
|
|
446
|
+
emit({ type: 'blocked', state: blockedState });
|
|
447
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history, acceptedCount };
|
|
448
|
+
}
|
|
412
449
|
const validationResult = {
|
|
413
450
|
stage: 'dispatch',
|
|
414
451
|
errors: [dispatchResult.reason || 'Dispatch callback rejected the turn'],
|
|
@@ -449,6 +486,10 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
449
486
|
return { terminal: false, history, acceptedCount };
|
|
450
487
|
}
|
|
451
488
|
|
|
489
|
+
function isDispatchableActiveTurn(turn) {
|
|
490
|
+
return ['assigned', 'dispatched', 'starting', 'running', 'retrying'].includes(turn?.status);
|
|
491
|
+
}
|
|
492
|
+
|
|
452
493
|
/**
|
|
453
494
|
* Dispatch a single turn and process its result.
|
|
454
495
|
*/
|
|
@@ -463,6 +504,7 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
|
|
|
463
504
|
errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
|
|
464
505
|
return { terminal: true, ok: false, stop_reason: 'blocked', history };
|
|
465
506
|
}
|
|
507
|
+
transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
|
|
466
508
|
|
|
467
509
|
const stagingPath = getTurnStagingResultPath(turn.turn_id);
|
|
468
510
|
const context = {
|
|
@@ -488,6 +530,22 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
|
|
|
488
530
|
return { terminal: true, ok: false, stop_reason: 'blocked', history };
|
|
489
531
|
}
|
|
490
532
|
|
|
533
|
+
if (dispatchResult.accept && !hasMinimumTurnResultShape(dispatchResult.turnResult)) {
|
|
534
|
+
// DEC-RUN-LOOP-MIN-SHAPE-SYMMETRY-001: same boundary as parallel branch.
|
|
535
|
+
// Refuse to stage; convert to a standard rejection.
|
|
536
|
+
const validationResult = { stage: 'dispatch', errors: [MIN_SHAPE_REJECTION_REASON] };
|
|
537
|
+
rejectTurn(root, config, validationResult, MIN_SHAPE_REJECTION_REASON);
|
|
538
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
|
|
539
|
+
emit({ type: 'turn_rejected', turn, role: roleId, reason: MIN_SHAPE_REJECTION_REASON });
|
|
540
|
+
const postRejectState = loadState(root, config);
|
|
541
|
+
if (postRejectState?.status === 'blocked') {
|
|
542
|
+
errors.push(`Turn rejected for ${roleId}, retries exhausted`);
|
|
543
|
+
emit({ type: 'blocked', state: postRejectState });
|
|
544
|
+
return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history };
|
|
545
|
+
}
|
|
546
|
+
return { terminal: false, accepted: false, history };
|
|
547
|
+
}
|
|
548
|
+
|
|
491
549
|
if (dispatchResult.accept) {
|
|
492
550
|
const absStaging = join(root, stagingPath);
|
|
493
551
|
mkdirSync(dirname(absStaging), { recursive: true });
|
|
@@ -537,6 +595,13 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
|
|
|
537
595
|
return { terminal: false, accepted: true, history };
|
|
538
596
|
}
|
|
539
597
|
|
|
598
|
+
if (dispatchResult?.blocked === true) {
|
|
599
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: false, blocked: true });
|
|
600
|
+
const blockedState = loadState(root, config);
|
|
601
|
+
emit({ type: 'blocked', state: blockedState });
|
|
602
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history };
|
|
603
|
+
}
|
|
604
|
+
|
|
540
605
|
// Rejection
|
|
541
606
|
const validationResult = {
|
|
542
607
|
stage: 'dispatch',
|
package/src/lib/schema.js
CHANGED
|
@@ -35,6 +35,13 @@ export function validateGovernedStateSchema(data) {
|
|
|
35
35
|
// but validators and read-only surfaces still tolerate reserved/manual states.
|
|
36
36
|
const VALID_RUN_STATUSES = ['idle', 'active', 'paused', 'blocked', 'completed', 'failed'];
|
|
37
37
|
const isV1_1 = data?.schema_version === '1.1';
|
|
38
|
+
// NOTE: `current_turn` is the persisted v1.0 schema field. Under v1.1 it is
|
|
39
|
+
// not a persisted field at all — `loadProjectState()` re-attaches it as a
|
|
40
|
+
// non-enumerable getter alias over `active_turns` after normalization
|
|
41
|
+
// (DEC-CURRENT-TURN-COMPAT-ALIAS-001). This validator runs against the
|
|
42
|
+
// persisted shape, so an `own` property named `current_turn` on a v1.1 doc
|
|
43
|
+
// means "stray persisted-shape leak from a legacy write" and is rejected
|
|
44
|
+
// below — it does NOT mean the runtime alias is going away.
|
|
38
45
|
const hasLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(data || {}, 'current_turn');
|
|
39
46
|
|
|
40
47
|
function validateTurn(turn, label) {
|
|
@@ -85,7 +85,21 @@
|
|
|
85
85
|
"type": "object"
|
|
86
86
|
},
|
|
87
87
|
"run_loop": {
|
|
88
|
-
"type": "object"
|
|
88
|
+
"type": "object",
|
|
89
|
+
"description": "Runner control knobs for execution watchdogs and automation behavior.",
|
|
90
|
+
"properties": {
|
|
91
|
+
"startup_watchdog_ms": {
|
|
92
|
+
"type": "integer",
|
|
93
|
+
"minimum": 1,
|
|
94
|
+
"description": "Milliseconds to wait after dispatch for worker attach/first-output proof before retaining the turn as failed_start. Default 30000."
|
|
95
|
+
},
|
|
96
|
+
"stale_turn_threshold_ms": {
|
|
97
|
+
"type": "integer",
|
|
98
|
+
"minimum": 1,
|
|
99
|
+
"description": "Milliseconds to wait before a started turn that previously produced output is treated as stale. Default 600000 for local_cli turns and 300000 for api_proxy turns."
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"additionalProperties": true
|
|
89
103
|
},
|
|
90
104
|
"mission_planner": {
|
|
91
105
|
"type": "object"
|
|
@@ -148,7 +148,10 @@
|
|
|
148
148
|
},
|
|
149
149
|
"commands": {
|
|
150
150
|
"type": "array",
|
|
151
|
-
"items": {
|
|
151
|
+
"items": {
|
|
152
|
+
"type": "string",
|
|
153
|
+
"pattern": "\\S"
|
|
154
|
+
},
|
|
152
155
|
"description": "Verification commands that were run."
|
|
153
156
|
},
|
|
154
157
|
"evidence_summary": {
|
|
@@ -161,7 +164,10 @@
|
|
|
161
164
|
"type": "object",
|
|
162
165
|
"required": ["command", "exit_code"],
|
|
163
166
|
"properties": {
|
|
164
|
-
"command": {
|
|
167
|
+
"command": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"pattern": "\\S"
|
|
170
|
+
},
|
|
165
171
|
"exit_code": { "type": "integer" }
|
|
166
172
|
}
|
|
167
173
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged turn-result proof helpers.
|
|
3
|
+
*
|
|
4
|
+
* Per DEC-BUG51-STAGING-PLACEHOLDER-NOT-PROOF-001: a turn-scoped staged-result
|
|
5
|
+
* file is proof of execution only when it contains meaningful result content.
|
|
6
|
+
* Adapter-authored placeholders (`{}`, blank, whitespace-only) are cleanup
|
|
7
|
+
* artifacts — watchdog, adapter, and recovery code must treat them as absent.
|
|
8
|
+
*
|
|
9
|
+
* This module centralizes that check so every surface (local-cli adapter,
|
|
10
|
+
* manual adapter, stale-turn watchdog) uses the same rule.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns true when the staged-result file at `filePath` exists AND contains
|
|
17
|
+
* content that is not a placeholder (empty, whitespace-only, or `{}`).
|
|
18
|
+
*
|
|
19
|
+
* Trim-aware: `{}\n`, ` {}\n`, and `{}` are all rejected. Legitimate turn
|
|
20
|
+
* results carry the full governed schema and are far larger than the
|
|
21
|
+
* placeholder shapes this function filters.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} filePath - absolute path to the staged-result file
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function hasMeaningfulStagedResult(filePath) {
|
|
27
|
+
if (!existsSync(filePath)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(filePath, 'utf8');
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const trimmed = raw.trim();
|
|
39
|
+
if (trimmed === '' || trimmed === '{}') {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|