agentxchain 2.129.0 → 2.130.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.
@@ -153,8 +153,21 @@ function buildRejectionValidation(root, state, config, opts) {
153
153
  [resolution.turn.turn_id]: resolution.turn,
154
154
  },
155
155
  };
156
+ const stagingPath = resolveStagingPath(root, resolution.turn.turn_id);
157
+ // BUG-22: If resolveStagingPath returns null, a stale result from another turn
158
+ // was detected. Reject with a clear diagnostic instead of consuming it.
159
+ if (stagingPath === null) {
160
+ return {
161
+ ok: true,
162
+ turn: resolution.turn,
163
+ validationResult: {
164
+ errors: [`Stale staging data: .agentxchain/staging/turn-result.json belongs to a different turn. Clean up with: rm .agentxchain/staging/turn-result.json`],
165
+ failed_stage: 'stale_staging',
166
+ },
167
+ };
168
+ }
156
169
  const validation = validateStagedTurnResult(root, projectedState, config, {
157
- stagingPath: resolveStagingPath(root, resolution.turn.turn_id),
170
+ stagingPath,
158
171
  });
159
172
  if (!validation.ok) {
160
173
  return {
@@ -213,8 +226,29 @@ function resolveTargetTurn(state, turnId) {
213
226
  }
214
227
 
215
228
  function resolveStagingPath(root, turnId) {
229
+ // BUG-22: Prefer turn-scoped staging path. Only fall back to legacy global
230
+ // staging if the global result's turn_id matches the active turn.
216
231
  const turnScopedPath = getTurnStagingResultPath(turnId);
217
- return existsSync(join(root, turnScopedPath)) ? turnScopedPath : '.agentxchain/staging/turn-result.json';
232
+ if (existsSync(join(root, turnScopedPath))) {
233
+ return turnScopedPath;
234
+ }
235
+
236
+ const legacyPath = '.agentxchain/staging/turn-result.json';
237
+ const legacyAbs = join(root, legacyPath);
238
+ if (existsSync(legacyAbs)) {
239
+ try {
240
+ const raw = JSON.parse(require('fs').readFileSync(legacyAbs, 'utf8'));
241
+ if (raw.turn_id && raw.turn_id !== turnId) {
242
+ // Stale result from a different turn — do not consume
243
+ return null;
244
+ }
245
+ } catch {
246
+ // Parse error — let the validator handle it
247
+ }
248
+ return legacyPath;
249
+ }
250
+
251
+ return legacyPath; // File doesn't exist — validator will report "not found"
218
252
  }
219
253
 
220
254
  function printDispatchBundleWarnings(bundleResult) {
@@ -19,10 +19,15 @@ import {
19
19
  getActiveTurns,
20
20
  getActiveTurnCount,
21
21
  reactivateGovernedRun,
22
+ detectStateBundleDesync,
22
23
  STATE_PATH,
23
24
  HISTORY_PATH,
24
25
  LEDGER_PATH,
25
26
  } from '../lib/governed-state.js';
27
+ import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
28
+ import { getDispatchTurnDir } from '../lib/turn-paths.js';
29
+ import { consumeNextApprovedIntent } from '../lib/intake.js';
30
+ import { loadProjectState } from '../lib/config.js';
26
31
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
27
32
  import { deriveRecommendedContinuityAction } from '../lib/continuity-status.js';
28
33
  import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
@@ -213,6 +218,25 @@ export async function restartCommand(opts) {
213
218
  process.exit(1);
214
219
  }
215
220
 
221
+ // ── BUG-18: State/bundle integrity check ─────────────────────────────────
222
+ const desync = detectStateBundleDesync(root, state);
223
+ if (!desync.ok) {
224
+ console.log(chalk.red('State/bundle integrity failure detected:'));
225
+ for (const entry of desync.desynced) {
226
+ console.log(chalk.red(` Active turn ${entry.turn_id} (${entry.role}) has no dispatch bundle at ${entry.expected_path}`));
227
+ }
228
+ console.log('');
229
+ console.log(chalk.dim('This is a ghost turn — state references an active turn but the dispatch files are missing.'));
230
+ console.log(chalk.dim('Recovery options:'));
231
+ for (const entry of desync.desynced) {
232
+ console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${entry.turn_id} --reason "missing dispatch bundle"`)}`);
233
+ }
234
+ console.log(` ${chalk.cyan('agentxchain reject-turn --reason "ghost turn — missing dispatch bundle"')}`);
235
+ console.log('');
236
+ console.log(chalk.dim('Run `agentxchain doctor` for a full diagnostic.'));
237
+ process.exit(1);
238
+ }
239
+
216
240
  // ── Repo-drift detection ────────────────────────────────────────────────
217
241
  const driftWarnings = [];
218
242
  if (checkpoint?.baseline_ref) {
@@ -316,21 +340,61 @@ export async function restartCommand(opts) {
316
340
 
317
341
  // Assign next turn if no active turn exists
318
342
  if (activeTurnCount === 0) {
319
- const assignment = assignGovernedTurn(root, config, roleId);
320
- if (!assignment.ok) {
321
- console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
322
- process.exit(1);
343
+ // BUG-21 fix: consume approved intents (same as resume path) so intent_id
344
+ // propagates into turn metadata and all lifecycle events.
345
+ const consumed = consumeNextApprovedIntent(root, { role: roleId });
346
+ let assignedState;
347
+ let turnId;
348
+ let assignedRole = roleId;
349
+
350
+ if (consumed.ok) {
351
+ // Intake path handled the turn assignment with intakeContext
352
+ assignedState = loadProjectState(root, config);
353
+ if (!assignedState) {
354
+ console.log(chalk.red('Failed to reload governed state after intake binding.'));
355
+ process.exit(1);
356
+ }
357
+ turnId = consumed.turn_id;
358
+ assignedRole = consumed.role || roleId;
359
+ console.log(chalk.green(`Bound approved intent to next turn: ${consumed.intentId}`));
360
+ } else {
361
+ // No approved intents — plain assignment
362
+ const assignment = assignGovernedTurn(root, config, roleId);
363
+ if (!assignment.ok) {
364
+ console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
365
+ process.exit(1);
366
+ }
367
+ for (const warning of assignment.warnings || []) {
368
+ console.log(chalk.yellow(`Warning: ${warning}`));
369
+ }
370
+ assignedState = assignment.state;
371
+ turnId = assignment.turn?.turn_id || assignment.turn?.id;
372
+ assignedRole = assignment.turn?.assigned_role || roleId;
323
373
  }
324
- for (const warning of assignment.warnings || []) {
325
- console.log(chalk.yellow(`Warning: ${warning}`));
374
+
375
+ // BUG-17 fix: write dispatch bundle AFTER state assignment succeeds.
376
+ // The bundle must exist on disk before we report success, otherwise the
377
+ // operator sees a "ghost turn" in state with no dispatch directory.
378
+ if (turnId) {
379
+ const bundleResult = writeDispatchBundle(root, assignedState, config, { turnId });
380
+ if (!bundleResult.ok) {
381
+ console.log(chalk.red(`Turn assigned but dispatch bundle write failed: ${bundleResult.error}`));
382
+ console.log(chalk.dim('The turn is assigned in state but has no dispatch context.'));
383
+ console.log(chalk.dim('Run `agentxchain reissue-turn` to reissue with a fresh bundle.'));
384
+ process.exit(1);
385
+ }
386
+ for (const bw of bundleResult.warnings || []) {
387
+ console.log(chalk.yellow(`Dispatch bundle warning: ${bw}`));
388
+ }
326
389
  }
327
390
 
328
391
  // assignGovernedTurn already writes a checkpoint at turn_assigned
329
392
 
330
393
  console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
331
394
  console.log(chalk.dim(` Phase: ${phase}`));
332
- console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
333
- console.log(chalk.dim(` Role: ${assignment.turn?.role || roleId || 'routing default'}`));
395
+ console.log(chalk.dim(` Turn: ${turnId || 'assigned'}`));
396
+ console.log(chalk.dim(` Role: ${assignedRole || 'routing default'}`));
397
+ console.log(chalk.dim(` Dispatch: ${getDispatchTurnDir(turnId || 'unknown')}/`));
334
398
  if (checkpoint) {
335
399
  console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
336
400
  }
@@ -48,6 +48,7 @@ import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
48
48
  import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuous-run.js';
49
49
  import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
50
50
  import { emitRunEvent } from '../lib/run-events.js';
51
+ import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
51
52
 
52
53
  export async function runCommand(opts) {
53
54
  const context = loadProjectContext();
@@ -148,6 +149,7 @@ export async function executeGovernedRun(context, opts = {}) {
148
149
 
149
150
  const maxTurns = opts.maxTurns || 50;
150
151
  const autoApprove = !!opts.autoApprove;
152
+ const autoCheckpoint = opts.autoCheckpoint === true;
151
153
  const verbose = !!opts.verbose;
152
154
  const overrideResolution = opts.role
153
155
  ? resolveGovernedRole({ override: opts.role, state: null, config })
@@ -499,6 +501,17 @@ export async function executeGovernedRun(context, opts = {}) {
499
501
  return approved;
500
502
  },
501
503
 
504
+ async afterAccept({ turn }) {
505
+ if (!autoCheckpoint) {
506
+ return { ok: true };
507
+ }
508
+ const checkpoint = checkpointAcceptedTurn(root, { turnId: turn.turn_id });
509
+ if (!checkpoint.ok) {
510
+ return { ok: false, error: checkpoint.error || `checkpoint failed for ${turn.turn_id}` };
511
+ }
512
+ return { ok: true };
513
+ },
514
+
502
515
  onEvent(event) {
503
516
  switch (event.type) {
504
517
  case 'turn_assigned':
@@ -8,7 +8,7 @@ import {
8
8
  deriveRecoveryDescriptor,
9
9
  deriveRuntimeBlockedGuidance,
10
10
  } from '../lib/blocked-state.js';
11
- import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift } from '../lib/governed-state.js';
11
+ import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/governed-state.js';
12
12
  import { getContinuityStatus } from '../lib/continuity-status.js';
13
13
  import { getConnectorHealth } from '../lib/connector-health.js';
14
14
  import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
@@ -182,6 +182,7 @@ function renderGovernedStatus(context, opts) {
182
182
  workflow_kit_artifacts: workflowKitArtifacts,
183
183
  dashboard_session: dashboardSessionObj,
184
184
  binding_drift: detectActiveTurnBindingDrift(state, config),
185
+ bundle_integrity: detectStateBundleDesync(root, state),
185
186
  }, null, 2));
186
187
  return;
187
188
  }
@@ -284,6 +285,17 @@ function renderGovernedStatus(context, opts) {
284
285
  renderConnectorHealthStatus(connectorHealth);
285
286
  renderRecentEventSummary(recentEventSummary);
286
287
 
288
+ // BUG-18: State/bundle integrity check
289
+ const desync = detectStateBundleDesync(root, state);
290
+ if (!desync.ok) {
291
+ console.log(chalk.red.bold(' ⚠ Ghost turn(s) detected — dispatch bundle missing'));
292
+ for (const entry of desync.desynced) {
293
+ console.log(chalk.red(` ${entry.turn_id} (${entry.role}): ${entry.expected_path} not found`));
294
+ }
295
+ console.log(chalk.dim(' Run `agentxchain reissue-turn` to recover, or `agentxchain doctor` for diagnostics.'));
296
+ console.log('');
297
+ }
298
+
287
299
  const activeTurnCount = getActiveTurnCount(state);
288
300
  const singleActiveTurn = getActiveTurn(state);
289
301
  const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
@@ -239,6 +239,7 @@ export function resolveContinuousOptions(opts, config) {
239
239
  triageApproval: opts.triageApproval ?? configCont.triage_approval ?? 'auto',
240
240
  cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
241
241
  perSessionMaxUsd: opts.sessionBudget ?? configCont.per_session_max_usd ?? null,
242
+ autoCheckpoint: opts.autoCheckpoint ?? configCont.auto_checkpoint ?? true,
242
243
  };
243
244
  }
244
245
 
@@ -307,7 +308,12 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
307
308
 
308
309
  let execution;
309
310
  try {
310
- execution = await executeGovernedRun(context, { autoApprove: true, report: true, log });
311
+ execution = await executeGovernedRun(context, {
312
+ autoApprove: true,
313
+ autoCheckpoint: contOpts.autoCheckpoint,
314
+ report: true,
315
+ log,
316
+ });
311
317
  } catch (err) {
312
318
  session.status = 'failed';
313
319
  writeContinuousSession(root, session);
@@ -413,6 +419,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
413
419
  try {
414
420
  execution = await executeGovernedRun(context, {
415
421
  autoApprove: true,
422
+ autoCheckpoint: contOpts.autoCheckpoint,
416
423
  report: true,
417
424
  log,
418
425
  });
@@ -213,6 +213,31 @@ export function selectNextAssignment(workspacePath, state, config) {
213
213
  return firstFailure || { ok: false, reason: 'no_assignable_workstream', detail: 'No workstream is assignable in the current phase' };
214
214
  }
215
215
 
216
+ export function selectAssignmentForWorkstream(workspacePath, state, config, workstreamId) {
217
+ const workstream = config.workstreams?.[workstreamId];
218
+ if (!workstream) {
219
+ return {
220
+ ok: false,
221
+ reason: 'workstream_missing',
222
+ detail: `Unknown workstream "${workstreamId}"`,
223
+ workstream_id: workstreamId,
224
+ };
225
+ }
226
+
227
+ if (workstream.phase !== state.phase) {
228
+ return {
229
+ ok: false,
230
+ reason: 'phase_mismatch',
231
+ detail: `Workstream "${workstreamId}" is in phase "${workstream.phase}", but coordinator is currently in phase "${state.phase}"`,
232
+ workstream_id: workstreamId,
233
+ };
234
+ }
235
+
236
+ const history = readCoordinatorHistory(workspacePath);
237
+ const barriers = readBarriers(workspacePath);
238
+ return evaluateWorkstream(workspacePath, state, config, workstreamId, history, barriers);
239
+ }
240
+
216
241
  export function dispatchCoordinatorTurn(workspacePath, state, config, assignment) {
217
242
  if (!assignment?.ok) {
218
243
  return { ok: false, error: 'Assignment is required before dispatch' };
@@ -62,6 +62,7 @@ import {
62
62
  summarizeVerificationReplay,
63
63
  } from './verification-replay.js';
64
64
  import { executeGateActions } from './gate-actions.js';
65
+ import { detectPendingCheckpoint } from './turn-checkpoint.js';
65
66
 
66
67
  // ── Constants ────────────────────────────────────────────────────────────────
67
68
 
@@ -405,6 +406,32 @@ export function getActiveTurn(state) {
405
406
  return turns.length === 1 ? turns[0] : null;
406
407
  }
407
408
 
409
+ /**
410
+ * BUG-18: Detect state/bundle desync — every active turn referenced in
411
+ * state.json must have a corresponding dispatch bundle directory on disk.
412
+ *
413
+ * @param {string} root - project root
414
+ * @param {object} state - governed state
415
+ * @returns {{ ok: boolean, desynced: Array<{ turn_id: string, role: string, expected_path: string }> }}
416
+ */
417
+ export function detectStateBundleDesync(root, state) {
418
+ const activeTurns = getActiveTurns(state);
419
+ const desynced = [];
420
+
421
+ for (const [turnId, turn] of Object.entries(activeTurns)) {
422
+ const bundleDir = join(root, '.agentxchain', 'dispatch', 'turns', turnId);
423
+ if (!existsSync(bundleDir)) {
424
+ desynced.push({
425
+ turn_id: turnId,
426
+ role: turn.assigned_role || 'unknown',
427
+ expected_path: `.agentxchain/dispatch/turns/${turnId}`,
428
+ });
429
+ }
430
+ }
431
+
432
+ return { ok: desynced.length === 0, desynced };
433
+ }
434
+
408
435
  function resolveRecoveryTurnId(state, preferredTurnId = null) {
409
436
  const activeTurns = getActiveTurns(state);
410
437
  if (preferredTurnId && activeTurns[preferredTurnId]) {
@@ -2163,6 +2190,10 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
2163
2190
  const writeAuthority = role.write_authority || 'review_only';
2164
2191
  const cleanCheck = checkCleanBaseline(root, writeAuthority);
2165
2192
  if (!cleanCheck.clean) {
2193
+ const pendingCheckpoint = detectPendingCheckpoint(root, cleanCheck.dirty_files || []);
2194
+ if (pendingCheckpoint.required) {
2195
+ return { ok: false, error: pendingCheckpoint.message, error_code: 'checkpoint_required' };
2196
+ }
2166
2197
  return { ok: false, error: cleanCheck.reason };
2167
2198
  }
2168
2199
 
@@ -2754,7 +2785,25 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2754
2785
  }
2755
2786
 
2756
2787
  const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
2757
- const resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
2788
+ let resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
2789
+ // BUG-22: verify legacy staging file belongs to the active turn before consuming
2790
+ if (resolvedStagingPath === STAGING_PATH) {
2791
+ try {
2792
+ const legacyAbs = join(root, STAGING_PATH);
2793
+ if (existsSync(legacyAbs)) {
2794
+ const raw = JSON.parse(readFileSync(legacyAbs, 'utf8'));
2795
+ if (raw.turn_id && raw.turn_id !== currentTurn.turn_id) {
2796
+ return {
2797
+ ok: false,
2798
+ error: `Stale staging data: ${STAGING_PATH} contains turn_id "${raw.turn_id}" but active turn is "${currentTurn.turn_id}". Remove the stale file or use the turn-scoped staging path.`,
2799
+ error_code: 'stale_staging',
2800
+ };
2801
+ }
2802
+ }
2803
+ } catch {
2804
+ // Parse error handled by downstream validation
2805
+ }
2806
+ }
2758
2807
  const stagedTurn = loadHookStagedTurn(root, resolvedStagingPath);
2759
2808
  const validationState = attachLegacyCurrentTurnAlias({
2760
2809
  ...state,
@@ -3838,6 +3887,106 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3838
3887
  }
3839
3888
  }
3840
3889
 
3890
+ // ── BUG-19: Post-acceptance gate reconciliation ────────────────────────
3891
+ // If a previous gate failure is cached in last_gate_failure, re-evaluate
3892
+ // whether the conditions are now satisfied after this turn's artifacts.
3893
+ // This prevents stale gate failures from surviving after a turn fixes them.
3894
+ // Only clear if ALL failure conditions are now resolved.
3895
+ if (updatedState.last_gate_failure && updatedState.status !== 'completed' && updatedState.status !== 'blocked') {
3896
+ const staleGate = updatedState.last_gate_failure;
3897
+ let allConditionsResolved = true;
3898
+
3899
+ // Check if missing_files are now present
3900
+ if (Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0) {
3901
+ const stillMissing = staleGate.missing_files.filter(f => !existsSync(join(root, f)));
3902
+ if (stillMissing.length > 0) {
3903
+ allConditionsResolved = false;
3904
+ }
3905
+ }
3906
+
3907
+ // Check if missing_verification is still an issue
3908
+ // Verification failures can only be resolved by the specific gate re-evaluation
3909
+ // during a phase_transition_request, not by post-acceptance reconciliation,
3910
+ // because verification is turn-specific — the prior turn's verification status
3911
+ // is what the gate evaluated, and a different turn's pass doesn't retroactively
3912
+ // fix the prior turn's failure.
3913
+ if (staleGate.missing_verification) {
3914
+ allConditionsResolved = false;
3915
+ }
3916
+
3917
+ // Only clear if there were resolvable conditions and they are all resolved
3918
+ const hadResolvableConditions = Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0;
3919
+ if (allConditionsResolved && hadResolvableConditions) {
3920
+ updatedState.last_gate_failure = null;
3921
+ if (staleGate.gate_id) {
3922
+ updatedState.phase_gate_status = {
3923
+ ...(updatedState.phase_gate_status || {}),
3924
+ [staleGate.gate_id]: 'cleared_by_reconciliation',
3925
+ };
3926
+ }
3927
+ ledgerEntries.push({
3928
+ type: 'gate_reconciliation',
3929
+ gate_id: staleGate.gate_id,
3930
+ gate_type: staleGate.gate_type,
3931
+ phase: updatedState.phase,
3932
+ reason: 'post_acceptance_reconciliation',
3933
+ previously_missing_files: staleGate.missing_files || [],
3934
+ reconciled_at: now,
3935
+ reconciled_by_turn: currentTurn.turn_id,
3936
+ });
3937
+ }
3938
+ }
3939
+
3940
+ // ── BUG-20: Post-acceptance intent satisfaction ─────────────────────────
3941
+ // When a turn bound to an injected intent is accepted successfully, transition
3942
+ // the intent to 'completed' so it disappears from the pending queue.
3943
+ if (currentTurn.intake_context?.intent_id) {
3944
+ const intentId = currentTurn.intake_context.intent_id;
3945
+ try {
3946
+ const intentPath = join(root, '.agentxchain', 'intake', 'intents', `${intentId}.json`);
3947
+ if (existsSync(intentPath)) {
3948
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
3949
+ if (intent.status === 'executing') {
3950
+ intent.status = 'completed';
3951
+ intent.completed_at = now;
3952
+ intent.run_completed_at = updatedState.completed_at || now;
3953
+ intent.run_final_turn = currentTurn.turn_id;
3954
+ intent.updated_at = now;
3955
+ intent.satisfying_turn = currentTurn.turn_id;
3956
+ if (!Array.isArray(intent.history)) intent.history = [];
3957
+ intent.history.push({
3958
+ from: 'executing',
3959
+ to: 'completed',
3960
+ at: now,
3961
+ turn_id: currentTurn.turn_id,
3962
+ role: currentTurn.assigned_role,
3963
+ run_id: updatedState.run_id,
3964
+ reason: 'turn accepted — acceptance contract satisfied',
3965
+ });
3966
+ writeFileSync(intentPath, JSON.stringify(intent, null, 2));
3967
+
3968
+ // Create observation scaffold (same as resolve path)
3969
+ const obsDir = join(root, '.agentxchain', 'intake', 'observations', intentId);
3970
+ mkdirSync(obsDir, { recursive: true });
3971
+
3972
+ // Emit intent_satisfied event
3973
+ emitRunEvent(root, 'intent_satisfied', {
3974
+ run_id: updatedState.run_id,
3975
+ phase: updatedState.phase,
3976
+ status: updatedState.status,
3977
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3978
+ intent_id: intentId,
3979
+ payload: {
3980
+ satisfying_turn: currentTurn.turn_id,
3981
+ },
3982
+ });
3983
+ }
3984
+ }
3985
+ } catch {
3986
+ // Non-fatal — intent satisfaction is advisory
3987
+ }
3988
+ }
3989
+
3841
3990
  // ── Transaction journal: prepare before committing writes ──────────────
3842
3991
  const transactionId = generateId('txn');
3843
3992
  const journal = {
package/src/lib/intake.js CHANGED
@@ -1155,6 +1155,18 @@ export function resolveIntent(root, intentId) {
1155
1155
 
1156
1156
  const { intent, intentPath, dirs } = loadedIntent;
1157
1157
 
1158
+ if (intent.status === 'completed') {
1159
+ return {
1160
+ ok: true,
1161
+ intent,
1162
+ previous_status: 'completed',
1163
+ new_status: 'completed',
1164
+ run_outcome: 'completed',
1165
+ no_change: true,
1166
+ exitCode: 0,
1167
+ };
1168
+ }
1169
+
1158
1170
  if (intent.status !== 'executing' && intent.status !== 'blocked') {
1159
1171
  return {
1160
1172
  ok: false,