agentxchain 2.135.1 → 2.136.1
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 +19 -1
- package/package.json +4 -2
- package/scripts/release-preflight.sh +2 -0
- package/src/commands/connector.js +113 -0
- package/src/commands/restart.js +3 -0
- package/src/commands/resume.js +15 -0
- package/src/commands/step.js +12 -0
- package/src/commands/workflow-kit.js +186 -0
- package/src/lib/connector-schema-contract.js +57 -0
- package/src/lib/connector-validate.js +41 -0
- package/src/lib/continuous-run.js +60 -5
- package/src/lib/dispatch-bundle.js +14 -0
- package/src/lib/governed-state.js +42 -46
- package/src/lib/intake.js +17 -57
- package/src/lib/intent-startup-migration.js +151 -0
- package/src/lib/runtime-capabilities.js +132 -81
- package/src/lib/schemas/agentxchain-config.schema.json +391 -0
- package/src/lib/schemas/connector-capabilities-output.schema.json +104 -0
|
@@ -24,6 +24,11 @@ import {
|
|
|
24
24
|
} from './intake.js';
|
|
25
25
|
import { loadProjectState } from './config.js';
|
|
26
26
|
import { safeWriteJson } from './safe-write.js';
|
|
27
|
+
import { emitRunEvent } from './run-events.js';
|
|
28
|
+
import {
|
|
29
|
+
archiveStaleIntentsForRun,
|
|
30
|
+
formatLegacyIntentMigrationNotice,
|
|
31
|
+
} from './intent-startup-migration.js';
|
|
27
32
|
|
|
28
33
|
const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
|
|
29
34
|
|
|
@@ -56,7 +61,7 @@ export function removeContinuousSession(root) {
|
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd) {
|
|
64
|
+
function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd, currentRunId = null) {
|
|
60
65
|
return {
|
|
61
66
|
session_id: `cont-${randomUUID().slice(0, 8)}`,
|
|
62
67
|
started_at: new Date().toISOString(),
|
|
@@ -65,12 +70,13 @@ function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd) {
|
|
|
65
70
|
max_runs: maxRuns,
|
|
66
71
|
idle_cycles: 0,
|
|
67
72
|
max_idle_cycles: maxIdleCycles,
|
|
68
|
-
current_run_id:
|
|
73
|
+
current_run_id: currentRunId,
|
|
69
74
|
current_vision_objective: null,
|
|
70
75
|
status: 'running',
|
|
71
76
|
per_session_max_usd: perSessionMaxUsd || null,
|
|
72
77
|
cumulative_spent_usd: 0,
|
|
73
78
|
budget_exhausted: false,
|
|
79
|
+
startup_reconciled_run_id: null,
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -133,8 +139,46 @@ function buildContinuousProvenance(intentId, options = {}) {
|
|
|
133
139
|
};
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
export function findNextQueuedIntent(root) {
|
|
137
|
-
return findNextDispatchableIntent(root);
|
|
142
|
+
export function findNextQueuedIntent(root, options = {}) {
|
|
143
|
+
return findNextDispatchableIntent(root, { run_id: options.run_id || null });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function reconcileContinuousStartupState(context, session, contOpts, log) {
|
|
147
|
+
const { root, config } = context;
|
|
148
|
+
const governedState = loadProjectState(root, config);
|
|
149
|
+
const scopedRunId = session.current_run_id || contOpts.continueFrom || governedState?.run_id || null;
|
|
150
|
+
|
|
151
|
+
let sessionChanged = false;
|
|
152
|
+
if (scopedRunId && session.current_run_id !== scopedRunId) {
|
|
153
|
+
session.current_run_id = scopedRunId;
|
|
154
|
+
sessionChanged = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (scopedRunId && session.startup_reconciled_run_id !== scopedRunId) {
|
|
158
|
+
const startupIntents = archiveStaleIntentsForRun(root, scopedRunId, {
|
|
159
|
+
protocolVersion: governedState?.protocol_version || config?.schema_version || '2.x',
|
|
160
|
+
});
|
|
161
|
+
if (startupIntents.archived_migration_intent_ids?.length > 0) {
|
|
162
|
+
emitRunEvent(root, 'intents_migrated', {
|
|
163
|
+
run_id: scopedRunId,
|
|
164
|
+
phase: governedState?.phase || null,
|
|
165
|
+
status: governedState?.status || 'active',
|
|
166
|
+
payload: {
|
|
167
|
+
archived_count: startupIntents.archived_migration_intent_ids.length,
|
|
168
|
+
archived_intent_ids: startupIntents.archived_migration_intent_ids,
|
|
169
|
+
reason: 'pre-BUG-34 intents with approved_run_id: null archived during continuous startup',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const migrationNotice = formatLegacyIntentMigrationNotice(startupIntents.archived_migration_intent_ids);
|
|
173
|
+
if (migrationNotice) log(migrationNotice);
|
|
174
|
+
}
|
|
175
|
+
session.startup_reconciled_run_id = scopedRunId;
|
|
176
|
+
sessionChanged = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (sessionChanged) {
|
|
180
|
+
writeContinuousSession(root, session);
|
|
181
|
+
}
|
|
138
182
|
}
|
|
139
183
|
|
|
140
184
|
// ---------------------------------------------------------------------------
|
|
@@ -232,6 +276,7 @@ export function resolveContinuousOptions(opts, config) {
|
|
|
232
276
|
|
|
233
277
|
return {
|
|
234
278
|
enabled: opts.continuous ?? configCont.enabled ?? false,
|
|
279
|
+
continueFrom: opts.continueFrom ?? null,
|
|
235
280
|
visionPath: opts.vision ?? configCont.vision_path ?? '.planning/VISION.md',
|
|
236
281
|
maxRuns: opts.maxRuns ?? configCont.max_runs ?? 100,
|
|
237
282
|
pollSeconds: opts.pollSeconds ?? configCont.poll_seconds ?? 30,
|
|
@@ -288,6 +333,8 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
288
333
|
return { ok: true, status: 'completed', action: 'session_budget_exhausted', stop_reason: 'session_budget' };
|
|
289
334
|
}
|
|
290
335
|
|
|
336
|
+
reconcileContinuousStartupState(context, session, contOpts, log);
|
|
337
|
+
|
|
291
338
|
// Paused-session guard: if session is paused (blocked run awaiting unblock),
|
|
292
339
|
// check governed state before attempting to advance. Without this guard, the
|
|
293
340
|
// loop would try to startIntent() on a blocked project, hit the blocked-state
|
|
@@ -540,7 +587,15 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
540
587
|
return { exitCode: 1, session: null };
|
|
541
588
|
}
|
|
542
589
|
|
|
543
|
-
const
|
|
590
|
+
const startupState = loadProjectState(root, context.config);
|
|
591
|
+
const initialRunId = contOpts.continueFrom || startupState?.run_id || null;
|
|
592
|
+
const session = createSession(
|
|
593
|
+
contOpts.visionPath,
|
|
594
|
+
contOpts.maxRuns,
|
|
595
|
+
contOpts.maxIdleCycles,
|
|
596
|
+
contOpts.perSessionMaxUsd,
|
|
597
|
+
initialRunId,
|
|
598
|
+
);
|
|
544
599
|
writeContinuousSession(root, session);
|
|
545
600
|
|
|
546
601
|
// SIGINT handler
|
|
@@ -389,6 +389,20 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
389
389
|
}
|
|
390
390
|
lines.push('');
|
|
391
391
|
}
|
|
392
|
+
if (turn.conflict_context.forward_revision_files?.length) {
|
|
393
|
+
lines.push('Forward-revision files already safe to carry forward:');
|
|
394
|
+
for (const file of turn.conflict_context.forward_revision_files) {
|
|
395
|
+
lines.push(`- \`${file}\``);
|
|
396
|
+
}
|
|
397
|
+
if (turn.conflict_context.forward_revision_turns_since?.length) {
|
|
398
|
+
lines.push('');
|
|
399
|
+
lines.push('Forward-revision turns since assignment:');
|
|
400
|
+
for (const acceptedTurn of turn.conflict_context.forward_revision_turns_since) {
|
|
401
|
+
lines.push(`- \`${acceptedTurn.turn_id}\` (${acceptedTurn.role}) touched: ${acceptedTurn.files_changed.join(', ') || '(none)'}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
lines.push('');
|
|
405
|
+
}
|
|
392
406
|
if (turn.conflict_context.non_conflicting_files_preserved?.length) {
|
|
393
407
|
lines.push('Non-conflicting files to preserve from your prior attempt:');
|
|
394
408
|
for (const file of turn.conflict_context.non_conflicting_files_preserved) {
|
|
@@ -46,6 +46,10 @@ import { emitRunEvent } from './run-events.js';
|
|
|
46
46
|
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
47
47
|
import { recordRunHistory } from './run-history.js';
|
|
48
48
|
import { buildDefaultRunProvenance } from './run-provenance.js';
|
|
49
|
+
import {
|
|
50
|
+
archiveStaleIntentsForRun,
|
|
51
|
+
formatLegacyIntentMigrationNotice,
|
|
52
|
+
} from './intent-startup-migration.js';
|
|
49
53
|
import {
|
|
50
54
|
ensureHumanEscalation,
|
|
51
55
|
findCurrentHumanEscalation,
|
|
@@ -1243,6 +1247,13 @@ function buildConflictContext(turn) {
|
|
|
1243
1247
|
files_changed: Array.isArray(entry.files_changed) ? entry.files_changed : [],
|
|
1244
1248
|
}))
|
|
1245
1249
|
: [];
|
|
1250
|
+
const forwardRevisionTurnsSince = Array.isArray(conflictError.forward_revision_turns)
|
|
1251
|
+
? conflictError.forward_revision_turns.map((entry) => ({
|
|
1252
|
+
turn_id: entry.turn_id,
|
|
1253
|
+
role: entry.role,
|
|
1254
|
+
files_changed: Array.isArray(entry.files_changed) ? entry.files_changed : [],
|
|
1255
|
+
}))
|
|
1256
|
+
: [];
|
|
1246
1257
|
|
|
1247
1258
|
return {
|
|
1248
1259
|
prior_attempt_turn_id: turn.turn_id,
|
|
@@ -1250,6 +1261,8 @@ function buildConflictContext(turn) {
|
|
|
1250
1261
|
conflict_type: conflictError.type || 'file_conflict',
|
|
1251
1262
|
conflicting_files: Array.isArray(conflictError.conflicting_files) ? conflictError.conflicting_files : [],
|
|
1252
1263
|
accepted_turns_since: acceptedTurnsSince,
|
|
1264
|
+
forward_revision_files: Array.isArray(conflictError.forward_revision_files) ? conflictError.forward_revision_files : [],
|
|
1265
|
+
forward_revision_turns_since: forwardRevisionTurnsSince,
|
|
1253
1266
|
non_conflicting_files_preserved: Array.isArray(conflictError.non_conflicting_files)
|
|
1254
1267
|
? conflictError.non_conflicting_files
|
|
1255
1268
|
: [],
|
|
@@ -2067,6 +2080,25 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2067
2080
|
|
|
2068
2081
|
writeState(root, nextState);
|
|
2069
2082
|
|
|
2083
|
+
const startupIntents = nextState.run_id
|
|
2084
|
+
? archiveStaleIntentsForRun(root, nextState.run_id, {
|
|
2085
|
+
protocolVersion: nextState.protocol_version || state.protocol_version || '2.x',
|
|
2086
|
+
})
|
|
2087
|
+
: { archived_migration_intent_ids: [], migration_notice: null };
|
|
2088
|
+
|
|
2089
|
+
if (startupIntents.archived_migration_intent_ids?.length > 0) {
|
|
2090
|
+
emitRunEvent(root, 'intents_migrated', {
|
|
2091
|
+
run_id: nextState.run_id,
|
|
2092
|
+
phase: nextState.phase,
|
|
2093
|
+
status: nextState.status,
|
|
2094
|
+
payload: {
|
|
2095
|
+
archived_count: startupIntents.archived_migration_intent_ids.length,
|
|
2096
|
+
archived_intent_ids: startupIntents.archived_migration_intent_ids,
|
|
2097
|
+
reason: 'pre-BUG-34 intents with approved_run_id: null archived during run reactivation',
|
|
2098
|
+
},
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2070
2102
|
if (humanEscalation) {
|
|
2071
2103
|
resolveHumanEscalation(root, humanEscalation.escalation_id, {
|
|
2072
2104
|
resolved_at: now,
|
|
@@ -2108,7 +2140,11 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2108
2140
|
});
|
|
2109
2141
|
}
|
|
2110
2142
|
|
|
2111
|
-
return {
|
|
2143
|
+
return {
|
|
2144
|
+
ok: true,
|
|
2145
|
+
state: attachLegacyCurrentTurnAlias(nextState),
|
|
2146
|
+
migration_notice: formatLegacyIntentMigrationNotice(startupIntents.archived_migration_intent_ids),
|
|
2147
|
+
};
|
|
2112
2148
|
}
|
|
2113
2149
|
|
|
2114
2150
|
// ── Core Operations ──────────────────────────────────────────────────────────
|
|
@@ -2162,48 +2198,10 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2162
2198
|
|
|
2163
2199
|
writeState(root, updatedState);
|
|
2164
2200
|
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
// run. Silently adopting them caused continuous mode to pick up stale intents.
|
|
2170
|
-
const archivedMigrationIntentIds = [];
|
|
2171
|
-
try {
|
|
2172
|
-
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
2173
|
-
if (existsSync(intentsDir)) {
|
|
2174
|
-
const DISPATCHABLE = new Set(['planned', 'approved']);
|
|
2175
|
-
const intNow = new Date().toISOString();
|
|
2176
|
-
for (const f of readdirSync(intentsDir).filter(x => x.endsWith('.json') && !x.startsWith('.tmp-'))) {
|
|
2177
|
-
const ip = join(intentsDir, f);
|
|
2178
|
-
try {
|
|
2179
|
-
const intent = JSON.parse(readFileSync(ip, 'utf8'));
|
|
2180
|
-
if (!intent || !DISPATCHABLE.has(intent.status)) continue;
|
|
2181
|
-
if (intent.cross_run_durable === true) continue;
|
|
2182
|
-
if (intent.approved_run_id === runId) continue;
|
|
2183
|
-
|
|
2184
|
-
const prevStatus = intent.status;
|
|
2185
|
-
if (intent.approved_run_id && intent.approved_run_id !== runId) {
|
|
2186
|
-
// Intent from a different run — archive it (BUG-34)
|
|
2187
|
-
intent.status = 'suppressed';
|
|
2188
|
-
intent.updated_at = intNow;
|
|
2189
|
-
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
|
|
2190
|
-
if (!intent.history) intent.history = [];
|
|
2191
|
-
intent.history.push({ from: prevStatus, to: 'suppressed', at: intNow, reason: intent.archived_reason });
|
|
2192
|
-
} else if (!intent.approved_run_id) {
|
|
2193
|
-
// BUG-39: pre-BUG-34 legacy intent with no run binding — archive it
|
|
2194
|
-
// with explicit migration reason. Do NOT adopt into current run.
|
|
2195
|
-
intent.status = 'archived_migration';
|
|
2196
|
-
intent.updated_at = intNow;
|
|
2197
|
-
intent.archived_reason = `pre-BUG-34 intent with no run scope; archived during v${updatedState.protocol_version || '2.x'} migration on run ${runId}`;
|
|
2198
|
-
if (!intent.history) intent.history = [];
|
|
2199
|
-
intent.history.push({ from: prevStatus, to: 'archived_migration', at: intNow, reason: intent.archived_reason });
|
|
2200
|
-
if (intent.intent_id) archivedMigrationIntentIds.push(intent.intent_id);
|
|
2201
|
-
}
|
|
2202
|
-
safeWriteJson(ip, intent);
|
|
2203
|
-
} catch { /* non-fatal per-intent */ }
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
} catch { /* non-fatal — intent migration is best-effort */ }
|
|
2201
|
+
const startupIntents = archiveStaleIntentsForRun(root, runId, {
|
|
2202
|
+
protocolVersion: updatedState.protocol_version || '2.x',
|
|
2203
|
+
});
|
|
2204
|
+
const archivedMigrationIntentIds = startupIntents.archived_migration_intent_ids || [];
|
|
2207
2205
|
|
|
2208
2206
|
// BUG-39: emit intents_migrated event when pre-BUG-34 intents were archived
|
|
2209
2207
|
if (archivedMigrationIntentIds.length > 0) {
|
|
@@ -2226,9 +2224,7 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2226
2224
|
payload: { provenance: provenance || {} },
|
|
2227
2225
|
});
|
|
2228
2226
|
// BUG-39: return migration notice so callers can display it
|
|
2229
|
-
const migrationNotice =
|
|
2230
|
-
? `Archived ${archivedMigrationIntentIds.length} pre-BUG-34 intent(s). Review: agentxchain intake status --archived.`
|
|
2231
|
-
: null;
|
|
2227
|
+
const migrationNotice = startupIntents.migration_notice;
|
|
2232
2228
|
|
|
2233
2229
|
return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), migration_notice: migrationNotice };
|
|
2234
2230
|
}
|
package/src/lib/intake.js
CHANGED
|
@@ -17,6 +17,11 @@ import { getDispatchTurnDir } from './turn-paths.js';
|
|
|
17
17
|
import { loadCoordinatorConfig } from './coordinator-config.js';
|
|
18
18
|
import { loadCoordinatorState, readBarriers } from './coordinator-state.js';
|
|
19
19
|
import { writeCoordinatorHandoff } from './intake-handoff.js';
|
|
20
|
+
import {
|
|
21
|
+
archiveStaleIntentsForRun,
|
|
22
|
+
migratePreBug34Intents,
|
|
23
|
+
formatLegacyIntentMigrationNotice,
|
|
24
|
+
} from './intent-startup-migration.js';
|
|
20
25
|
|
|
21
26
|
const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
|
|
22
27
|
const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
|
|
@@ -516,16 +521,14 @@ export function findNextDispatchableIntent(root, options = {}) {
|
|
|
516
521
|
.filter((intent) => intent && DISPATCHABLE_STATUSES.has(intent.status));
|
|
517
522
|
|
|
518
523
|
// BUG-34: when run_id scoping is active, filter out intents that belong to
|
|
519
|
-
// a different run.
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
// Legacy intents (no approved_run_id, no cross_run_durable) are excluded
|
|
524
|
-
// because they are stale leftovers from prior runs.
|
|
524
|
+
// a different run. `cross_run_durable` is only a pre-run holding state for
|
|
525
|
+
// freshly approved idle intents. Once a run starts, those intents must be
|
|
526
|
+
// rebound onto that run and the flag cleared; it must never override an
|
|
527
|
+
// existing run binding.
|
|
525
528
|
if (scopeRunId) {
|
|
526
529
|
intents = intents.filter((intent) => {
|
|
527
530
|
if (intent.approved_run_id === scopeRunId) return true;
|
|
528
|
-
if (intent.cross_run_durable === true) return true;
|
|
531
|
+
if (!intent.approved_run_id && intent.cross_run_durable === true) return true;
|
|
529
532
|
// Legacy intent with no run binding — stale, skip it
|
|
530
533
|
if (!intent.approved_run_id) return false;
|
|
531
534
|
// Intent bound to a different run — stale, skip it
|
|
@@ -579,10 +582,11 @@ export function findPendingApprovedIntents(root, options = {}) {
|
|
|
579
582
|
return readJsonDir(dirs.intents)
|
|
580
583
|
.filter((intent) => {
|
|
581
584
|
if (!intent || intent.status !== 'approved') return false;
|
|
582
|
-
// BUG-34: run_id scoping — same logic as findNextDispatchableIntent
|
|
585
|
+
// BUG-34: run_id scoping — same logic as findNextDispatchableIntent.
|
|
586
|
+
// `cross_run_durable` only applies before the first run binding exists.
|
|
583
587
|
if (scopeRunId) {
|
|
584
588
|
if (intent.approved_run_id === scopeRunId) return true;
|
|
585
|
-
if (intent.cross_run_durable === true) return true;
|
|
589
|
+
if (!intent.approved_run_id && intent.cross_run_durable === true) return true;
|
|
586
590
|
return false;
|
|
587
591
|
}
|
|
588
592
|
return true;
|
|
@@ -616,51 +620,7 @@ export function findPendingApprovedIntents(root, options = {}) {
|
|
|
616
620
|
* @returns {{ archived: number }}
|
|
617
621
|
*/
|
|
618
622
|
export function archiveStaleIntents(root, newRunId) {
|
|
619
|
-
|
|
620
|
-
if (!existsSync(dirs.intents)) return { archived: 0, adopted: 0 };
|
|
621
|
-
|
|
622
|
-
const now = nowISO();
|
|
623
|
-
let archived = 0;
|
|
624
|
-
let adopted = 0;
|
|
625
|
-
|
|
626
|
-
const files = readdirSync(dirs.intents).filter(f => f.endsWith('.json') && !f.startsWith('.tmp-'));
|
|
627
|
-
for (const file of files) {
|
|
628
|
-
const intentPath = join(dirs.intents, file);
|
|
629
|
-
let intent;
|
|
630
|
-
try {
|
|
631
|
-
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
632
|
-
} catch {
|
|
633
|
-
continue;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (!intent || !DISPATCHABLE_STATUSES.has(intent.status)) continue;
|
|
637
|
-
if (intent.cross_run_durable === true) continue;
|
|
638
|
-
if (intent.approved_run_id === newRunId) continue;
|
|
639
|
-
|
|
640
|
-
if (intent.approved_run_id && intent.approved_run_id !== newRunId) {
|
|
641
|
-
// Intent from a different run — archive it
|
|
642
|
-
intent.status = 'suppressed';
|
|
643
|
-
intent.updated_at = now;
|
|
644
|
-
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${newRunId} initialization`;
|
|
645
|
-
if (!intent.history) intent.history = [];
|
|
646
|
-
intent.history.push({ from: 'approved', to: 'suppressed', at: now, reason: intent.archived_reason });
|
|
647
|
-
safeWriteJson(intentPath, intent);
|
|
648
|
-
archived++;
|
|
649
|
-
} else if (!intent.approved_run_id) {
|
|
650
|
-
// BUG-39: pre-BUG-34 legacy intent with no run binding — archive it
|
|
651
|
-
// with explicit migration reason. Do NOT adopt into current run.
|
|
652
|
-
const prevStatus = intent.status;
|
|
653
|
-
intent.status = 'archived_migration';
|
|
654
|
-
intent.updated_at = now;
|
|
655
|
-
intent.archived_reason = `pre-BUG-34 intent with no run scope; archived during migration on run ${newRunId}`;
|
|
656
|
-
if (!intent.history) intent.history = [];
|
|
657
|
-
intent.history.push({ from: prevStatus, to: 'archived_migration', at: now, reason: intent.archived_reason });
|
|
658
|
-
safeWriteJson(intentPath, intent);
|
|
659
|
-
archived++;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
return { archived, adopted };
|
|
623
|
+
return archiveStaleIntentsForRun(root, newRunId);
|
|
664
624
|
}
|
|
665
625
|
|
|
666
626
|
/**
|
|
@@ -823,9 +783,9 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
823
783
|
|
|
824
784
|
// BUG-34/39: stamp the current run_id on approval so the intent is scoped
|
|
825
785
|
// to the run that approved it. When approval happens before any governed
|
|
826
|
-
// run exists,
|
|
827
|
-
//
|
|
828
|
-
//
|
|
786
|
+
// run exists, hold the intent in a pre-run durable state so the *next* run
|
|
787
|
+
// initialization can bind it to that run. The flag is not an evergreen
|
|
788
|
+
// cross-run bypass; it exists only until first run binding happens.
|
|
829
789
|
if (!intent.approved_run_id) {
|
|
830
790
|
const statePath = join(root, '.agentxchain', 'state.json');
|
|
831
791
|
if (existsSync(statePath)) {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { safeWriteJson } from './safe-write.js';
|
|
5
|
+
|
|
6
|
+
const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
|
|
7
|
+
|
|
8
|
+
function nowISO() {
|
|
9
|
+
return new Date().toISOString();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getIntentsDir(root) {
|
|
13
|
+
return join(root, '.agentxchain', 'intake', 'intents');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function listIntentFiles(intentsDir) {
|
|
17
|
+
if (!existsSync(intentsDir)) return [];
|
|
18
|
+
return readdirSync(intentsDir).filter((file) => file.endsWith('.json') && !file.startsWith('.tmp-'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatLegacyIntentMigrationNotice(intentIds) {
|
|
22
|
+
if (!Array.isArray(intentIds) || intentIds.length === 0) return null;
|
|
23
|
+
return `Archived ${intentIds.length} pre-BUG-34 intent(s): ${intentIds.join(', ')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function migratePreBug34Intents(root, runId, options = {}) {
|
|
27
|
+
const intentsDir = getIntentsDir(root);
|
|
28
|
+
if (!existsSync(intentsDir)) {
|
|
29
|
+
return {
|
|
30
|
+
archived_migration_count: 0,
|
|
31
|
+
archived_migration_intent_ids: [],
|
|
32
|
+
migration_notice: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const now = nowISO();
|
|
37
|
+
const archivedMigrationIntentIds = [];
|
|
38
|
+
const protocolVersion = options.protocolVersion || '2.x';
|
|
39
|
+
|
|
40
|
+
for (const file of listIntentFiles(intentsDir)) {
|
|
41
|
+
const intentPath = join(intentsDir, file);
|
|
42
|
+
let intent;
|
|
43
|
+
try {
|
|
44
|
+
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!intent || !DISPATCHABLE_STATUSES.has(intent.status)) continue;
|
|
50
|
+
if (intent.cross_run_durable === true) continue;
|
|
51
|
+
if (intent.approved_run_id) continue;
|
|
52
|
+
|
|
53
|
+
const prevStatus = intent.status;
|
|
54
|
+
intent.status = 'archived_migration';
|
|
55
|
+
intent.updated_at = now;
|
|
56
|
+
intent.archived_reason = `pre-BUG-34 intent with no run scope; archived during v${protocolVersion} migration on run ${runId}`;
|
|
57
|
+
if (!intent.history) intent.history = [];
|
|
58
|
+
intent.history.push({
|
|
59
|
+
from: prevStatus,
|
|
60
|
+
to: 'archived_migration',
|
|
61
|
+
at: now,
|
|
62
|
+
reason: intent.archived_reason,
|
|
63
|
+
});
|
|
64
|
+
safeWriteJson(intentPath, intent);
|
|
65
|
+
if (intent.intent_id) archivedMigrationIntentIds.push(intent.intent_id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
archived_migration_count: archivedMigrationIntentIds.length,
|
|
70
|
+
archived_migration_intent_ids: archivedMigrationIntentIds,
|
|
71
|
+
migration_notice: formatLegacyIntentMigrationNotice(archivedMigrationIntentIds),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function archiveStaleIntentsForRun(root, runId, options = {}) {
|
|
76
|
+
const intentsDir = getIntentsDir(root);
|
|
77
|
+
if (!existsSync(intentsDir)) {
|
|
78
|
+
return {
|
|
79
|
+
archived: 0,
|
|
80
|
+
adopted: 0,
|
|
81
|
+
archived_migration_count: 0,
|
|
82
|
+
archived_migration_intent_ids: [],
|
|
83
|
+
migration_notice: null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const now = nowISO();
|
|
88
|
+
let archived = 0;
|
|
89
|
+
let adopted = 0;
|
|
90
|
+
|
|
91
|
+
for (const file of listIntentFiles(intentsDir)) {
|
|
92
|
+
const intentPath = join(intentsDir, file);
|
|
93
|
+
let intent;
|
|
94
|
+
try {
|
|
95
|
+
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!intent || !DISPATCHABLE_STATUSES.has(intent.status)) continue;
|
|
101
|
+
|
|
102
|
+
if (intent.cross_run_durable === true && !intent.approved_run_id) {
|
|
103
|
+
intent.approved_run_id = runId;
|
|
104
|
+
delete intent.cross_run_durable;
|
|
105
|
+
intent.updated_at = now;
|
|
106
|
+
if (!intent.history) intent.history = [];
|
|
107
|
+
intent.history.push({
|
|
108
|
+
from: intent.status,
|
|
109
|
+
to: intent.status,
|
|
110
|
+
at: now,
|
|
111
|
+
reason: `pre-run durable approval bound to run ${runId}`,
|
|
112
|
+
});
|
|
113
|
+
safeWriteJson(intentPath, intent);
|
|
114
|
+
adopted += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (intent.cross_run_durable === true && intent.approved_run_id === runId) {
|
|
119
|
+
delete intent.cross_run_durable;
|
|
120
|
+
intent.updated_at = now;
|
|
121
|
+
safeWriteJson(intentPath, intent);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (intent.approved_run_id && intent.approved_run_id !== runId) {
|
|
126
|
+
const prevStatus = intent.status;
|
|
127
|
+
intent.status = 'suppressed';
|
|
128
|
+
intent.updated_at = now;
|
|
129
|
+
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
|
|
130
|
+
if (!intent.history) intent.history = [];
|
|
131
|
+
intent.history.push({
|
|
132
|
+
from: prevStatus,
|
|
133
|
+
to: 'suppressed',
|
|
134
|
+
at: now,
|
|
135
|
+
reason: intent.archived_reason,
|
|
136
|
+
});
|
|
137
|
+
safeWriteJson(intentPath, intent);
|
|
138
|
+
archived += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const migration = migratePreBug34Intents(root, runId, options);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
archived,
|
|
146
|
+
adopted,
|
|
147
|
+
archived_migration_count: migration.archived_migration_count,
|
|
148
|
+
archived_migration_intent_ids: migration.archived_migration_intent_ids,
|
|
149
|
+
migration_notice: migration.migration_notice,
|
|
150
|
+
};
|
|
151
|
+
}
|