agentxchain 2.135.0 → 2.136.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 +10 -1
- package/package.json +4 -2
- package/scripts/release-preflight.sh +2 -0
- package/src/commands/connector.js +136 -0
- package/src/commands/mission.js +250 -103
- package/src/commands/restart.js +3 -0
- package/src/commands/resume.js +15 -0
- package/src/commands/step.js +12 -0
- package/src/lib/connector-validate.js +6 -0
- package/src/lib/context-section-parser.js +52 -0
- package/src/lib/continuous-run.js +60 -5
- package/src/lib/dispatch-bundle.js +14 -0
- package/src/lib/gate-evaluator.js +18 -1
- package/src/lib/governed-state.js +182 -52
- package/src/lib/intake.js +23 -52
- package/src/lib/intent-startup-migration.js +151 -0
- package/src/lib/normalized-config.js +5 -0
- package/src/lib/runtime-capabilities.js +101 -81
- package/src/lib/schemas/agentxchain-config.schema.json +391 -0
- package/src/lib/schemas/connector-capabilities-output.schema.json +104 -0
|
@@ -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
|
: [],
|
|
@@ -2049,6 +2062,7 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2049
2062
|
|
|
2050
2063
|
const now = new Date().toISOString();
|
|
2051
2064
|
const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
|
|
2065
|
+
const wasNonProgress = state.blocked_on === 'non_progress';
|
|
2052
2066
|
const humanEscalation = findCurrentHumanEscalation(root, state);
|
|
2053
2067
|
const nextState = {
|
|
2054
2068
|
...state,
|
|
@@ -2058,8 +2072,33 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2058
2072
|
escalation: null,
|
|
2059
2073
|
};
|
|
2060
2074
|
|
|
2075
|
+
// BUG-38: reset non-progress tracking when acknowledging non-progress block
|
|
2076
|
+
if (wasNonProgress || details.acknowledge_non_progress) {
|
|
2077
|
+
nextState.non_progress_signature = null;
|
|
2078
|
+
nextState.non_progress_count = 0;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2061
2081
|
writeState(root, nextState);
|
|
2062
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
|
+
|
|
2063
2102
|
if (humanEscalation) {
|
|
2064
2103
|
resolveHumanEscalation(root, humanEscalation.escalation_id, {
|
|
2065
2104
|
resolved_at: now,
|
|
@@ -2101,7 +2140,11 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2101
2140
|
});
|
|
2102
2141
|
}
|
|
2103
2142
|
|
|
2104
|
-
return {
|
|
2143
|
+
return {
|
|
2144
|
+
ok: true,
|
|
2145
|
+
state: attachLegacyCurrentTurnAlias(nextState),
|
|
2146
|
+
migration_notice: formatLegacyIntentMigrationNotice(startupIntents.archived_migration_intent_ids),
|
|
2147
|
+
};
|
|
2105
2148
|
}
|
|
2106
2149
|
|
|
2107
2150
|
// ── Core Operations ──────────────────────────────────────────────────────────
|
|
@@ -2155,40 +2198,24 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2155
2198
|
|
|
2156
2199
|
writeState(root, updatedState);
|
|
2157
2200
|
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
// Intent from a different run — archive it
|
|
2177
|
-
intent.status = 'suppressed';
|
|
2178
|
-
intent.updated_at = intNow;
|
|
2179
|
-
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
|
|
2180
|
-
if (!intent.history) intent.history = [];
|
|
2181
|
-
intent.history.push({ from: 'approved', to: 'suppressed', at: intNow, reason: intent.archived_reason });
|
|
2182
|
-
} else if (!intent.approved_run_id) {
|
|
2183
|
-
// Legacy intent with no run binding — adopt into current run
|
|
2184
|
-
intent.approved_run_id = runId;
|
|
2185
|
-
intent.updated_at = intNow;
|
|
2186
|
-
}
|
|
2187
|
-
safeWriteJson(ip, intent);
|
|
2188
|
-
} catch { /* non-fatal per-intent */ }
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
} 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 || [];
|
|
2205
|
+
|
|
2206
|
+
// BUG-39: emit intents_migrated event when pre-BUG-34 intents were archived
|
|
2207
|
+
if (archivedMigrationIntentIds.length > 0) {
|
|
2208
|
+
emitRunEvent(root, 'intents_migrated', {
|
|
2209
|
+
run_id: runId,
|
|
2210
|
+
phase: updatedState.phase,
|
|
2211
|
+
status: 'active',
|
|
2212
|
+
payload: {
|
|
2213
|
+
archived_count: archivedMigrationIntentIds.length,
|
|
2214
|
+
archived_intent_ids: archivedMigrationIntentIds,
|
|
2215
|
+
reason: 'pre-BUG-34 intents with approved_run_id: null archived during run initialization',
|
|
2216
|
+
},
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2192
2219
|
|
|
2193
2220
|
emitRunEvent(root, 'run_started', {
|
|
2194
2221
|
run_id: runId,
|
|
@@ -2196,7 +2223,10 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2196
2223
|
status: 'active',
|
|
2197
2224
|
payload: { provenance: provenance || {} },
|
|
2198
2225
|
});
|
|
2199
|
-
return
|
|
2226
|
+
// BUG-39: return migration notice so callers can display it
|
|
2227
|
+
const migrationNotice = startupIntents.migration_notice;
|
|
2228
|
+
|
|
2229
|
+
return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), migration_notice: migrationNotice };
|
|
2200
2230
|
}
|
|
2201
2231
|
|
|
2202
2232
|
/**
|
|
@@ -3160,22 +3190,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3160
3190
|
// that this turn didn't modify.
|
|
3161
3191
|
const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
|
|
3162
3192
|
const exitGateId = preGateResult.gate_id || 'unknown_gate';
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
];
|
|
3168
|
-
|
|
3169
|
-
// Also extract file paths from failure reasons (e.g., ".planning/IMPLEMENTATION_NOTES.md: ...")
|
|
3170
|
-
for (const reason of (preGateResult.reasons || [])) {
|
|
3171
|
-
const fileMatch = reason.match(/(?:Required file missing|file): ([^\s,]+)/);
|
|
3172
|
-
if (fileMatch) failingFiles.push(fileMatch[1]);
|
|
3173
|
-
// Also catch paths at the start of semantic failure messages
|
|
3174
|
-
const semanticMatch = reason.match(/^([^\s:]+\.md):/);
|
|
3175
|
-
if (semanticMatch) failingFiles.push(semanticMatch[1]);
|
|
3176
|
-
}
|
|
3177
|
-
|
|
3178
|
-
const uniqueFailingFiles = [...new Set(failingFiles.map(f => f.replace(/^\.\//, '')))];
|
|
3193
|
+
const uniqueFailingFiles = [...new Set(
|
|
3194
|
+
(preGateResult.failing_files || preGateResult.missing_files || [])
|
|
3195
|
+
.map(f => f.replace(/^\.\//, ''))
|
|
3196
|
+
)];
|
|
3179
3197
|
const uncoveredFiles = uniqueFailingFiles.filter(f => !declaredFiles.has(f));
|
|
3180
3198
|
|
|
3181
3199
|
const gateSemanticMode = config.gate_semantic_coverage_mode || 'strict';
|
|
@@ -4207,6 +4225,118 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4207
4225
|
}
|
|
4208
4226
|
}
|
|
4209
4227
|
|
|
4228
|
+
// ── BUG-38: Non-progress convergence guard ─────────────────────────────
|
|
4229
|
+
// Track whether consecutive accepted turns leave the same gate failure
|
|
4230
|
+
// intact without modifying the gated files. When the count reaches the
|
|
4231
|
+
// configurable threshold, block the run to prevent infinite loops.
|
|
4232
|
+
// Proactively evaluates the current phase exit gate on every accepted turn
|
|
4233
|
+
// — not just when a transition is requested — to detect stalled patterns.
|
|
4234
|
+
if (updatedState.status === 'active') {
|
|
4235
|
+
const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
|
|
4236
|
+
const npThreshold = config.run_loop?.non_progress_threshold ?? 3;
|
|
4237
|
+
|
|
4238
|
+
// Proactively evaluate the exit gate for the current phase.
|
|
4239
|
+
// evaluatePhaseExit requires a phase_transition_request, so we synthesize
|
|
4240
|
+
// one pointing to the next phase from routing for probing purposes.
|
|
4241
|
+
let proactiveGateFailure = null;
|
|
4242
|
+
try {
|
|
4243
|
+
const currentRouting = config.routing?.[updatedState.phase];
|
|
4244
|
+
if (currentRouting?.exit_gate) {
|
|
4245
|
+
// Find the next phase to use as synthetic transition target
|
|
4246
|
+
const phases = config.phases || [];
|
|
4247
|
+
const currentIdx = phases.findIndex(p => p.id === updatedState.phase);
|
|
4248
|
+
const nextPhaseId = currentIdx >= 0 && currentIdx < phases.length - 1
|
|
4249
|
+
? phases[currentIdx + 1].id
|
|
4250
|
+
: (currentRouting.allowed_next_roles ? Object.keys(config.routing).find(k => k !== updatedState.phase) : null);
|
|
4251
|
+
|
|
4252
|
+
if (nextPhaseId) {
|
|
4253
|
+
const probeTurn = { ...turnResult, phase_transition_request: nextPhaseId };
|
|
4254
|
+
const probeResult = evaluatePhaseExit({
|
|
4255
|
+
state: updatedState,
|
|
4256
|
+
config,
|
|
4257
|
+
acceptedTurn: probeTurn,
|
|
4258
|
+
root,
|
|
4259
|
+
});
|
|
4260
|
+
if (probeResult.action === 'gate_failed') {
|
|
4261
|
+
proactiveGateFailure = probeResult;
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
} catch { /* non-fatal — probe is advisory */ }
|
|
4266
|
+
|
|
4267
|
+
// Fall back to last_gate_failure if proactive evaluation didn't find one
|
|
4268
|
+
const effectiveGateFailure = proactiveGateFailure || updatedState.last_gate_failure;
|
|
4269
|
+
|
|
4270
|
+
if (effectiveGateFailure && (effectiveGateFailure.gate_id || effectiveGateFailure.gate_type)) {
|
|
4271
|
+
const gateId = effectiveGateFailure.gate_id || effectiveGateFailure.gate_type || 'unknown';
|
|
4272
|
+
// Compute gate failure signature: gate_id + sorted failing files + sorted reasons
|
|
4273
|
+
const failingFiles = [...(effectiveGateFailure.missing_files || effectiveGateFailure.failing_files || [])].sort();
|
|
4274
|
+
const reasons = [...(effectiveGateFailure.reasons || [])].sort();
|
|
4275
|
+
const signature = `${gateId}::${failingFiles.join(',')}::${reasons.join(',')}`;
|
|
4276
|
+
|
|
4277
|
+
// Check if any of the gated files were modified by this turn
|
|
4278
|
+
const gatedFilesModified = failingFiles.some(f => declaredFiles.has(f.replace(/^\.\//, '')));
|
|
4279
|
+
|
|
4280
|
+
const prevSignature = state.non_progress_signature || null;
|
|
4281
|
+
const prevCount = state.non_progress_count || 0;
|
|
4282
|
+
|
|
4283
|
+
if (signature === prevSignature && !gatedFilesModified) {
|
|
4284
|
+
// Same gate failure, gated files untouched — increment counter
|
|
4285
|
+
const newCount = prevCount + 1;
|
|
4286
|
+
updatedState.non_progress_signature = signature;
|
|
4287
|
+
updatedState.non_progress_count = newCount;
|
|
4288
|
+
|
|
4289
|
+
if (newCount >= npThreshold) {
|
|
4290
|
+
// Threshold reached — block the run
|
|
4291
|
+
updatedState.status = 'blocked';
|
|
4292
|
+
updatedState.blocked_on = 'non_progress';
|
|
4293
|
+
updatedState.blocked_reason = buildBlockedReason({
|
|
4294
|
+
category: 'non_progress',
|
|
4295
|
+
recovery: {
|
|
4296
|
+
typed_reason: `Non-progress detected: ${newCount} accepted turns have not reduced gate failure "${gateId}".`,
|
|
4297
|
+
recovery_action: 'agentxchain resume --acknowledge-non-progress',
|
|
4298
|
+
detail: `Gate "${gateId}" has been failing on ${failingFiles.join(', ')} for ${newCount} consecutive turns. The gated file(s) were never modified.`,
|
|
4299
|
+
},
|
|
4300
|
+
turnId: currentTurn.turn_id,
|
|
4301
|
+
blockedAt: now,
|
|
4302
|
+
});
|
|
4303
|
+
|
|
4304
|
+
ledgerEntries.push({
|
|
4305
|
+
type: 'non_progress_block',
|
|
4306
|
+
gate_id: gateId,
|
|
4307
|
+
consecutive_turns: newCount,
|
|
4308
|
+
threshold: npThreshold,
|
|
4309
|
+
failing_files: failingFiles,
|
|
4310
|
+
signature,
|
|
4311
|
+
timestamp: now,
|
|
4312
|
+
});
|
|
4313
|
+
|
|
4314
|
+
emitRunEvent(root, 'run_stalled', {
|
|
4315
|
+
run_id: updatedState.run_id,
|
|
4316
|
+
phase: updatedState.phase,
|
|
4317
|
+
status: 'blocked',
|
|
4318
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4319
|
+
payload: {
|
|
4320
|
+
consecutive_non_progress_turns: newCount,
|
|
4321
|
+
threshold: npThreshold,
|
|
4322
|
+
gate_id: gateId,
|
|
4323
|
+
failing_files: failingFiles,
|
|
4324
|
+
signature,
|
|
4325
|
+
},
|
|
4326
|
+
});
|
|
4327
|
+
}
|
|
4328
|
+
} else {
|
|
4329
|
+
// Gate failure changed or gated files were modified — reset counter
|
|
4330
|
+
updatedState.non_progress_signature = signature;
|
|
4331
|
+
updatedState.non_progress_count = 1;
|
|
4332
|
+
}
|
|
4333
|
+
} else {
|
|
4334
|
+
// No gate failure — reset non-progress tracking
|
|
4335
|
+
updatedState.non_progress_signature = null;
|
|
4336
|
+
updatedState.non_progress_count = 0;
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4210
4340
|
// ── Transaction journal: prepare before committing writes ──────────────
|
|
4211
4341
|
const transactionId = generateId('txn');
|
|
4212
4342
|
const journal = {
|
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,46 +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
|
-
// Legacy intent with no run binding — adopt into current run
|
|
651
|
-
intent.approved_run_id = newRunId;
|
|
652
|
-
intent.updated_at = now;
|
|
653
|
-
safeWriteJson(intentPath, intent);
|
|
654
|
-
adopted++;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
return { archived, adopted };
|
|
623
|
+
return archiveStaleIntentsForRun(root, newRunId);
|
|
659
624
|
}
|
|
660
625
|
|
|
661
626
|
/**
|
|
@@ -816,9 +781,11 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
816
781
|
const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
|
|
817
782
|
const now = nowISO();
|
|
818
783
|
|
|
819
|
-
// BUG-34: stamp the current run_id on approval so the intent is scoped
|
|
820
|
-
// the run that approved it.
|
|
821
|
-
//
|
|
784
|
+
// BUG-34/39: stamp the current run_id on approval so the intent is scoped
|
|
785
|
+
// to the run that approved it. When approval happens before any governed
|
|
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.
|
|
822
789
|
if (!intent.approved_run_id) {
|
|
823
790
|
const statePath = join(root, '.agentxchain', 'state.json');
|
|
824
791
|
if (existsSync(statePath)) {
|
|
@@ -826,10 +793,14 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
826
793
|
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
827
794
|
if (state.run_id) {
|
|
828
795
|
intent.approved_run_id = state.run_id;
|
|
796
|
+
} else {
|
|
797
|
+
intent.cross_run_durable = true;
|
|
829
798
|
}
|
|
830
799
|
} catch {
|
|
831
800
|
// non-fatal — stamp is best-effort during approval
|
|
832
801
|
}
|
|
802
|
+
} else {
|
|
803
|
+
intent.cross_run_durable = true;
|
|
833
804
|
}
|
|
834
805
|
}
|
|
835
806
|
|
|
@@ -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
|
+
}
|
|
@@ -1178,6 +1178,11 @@ export function normalizeV4(raw) {
|
|
|
1178
1178
|
state: '.agentxchain/state.json',
|
|
1179
1179
|
log: null,
|
|
1180
1180
|
},
|
|
1181
|
+
// Passthrough fields — these are runtime behavior overrides that don't
|
|
1182
|
+
// need normalization but must survive the config pipeline.
|
|
1183
|
+
...(raw.gate_semantic_coverage_mode ? { gate_semantic_coverage_mode: raw.gate_semantic_coverage_mode } : {}),
|
|
1184
|
+
...(raw.intent_coverage_mode ? { intent_coverage_mode: raw.intent_coverage_mode } : {}),
|
|
1185
|
+
...(raw.run_loop ? { run_loop: raw.run_loop } : {}),
|
|
1181
1186
|
compat: {
|
|
1182
1187
|
next_owner_source: 'state-json',
|
|
1183
1188
|
lock_based_coordination: false,
|