agentxchain 2.154.10 → 2.155.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/bin/agentxchain.js +1 -0
- package/package.json +1 -1
- package/src/commands/init.js +24 -0
- package/src/commands/run.js +14 -2
- package/src/commands/schedule.js +29 -12
- package/src/lib/continuous-run.js +534 -14
- package/src/lib/governed-state.js +59 -0
- package/src/lib/idle-expansion-result-validator.js +251 -0
- package/src/lib/intake.js +85 -1
- package/src/lib/normalized-config.js +64 -0
- package/src/lib/schemas/agentxchain-config.schema.json +31 -0
- package/src/lib/schemas/turn-result.schema.json +67 -0
- package/src/lib/turn-result-validator.js +25 -0
- package/src/lib/vision-reader.js +165 -1
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
import { existsSync, readFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
resolveVisionPath,
|
|
17
|
+
deriveVisionCandidates,
|
|
18
|
+
captureVisionHeadingsSnapshot,
|
|
19
|
+
computeVisionContentSha,
|
|
20
|
+
buildSourceManifest,
|
|
21
|
+
} from './vision-reader.js';
|
|
16
22
|
import {
|
|
17
23
|
recordEvent,
|
|
18
24
|
triageIntent,
|
|
@@ -21,6 +27,7 @@ import {
|
|
|
21
27
|
prepareIntentForDispatch,
|
|
22
28
|
consumeNextApprovedIntent,
|
|
23
29
|
resolveIntent,
|
|
30
|
+
buildVisionIdleExpansionSignal,
|
|
24
31
|
} from './intake.js';
|
|
25
32
|
import { loadProjectState } from './config.js';
|
|
26
33
|
import { safeWriteJson } from './safe-write.js';
|
|
@@ -74,7 +81,7 @@ export function removeContinuousSession(root) {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
77
|
-
function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd, currentRunId = null) {
|
|
84
|
+
function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd, currentRunId = null, snapshotOpts = {}) {
|
|
78
85
|
return {
|
|
79
86
|
session_id: `cont-${randomUUID().slice(0, 8)}`,
|
|
80
87
|
started_at: new Date().toISOString(),
|
|
@@ -90,6 +97,12 @@ function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd, cur
|
|
|
90
97
|
cumulative_spent_usd: 0,
|
|
91
98
|
budget_exhausted: false,
|
|
92
99
|
startup_reconciled_run_id: null,
|
|
100
|
+
// BUG-60 Slice 3: vision snapshot for idle-expansion traceability
|
|
101
|
+
vision_headings_snapshot: snapshotOpts.visionHeadingsSnapshot || null,
|
|
102
|
+
vision_sha_at_snapshot: snapshotOpts.visionShaAtSnapshot || null,
|
|
103
|
+
expansion_iteration: snapshotOpts.expansionIteration ?? 0,
|
|
104
|
+
// Track which vision SHA values have already emitted a stale warning
|
|
105
|
+
_vision_stale_warned_shas: [],
|
|
93
106
|
};
|
|
94
107
|
}
|
|
95
108
|
|
|
@@ -106,6 +119,12 @@ function describeContinuousTerminalStep(step, contOpts) {
|
|
|
106
119
|
if (step.status === 'idle_exit') {
|
|
107
120
|
return `All vision goals appear addressed (${contOpts.maxIdleCycles} consecutive idle cycles). Stopping.`;
|
|
108
121
|
}
|
|
122
|
+
if (step.status === 'vision_exhausted') {
|
|
123
|
+
return 'PM idle-expansion declared vision exhausted. Stopping.';
|
|
124
|
+
}
|
|
125
|
+
if (step.status === 'vision_expansion_exhausted') {
|
|
126
|
+
return `Idle-expansion cap reached (${contOpts.idleExpansion?.maxExpansions ?? '?'} expansions without productive run). Stopping.`;
|
|
127
|
+
}
|
|
109
128
|
if (step.status === 'failed') {
|
|
110
129
|
const reason = step.stop_reason || step.action || 'unknown';
|
|
111
130
|
return `Continuous loop failed: ${reason}. Check "agentxchain status" for details.`;
|
|
@@ -131,6 +150,73 @@ function isBlockedContinuousExecution(execution) {
|
|
|
131
150
|
|| stopReason === 'reject_exhausted';
|
|
132
151
|
}
|
|
133
152
|
|
|
153
|
+
function getAcceptedIdleExpansionEntries(execution) {
|
|
154
|
+
const entries = Array.isArray(execution?.result?.accepted_turn_results)
|
|
155
|
+
? execution.result.accepted_turn_results
|
|
156
|
+
: [];
|
|
157
|
+
return entries.filter((entry) => entry?.turn_result?.idle_expansion_result);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log = console.log) {
|
|
161
|
+
const entries = getAcceptedIdleExpansionEntries(execution);
|
|
162
|
+
if (entries.length === 0) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let lastIngested = null;
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const ingested = ingestAcceptedIdleExpansion(context, session, {
|
|
169
|
+
turnResult: entry.turn_result,
|
|
170
|
+
historyEntry: entry.accepted || null,
|
|
171
|
+
state: entry.state || execution?.result?.state || null,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (!ingested.ingested) {
|
|
175
|
+
session.status = 'failed';
|
|
176
|
+
writeContinuousSession(context.root, session);
|
|
177
|
+
emitRunEvent(context.root, 'idle_expansion_ingestion_failed', {
|
|
178
|
+
run_id: session.current_run_id || null,
|
|
179
|
+
phase: null,
|
|
180
|
+
status: 'failed',
|
|
181
|
+
payload: {
|
|
182
|
+
session_id: session.session_id,
|
|
183
|
+
expansion_iteration: session.expansion_iteration,
|
|
184
|
+
turn_id: entry.turn_id || null,
|
|
185
|
+
error: ingested.error || 'unknown idle-expansion ingestion failure',
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
log(`Idle-expansion ingestion failed: ${ingested.error || 'unknown error'}`);
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
status: 'failed',
|
|
192
|
+
action: 'idle_expansion_ingestion_failed',
|
|
193
|
+
stop_reason: ingested.error || 'idle_expansion_ingestion_failed',
|
|
194
|
+
run_id: session.current_run_id || null,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lastIngested = ingested;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (lastIngested?.kind === 'vision_exhausted') {
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
status: 'vision_exhausted',
|
|
205
|
+
action: 'idle_expansion_ingested',
|
|
206
|
+
stop_reason: 'vision_exhausted',
|
|
207
|
+
run_id: session.current_run_id || null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
status: 'running',
|
|
214
|
+
action: 'idle_expansion_ingested',
|
|
215
|
+
intent_id: lastIngested?.intentId || null,
|
|
216
|
+
run_id: session.current_run_id || null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
134
220
|
function getBlockedRecoveryAction(state) {
|
|
135
221
|
return state?.blocked_reason?.recovery?.recovery_action || null;
|
|
136
222
|
}
|
|
@@ -605,6 +691,325 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
605
691
|
};
|
|
606
692
|
}
|
|
607
693
|
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// BUG-60: Idle-expansion dispatch + ingestion for perpetual continuous mode
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Dispatch a PM idle-expansion turn via the intake pipeline.
|
|
700
|
+
*
|
|
701
|
+
* Called when on_idle === "perpetual" and idle_cycles >= maxIdleCycles.
|
|
702
|
+
* Records a `vision_idle_expansion` intake event with deterministic signal,
|
|
703
|
+
* triages and auto-approves the synthesized PM intent, then returns a
|
|
704
|
+
* non-terminal step so the main loop re-enters on the next cycle.
|
|
705
|
+
*
|
|
706
|
+
* Returns null if the expansion cannot be dispatched (cap reached, source
|
|
707
|
+
* manifest fails, etc.) — caller falls through to idle_exit.
|
|
708
|
+
*
|
|
709
|
+
* @returns {{ ok, status, action, ... } | null}
|
|
710
|
+
*/
|
|
711
|
+
async function dispatchIdleExpansion(context, session, contOpts, absVisionPath, log = console.log) {
|
|
712
|
+
const { root } = context;
|
|
713
|
+
const expansion = contOpts.idleExpansion;
|
|
714
|
+
if (!expansion) return null;
|
|
715
|
+
|
|
716
|
+
// Check expansion iteration cap
|
|
717
|
+
const currentIteration = (session.expansion_iteration || 0) + 1;
|
|
718
|
+
if (currentIteration > expansion.maxExpansions) {
|
|
719
|
+
session.status = 'vision_expansion_exhausted';
|
|
720
|
+
writeContinuousSession(root, session);
|
|
721
|
+
log(`Idle-expansion cap reached (${expansion.maxExpansions} expansions). Stopping.`);
|
|
722
|
+
emitRunEvent(root, 'idle_expansion_cap_reached', {
|
|
723
|
+
run_id: session.current_run_id || null,
|
|
724
|
+
phase: null,
|
|
725
|
+
status: 'completed',
|
|
726
|
+
payload: {
|
|
727
|
+
session_id: session.session_id,
|
|
728
|
+
expansion_iteration: currentIteration - 1,
|
|
729
|
+
max_expansions: expansion.maxExpansions,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
return {
|
|
733
|
+
ok: true,
|
|
734
|
+
status: 'vision_expansion_exhausted',
|
|
735
|
+
action: 'idle_expansion_cap_reached',
|
|
736
|
+
stop_reason: 'vision_expansion_exhausted',
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Build bounded source manifest
|
|
741
|
+
const manifest = buildSourceManifest(root, expansion.sources);
|
|
742
|
+
if (!manifest.ok) {
|
|
743
|
+
log(`Idle-expansion source manifest failed: ${manifest.error}`);
|
|
744
|
+
return null; // Fall through to idle_exit
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Build the PM charter for idle-expansion
|
|
748
|
+
const sourceList = manifest.entries
|
|
749
|
+
.filter(e => e.present)
|
|
750
|
+
.map(e => ` - ${e.path} (${e.headings.length} headings, ${e.byte_count} bytes${e.warning ? `, warning: ${e.warning}` : ''})`)
|
|
751
|
+
.join('\n');
|
|
752
|
+
const visionHeadings = (session.vision_headings_snapshot || []).map(h => ` - ${h}`).join('\n');
|
|
753
|
+
|
|
754
|
+
const charter = [
|
|
755
|
+
`[idle-expansion #${currentIteration}] Inspect VISION.md, ROADMAP.md, SYSTEM_SPEC.md, and current project state.`,
|
|
756
|
+
`Derive the next concrete increment as a new intake intent with charter + acceptance_contract + priority.`,
|
|
757
|
+
`If ALL vision goals are genuinely exhausted, declare vision_exhausted with per-heading classification.`,
|
|
758
|
+
``,
|
|
759
|
+
`CONSTRAINTS:`,
|
|
760
|
+
`- Do NOT modify .planning/VISION.md (human-owned, read-only).`,
|
|
761
|
+
`- ROADMAP.md and SYSTEM_SPEC.md may be updated as supporting evidence.`,
|
|
762
|
+
`- Every proposed intent MUST cite at least one VISION.md heading from the snapshot below.`,
|
|
763
|
+
`- Output MUST be a structured idle_expansion_result (new_intake_intent or vision_exhausted).`,
|
|
764
|
+
``,
|
|
765
|
+
`VISION headings snapshot:`,
|
|
766
|
+
visionHeadings || ' (none captured)',
|
|
767
|
+
``,
|
|
768
|
+
`Source manifest:`,
|
|
769
|
+
sourceList || ' (no sources available)',
|
|
770
|
+
].join('\n');
|
|
771
|
+
|
|
772
|
+
// Use a placeholder accepted_turn_id for the signal — it will be the turn assigned by intake
|
|
773
|
+
// We use session_id + iteration as a pre-dispatch key; the real signal with accepted_turn_id
|
|
774
|
+
// is built after the PM turn completes and is accepted via ingestAcceptedIdleExpansion.
|
|
775
|
+
const preDispatchSignal = buildVisionIdleExpansionSignal(
|
|
776
|
+
session.session_id,
|
|
777
|
+
currentIteration,
|
|
778
|
+
`pre_dispatch_${session.session_id}_${currentIteration}`,
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
const idleExpansionContext = {
|
|
782
|
+
expansion_iteration: currentIteration,
|
|
783
|
+
vision_headings_snapshot: session.vision_headings_snapshot || [],
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// Record through intake pipeline
|
|
787
|
+
const eventResult = recordEvent(root, {
|
|
788
|
+
source: 'vision_idle_expansion',
|
|
789
|
+
category: 'idle_expansion',
|
|
790
|
+
signal: preDispatchSignal,
|
|
791
|
+
idle_expansion_context: idleExpansionContext,
|
|
792
|
+
evidence: [
|
|
793
|
+
{ type: 'text', value: `Idle-expansion iteration ${currentIteration}/${expansion.maxExpansions} — PM deriving next increment from vision/roadmap/spec.` },
|
|
794
|
+
],
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
if (!eventResult.ok) {
|
|
798
|
+
if (eventResult.deduplicated) {
|
|
799
|
+
log(`Idle-expansion iteration ${currentIteration} already recorded (deduplicated). Skipping.`);
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
log(`Idle-expansion intake record failed: ${eventResult.error}`);
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const intentId = eventResult.intent.intent_id;
|
|
807
|
+
|
|
808
|
+
// Triage with idle-expansion charter
|
|
809
|
+
const triageResult = triageIntent(root, intentId, {
|
|
810
|
+
priority: 'p1',
|
|
811
|
+
template: 'generic',
|
|
812
|
+
charter,
|
|
813
|
+
acceptance_contract: [
|
|
814
|
+
'Produces a structured idle_expansion_result with kind "new_intake_intent" or "vision_exhausted".',
|
|
815
|
+
'If new_intake_intent: contains charter, acceptance_contract (array), priority, and vision_traceability citing snapshot headings.',
|
|
816
|
+
'If vision_exhausted: contains per-heading classification covering all snapshot headings.',
|
|
817
|
+
],
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
if (!triageResult.ok) {
|
|
821
|
+
log(`Idle-expansion triage failed: ${triageResult.error}`);
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Auto-approve (idle-expansion intents are always auto-approved in perpetual mode)
|
|
826
|
+
const approveResult = approveIntent(root, intentId, {
|
|
827
|
+
approver: 'continuous_loop_idle_expansion',
|
|
828
|
+
reason: `idle-expansion iteration ${currentIteration}`,
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
if (!approveResult.ok) {
|
|
832
|
+
log(`Idle-expansion approve failed: ${approveResult.error}`);
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Update session state
|
|
837
|
+
session.expansion_iteration = currentIteration;
|
|
838
|
+
session.idle_cycles = 0; // Reset idle cycles after dispatching expansion
|
|
839
|
+
writeContinuousSession(root, session);
|
|
840
|
+
|
|
841
|
+
emitRunEvent(root, 'idle_expansion_dispatched', {
|
|
842
|
+
run_id: session.current_run_id || null,
|
|
843
|
+
phase: null,
|
|
844
|
+
status: 'running',
|
|
845
|
+
payload: {
|
|
846
|
+
session_id: session.session_id,
|
|
847
|
+
expansion_iteration: currentIteration,
|
|
848
|
+
max_expansions: expansion.maxExpansions,
|
|
849
|
+
intent_id: intentId,
|
|
850
|
+
role: expansion.role,
|
|
851
|
+
source_count: manifest.entries.length,
|
|
852
|
+
sources_present: manifest.entries.filter(e => e.present).length,
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
log(`Idle-expansion ${currentIteration}/${expansion.maxExpansions} dispatched — PM intent ${intentId} queued.`);
|
|
857
|
+
return {
|
|
858
|
+
ok: true,
|
|
859
|
+
status: 'running',
|
|
860
|
+
action: 'idle_expansion_dispatched',
|
|
861
|
+
intent_id: intentId,
|
|
862
|
+
expansion_iteration: currentIteration,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Ingest the accepted result of a PM idle-expansion turn.
|
|
868
|
+
*
|
|
869
|
+
* Called after a PM turn with `intake_context.source === 'vision_idle_expansion'`
|
|
870
|
+
* has been accepted. Reads the `idle_expansion_result` from the accepted turn
|
|
871
|
+
* result and either:
|
|
872
|
+
* (a) records a new intake intent from `new_intake_intent` → returns { ingested: true, kind: 'new_intake_intent', intentId }
|
|
873
|
+
* (b) sets session status to `vision_exhausted` → returns { ingested: true, kind: 'vision_exhausted' }
|
|
874
|
+
* (c) returns { ingested: false, error } on malformed output
|
|
875
|
+
*
|
|
876
|
+
* @param {object} context - { root, config }
|
|
877
|
+
* @param {object} session - mutable session
|
|
878
|
+
* @param {{ turnResult: object, historyEntry: object, state: object }} accepted
|
|
879
|
+
* @returns {{ ingested: boolean, kind?: string, intentId?: string, error?: string }}
|
|
880
|
+
*/
|
|
881
|
+
export function ingestAcceptedIdleExpansion(context, session, accepted) {
|
|
882
|
+
const { root } = context;
|
|
883
|
+
const { turnResult } = accepted;
|
|
884
|
+
const result = turnResult?.idle_expansion_result;
|
|
885
|
+
|
|
886
|
+
if (!result || typeof result !== 'object') {
|
|
887
|
+
emitRunEvent(root, 'idle_expansion_malformed', {
|
|
888
|
+
run_id: session.current_run_id || null,
|
|
889
|
+
phase: null,
|
|
890
|
+
status: 'running',
|
|
891
|
+
payload: {
|
|
892
|
+
session_id: session.session_id,
|
|
893
|
+
expansion_iteration: session.expansion_iteration,
|
|
894
|
+
error: 'Missing or invalid idle_expansion_result in accepted turn result.',
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
return { ingested: false, error: 'Missing or invalid idle_expansion_result in accepted turn result.' };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (result.kind === 'new_intake_intent') {
|
|
901
|
+
const intent = result.new_intake_intent;
|
|
902
|
+
if (!intent || !intent.charter || !Array.isArray(intent.acceptance_contract) || intent.acceptance_contract.length === 0) {
|
|
903
|
+
emitRunEvent(root, 'idle_expansion_malformed', {
|
|
904
|
+
run_id: session.current_run_id || null,
|
|
905
|
+
phase: null,
|
|
906
|
+
status: 'running',
|
|
907
|
+
payload: {
|
|
908
|
+
session_id: session.session_id,
|
|
909
|
+
expansion_iteration: session.expansion_iteration,
|
|
910
|
+
error: 'new_intake_intent missing required fields (charter, acceptance_contract).',
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
return { ingested: false, error: 'new_intake_intent missing required fields (charter, acceptance_contract).' };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Record the PM-derived intent through the normal intake pipeline
|
|
917
|
+
const eventResult = recordEvent(root, {
|
|
918
|
+
source: 'vision_scan',
|
|
919
|
+
category: 'pm_idle_expansion_derived',
|
|
920
|
+
signal: {
|
|
921
|
+
description: intent.charter,
|
|
922
|
+
derived: true,
|
|
923
|
+
expansion_iteration: session.expansion_iteration,
|
|
924
|
+
vision_traceability: result.vision_traceability || null,
|
|
925
|
+
},
|
|
926
|
+
evidence: [
|
|
927
|
+
{ type: 'text', value: `PM idle-expansion #${session.expansion_iteration} derived: ${intent.charter}` },
|
|
928
|
+
],
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
if (!eventResult.ok) {
|
|
932
|
+
if (eventResult.deduplicated) {
|
|
933
|
+
return { ingested: true, kind: 'new_intake_intent', intentId: null, deduplicated: true };
|
|
934
|
+
}
|
|
935
|
+
return { ingested: false, error: `Intake record for PM-derived intent failed: ${eventResult.error}` };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const newIntentId = eventResult.intent.intent_id;
|
|
939
|
+
|
|
940
|
+
// Triage with PM-derived charter and acceptance contract
|
|
941
|
+
const triageResult = triageIntent(root, newIntentId, {
|
|
942
|
+
priority: intent.priority || 'p2',
|
|
943
|
+
template: intent.template || 'generic',
|
|
944
|
+
charter: `[pm-derived] ${intent.charter}`,
|
|
945
|
+
acceptance_contract: intent.acceptance_contract,
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
if (!triageResult.ok) {
|
|
949
|
+
return { ingested: false, error: `Triage for PM-derived intent failed: ${triageResult.error}` };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Auto-approve the PM-derived intent
|
|
953
|
+
const approveResult = approveIntent(root, newIntentId, {
|
|
954
|
+
approver: 'idle_expansion_ingestion',
|
|
955
|
+
reason: `PM idle-expansion #${session.expansion_iteration} derived intent`,
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
if (!approveResult.ok) {
|
|
959
|
+
return { ingested: false, error: `Approve for PM-derived intent failed: ${approveResult.error}` };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
emitRunEvent(root, 'idle_expansion_ingested', {
|
|
963
|
+
run_id: session.current_run_id || null,
|
|
964
|
+
phase: null,
|
|
965
|
+
status: 'running',
|
|
966
|
+
payload: {
|
|
967
|
+
session_id: session.session_id,
|
|
968
|
+
expansion_iteration: session.expansion_iteration,
|
|
969
|
+
kind: 'new_intake_intent',
|
|
970
|
+
intent_id: newIntentId,
|
|
971
|
+
charter: intent.charter,
|
|
972
|
+
priority: intent.priority || 'p2',
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
return { ingested: true, kind: 'new_intake_intent', intentId: newIntentId };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (result.kind === 'vision_exhausted') {
|
|
980
|
+
session.status = 'vision_exhausted';
|
|
981
|
+
writeContinuousSession(root, session);
|
|
982
|
+
|
|
983
|
+
emitRunEvent(root, 'idle_expansion_ingested', {
|
|
984
|
+
run_id: session.current_run_id || null,
|
|
985
|
+
phase: null,
|
|
986
|
+
status: 'completed',
|
|
987
|
+
payload: {
|
|
988
|
+
session_id: session.session_id,
|
|
989
|
+
expansion_iteration: session.expansion_iteration,
|
|
990
|
+
kind: 'vision_exhausted',
|
|
991
|
+
reason_excerpt: result.vision_exhausted?.classification?.[0]?.reason || null,
|
|
992
|
+
classification: result.vision_exhausted?.classification || null,
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
return { ingested: true, kind: 'vision_exhausted' };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Unknown kind
|
|
1000
|
+
emitRunEvent(root, 'idle_expansion_malformed', {
|
|
1001
|
+
run_id: session.current_run_id || null,
|
|
1002
|
+
phase: null,
|
|
1003
|
+
status: 'running',
|
|
1004
|
+
payload: {
|
|
1005
|
+
session_id: session.session_id,
|
|
1006
|
+
expansion_iteration: session.expansion_iteration,
|
|
1007
|
+
error: `Unknown idle_expansion_result.kind: "${result.kind}". Expected "new_intake_intent" or "vision_exhausted".`,
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1010
|
+
return { ingested: false, error: `Unknown idle_expansion_result.kind: "${result.kind}".` };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
608
1013
|
// ---------------------------------------------------------------------------
|
|
609
1014
|
// Resolve continuous options from CLI flags + config
|
|
610
1015
|
// ---------------------------------------------------------------------------
|
|
@@ -631,6 +1036,25 @@ export function resolveContinuousOptions(opts, config) {
|
|
|
631
1036
|
?? configuredReconcile
|
|
632
1037
|
?? (fullAuto ? 'auto_safe_only' : 'manual');
|
|
633
1038
|
|
|
1039
|
+
// Resolve on_idle policy — CLI flag overrides config
|
|
1040
|
+
const validOnIdle = new Set(['exit', 'perpetual', 'human_review']);
|
|
1041
|
+
const configOnIdle = typeof configCont.on_idle === 'string' && validOnIdle.has(configCont.on_idle)
|
|
1042
|
+
? configCont.on_idle : null;
|
|
1043
|
+
const cliOnIdle = typeof opts.onIdle === 'string' && validOnIdle.has(opts.onIdle)
|
|
1044
|
+
? opts.onIdle : null;
|
|
1045
|
+
const onIdle = cliOnIdle ?? configOnIdle ?? 'exit';
|
|
1046
|
+
|
|
1047
|
+
// Resolve idle_expansion block when perpetual mode is active
|
|
1048
|
+
const configIdleExpansion = configCont.idle_expansion || {};
|
|
1049
|
+
const idleExpansion = onIdle === 'perpetual' ? {
|
|
1050
|
+
sources: Array.isArray(configIdleExpansion.sources) && configIdleExpansion.sources.length > 0
|
|
1051
|
+
? configIdleExpansion.sources
|
|
1052
|
+
: ['.planning/VISION.md', '.planning/ROADMAP.md', '.planning/SYSTEM_SPEC.md'],
|
|
1053
|
+
maxExpansions: configIdleExpansion.max_expansions ?? 5,
|
|
1054
|
+
role: configIdleExpansion.role ?? 'pm',
|
|
1055
|
+
malformedRetryLimit: configIdleExpansion.malformed_retry_limit ?? 1,
|
|
1056
|
+
} : null;
|
|
1057
|
+
|
|
634
1058
|
return {
|
|
635
1059
|
enabled: opts.continuous ?? configCont.enabled ?? false,
|
|
636
1060
|
continueFrom: opts.continueFrom ?? null,
|
|
@@ -652,6 +1076,8 @@ export function resolveContinuousOptions(opts, config) {
|
|
|
652
1076
|
?? 5,
|
|
653
1077
|
},
|
|
654
1078
|
reconcileOperatorCommits,
|
|
1079
|
+
onIdle,
|
|
1080
|
+
idleExpansion,
|
|
655
1081
|
};
|
|
656
1082
|
}
|
|
657
1083
|
|
|
@@ -684,20 +1110,42 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
684
1110
|
const { root } = context;
|
|
685
1111
|
const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
|
|
686
1112
|
|
|
687
|
-
//
|
|
688
|
-
if (session.
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1113
|
+
// BUG-60 Slice 3: detect VISION.md content drift since session snapshot
|
|
1114
|
+
if (session.vision_sha_at_snapshot && existsSync(absVisionPath)) {
|
|
1115
|
+
try {
|
|
1116
|
+
const currentContent = readFileSync(absVisionPath, 'utf8');
|
|
1117
|
+
const currentSha = computeVisionContentSha(currentContent);
|
|
1118
|
+
const warnedShas = session._vision_stale_warned_shas || [];
|
|
1119
|
+
if (currentSha !== session.vision_sha_at_snapshot && !warnedShas.includes(currentSha)) {
|
|
1120
|
+
emitRunEvent(root, 'vision_snapshot_stale', {
|
|
1121
|
+
run_id: session.current_run_id || null,
|
|
1122
|
+
phase: null,
|
|
1123
|
+
status: session.status || 'running',
|
|
1124
|
+
payload: {
|
|
1125
|
+
session_id: session.session_id,
|
|
1126
|
+
snapshot_sha: session.vision_sha_at_snapshot,
|
|
1127
|
+
current_sha: currentSha,
|
|
1128
|
+
vision_path: contOpts.visionPath,
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
session._vision_stale_warned_shas = [...warnedShas, currentSha];
|
|
1132
|
+
writeContinuousSession(root, session);
|
|
1133
|
+
log(`Warning: VISION.md has changed since session started (snapshot: ${session.vision_sha_at_snapshot.slice(0, 8)}, current: ${currentSha.slice(0, 8)}). Active session keeps its heading snapshot.`);
|
|
1134
|
+
}
|
|
1135
|
+
} catch {
|
|
1136
|
+
// VISION.md read failed — will be caught by the vision_missing guard below
|
|
1137
|
+
}
|
|
692
1138
|
}
|
|
693
1139
|
|
|
694
|
-
|
|
1140
|
+
// Terminal checks — order matters: max_runs, then budget, then idle policy.
|
|
1141
|
+
// Budget MUST fire before idle-expansion dispatch (BUG-60 Plan §5).
|
|
1142
|
+
if (session.runs_completed >= contOpts.maxRuns) {
|
|
695
1143
|
session.status = 'completed';
|
|
696
1144
|
writeContinuousSession(root, session);
|
|
697
|
-
return { ok: true, status: '
|
|
1145
|
+
return { ok: true, status: 'completed', action: 'max_runs_reached', stop_reason: 'max_runs' };
|
|
698
1146
|
}
|
|
699
1147
|
|
|
700
|
-
// Session budget check (cumulative spend across all runs)
|
|
1148
|
+
// Session budget check (cumulative spend across all runs) — before idle policy
|
|
701
1149
|
const sessionBudget = session.per_session_max_usd ?? contOpts.perSessionMaxUsd ?? null;
|
|
702
1150
|
if (sessionBudget != null && (session.cumulative_spent_usd || 0) >= sessionBudget) {
|
|
703
1151
|
session.status = 'completed';
|
|
@@ -707,6 +1155,44 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
707
1155
|
return { ok: true, status: 'completed', action: 'session_budget_exhausted', stop_reason: 'session_budget' };
|
|
708
1156
|
}
|
|
709
1157
|
|
|
1158
|
+
// Idle-cycle check: on_idle policy determines behavior
|
|
1159
|
+
if (session.idle_cycles >= contOpts.maxIdleCycles) {
|
|
1160
|
+
if (contOpts.onIdle === 'perpetual' && contOpts.idleExpansion) {
|
|
1161
|
+
// BUG-60: perpetual mode — dispatch PM idle-expansion instead of exiting
|
|
1162
|
+
const expansionResult = await dispatchIdleExpansion(context, session, contOpts, absVisionPath, log);
|
|
1163
|
+
if (expansionResult) return expansionResult;
|
|
1164
|
+
// If dispatchIdleExpansion returned null, fall through to idle_exit
|
|
1165
|
+
}
|
|
1166
|
+
if (contOpts.onIdle === 'human_review') {
|
|
1167
|
+
session.status = 'paused';
|
|
1168
|
+
writeContinuousSession(root, session);
|
|
1169
|
+
emitRunEvent(root, 'idle_human_review_required', {
|
|
1170
|
+
run_id: session.current_run_id || null,
|
|
1171
|
+
phase: null,
|
|
1172
|
+
status: 'blocked',
|
|
1173
|
+
payload: {
|
|
1174
|
+
session_id: session.session_id,
|
|
1175
|
+
idle_cycles: session.idle_cycles,
|
|
1176
|
+
max_idle_cycles: contOpts.maxIdleCycles,
|
|
1177
|
+
vision_path: contOpts.visionPath,
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
log(`Idle threshold reached (${session.idle_cycles}/${contOpts.maxIdleCycles}) — pausing for human review.`);
|
|
1181
|
+
return {
|
|
1182
|
+
ok: true,
|
|
1183
|
+
status: 'blocked',
|
|
1184
|
+
action: 'idle_human_review_required',
|
|
1185
|
+
stop_reason: 'human_review',
|
|
1186
|
+
run_id: session.current_run_id || null,
|
|
1187
|
+
recovery_action: 'Review .agentxchain/continuous-session.json and either inject/approve new work or rerun with --on-idle exit/perpetual.',
|
|
1188
|
+
blocked_category: 'idle_human_review',
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
session.status = 'completed';
|
|
1192
|
+
writeContinuousSession(root, session);
|
|
1193
|
+
return { ok: true, status: 'idle_exit', action: 'max_idle_reached', stop_reason: 'idle_exit' };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
710
1196
|
reconcileContinuousStartupState(context, session, contOpts, log);
|
|
711
1197
|
|
|
712
1198
|
const reconcileBlock = maybeAutoReconcileOperatorCommits(context, session, contOpts, log);
|
|
@@ -782,6 +1268,12 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
782
1268
|
return { ok: false, status: 'failed', action: 'run_failed', stop_reason: resumeStopReason || `exit_code_${execution.exitCode}`, run_id: session.current_run_id };
|
|
783
1269
|
}
|
|
784
1270
|
|
|
1271
|
+
const idleExpansionStep = ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log);
|
|
1272
|
+
if (idleExpansionStep) {
|
|
1273
|
+
writeContinuousSession(root, session);
|
|
1274
|
+
return idleExpansionStep;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
785
1277
|
session.runs_completed += 1;
|
|
786
1278
|
session.current_run_id = execution.result?.state?.run_id || session.current_run_id;
|
|
787
1279
|
log(`Resumed run completed (${session.runs_completed}/${contOpts.maxRuns}): ${resumeStopReason || 'completed'}`);
|
|
@@ -844,6 +1336,12 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
844
1336
|
return { ok: false, status: 'failed', action: 'run_failed', stop_reason: resumeStopReason || `exit_code_${execution.exitCode}`, run_id: session.current_run_id };
|
|
845
1337
|
}
|
|
846
1338
|
|
|
1339
|
+
const idleExpansionStep = ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log);
|
|
1340
|
+
if (idleExpansionStep) {
|
|
1341
|
+
writeContinuousSession(root, session);
|
|
1342
|
+
return idleExpansionStep;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
847
1345
|
session.runs_completed += 1;
|
|
848
1346
|
session.current_run_id = execution.result?.state?.run_id || session.current_run_id;
|
|
849
1347
|
log(`Active run completed (${session.runs_completed}/${contOpts.maxRuns}): ${resumeStopReason || 'completed'}`);
|
|
@@ -1044,9 +1542,6 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
1044
1542
|
};
|
|
1045
1543
|
}
|
|
1046
1544
|
|
|
1047
|
-
session.runs_completed += 1;
|
|
1048
|
-
log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
|
|
1049
|
-
|
|
1050
1545
|
// Resolve the consumed intent
|
|
1051
1546
|
const resolved = resolveIntent(root, targetIntentId);
|
|
1052
1547
|
if (!resolved.ok) {
|
|
@@ -1056,6 +1551,18 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
1056
1551
|
return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
|
|
1057
1552
|
}
|
|
1058
1553
|
|
|
1554
|
+
const idleExpansionStep = ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log);
|
|
1555
|
+
if (idleExpansionStep) {
|
|
1556
|
+
writeContinuousSession(root, session);
|
|
1557
|
+
return {
|
|
1558
|
+
...idleExpansionStep,
|
|
1559
|
+
intent_id: idleExpansionStep.intent_id || targetIntentId,
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
session.runs_completed += 1;
|
|
1564
|
+
log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
|
|
1565
|
+
|
|
1059
1566
|
writeContinuousSession(root, session);
|
|
1060
1567
|
return {
|
|
1061
1568
|
ok: true,
|
|
@@ -1092,12 +1599,25 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
1092
1599
|
|
|
1093
1600
|
const startupState = loadProjectState(root, context.config);
|
|
1094
1601
|
const initialRunId = contOpts.continueFrom || startupState?.run_id || null;
|
|
1602
|
+
|
|
1603
|
+
// BUG-60 Slice 3: capture vision heading snapshot + content hash at session start
|
|
1604
|
+
let visionHeadingsSnapshot = null;
|
|
1605
|
+
let visionShaAtSnapshot = null;
|
|
1606
|
+
try {
|
|
1607
|
+
const visionContent = readFileSync(absVisionPath, 'utf8');
|
|
1608
|
+
visionHeadingsSnapshot = captureVisionHeadingsSnapshot(visionContent);
|
|
1609
|
+
visionShaAtSnapshot = computeVisionContentSha(visionContent);
|
|
1610
|
+
} catch {
|
|
1611
|
+
// VISION.md unreadable — will fail at first advanceContinuousRunOnce anyway
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1095
1614
|
const session = createSession(
|
|
1096
1615
|
contOpts.visionPath,
|
|
1097
1616
|
contOpts.maxRuns,
|
|
1098
1617
|
contOpts.maxIdleCycles,
|
|
1099
1618
|
contOpts.perSessionMaxUsd,
|
|
1100
1619
|
initialRunId,
|
|
1620
|
+
{ visionHeadingsSnapshot, visionShaAtSnapshot },
|
|
1101
1621
|
);
|
|
1102
1622
|
writeContinuousSession(root, session);
|
|
1103
1623
|
|
|
@@ -1114,7 +1634,7 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
1114
1634
|
const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
|
|
1115
1635
|
|
|
1116
1636
|
// Terminal states
|
|
1117
|
-
if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped') {
|
|
1637
|
+
if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted') {
|
|
1118
1638
|
const terminalMessage = describeContinuousTerminalStep(step, contOpts);
|
|
1119
1639
|
if (terminalMessage) {
|
|
1120
1640
|
log(terminalMessage);
|