@renzrollon/interlock 1.0.2 → 1.1.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/lib/run.mjs CHANGED
@@ -52,6 +52,7 @@ import { planRemediation } from './remediate.mjs'
52
52
  import { classifySurface } from './surface.mjs'
53
53
  import { buildChecklist } from './conformance.mjs'
54
54
  import { record as recordAutonomy } from './autonomy.mjs'
55
+ import { writeResumeCard } from './resume-card.mjs'
55
56
  import {
56
57
  applyReplan,
57
58
  batchOutcomes,
@@ -1449,7 +1450,8 @@ function logSpawns(ctx, manifest, step) {
1449
1450
  // No run identity yet — the classify step runs before there is a wave state,
1450
1451
  // so nothing is owed. The same rule `logVerifyJudgement` follows: a call with
1451
1452
  // no runId is not part of a logged run, and logging is not what should fail it.
1452
- if (!manifest.runId) return step
1453
+ if (!manifest.runId) return null
1454
+ let failure = null
1453
1455
  for (const s of step.spawns || []) {
1454
1456
  if (s.kind === 'implementer') continue // logged by wave-state, at the lane
1455
1457
  const written = appendRunLogEvent(ctx.root, {
@@ -1460,13 +1462,18 @@ function logSpawns(ctx, manifest, step) {
1460
1462
  model: s.model || undefined,
1461
1463
  kind: s.kind
1462
1464
  })
1463
- if (!written.written) ctx.warn(`agent-spawn not recorded: ${written.reason}`)
1465
+ if (!written.written) {
1466
+ ctx.warn(`agent-spawn not recorded: ${written.reason}`)
1467
+ // The first failure is the one that opened the gap; the rest of the loop
1468
+ // still runs so the warnings a reader gets are unchanged.
1469
+ if (!failure) failure = `agent-spawn ${s.label}: ${written.reason}`
1470
+ }
1464
1471
  }
1465
- return step
1472
+ return failure
1466
1473
  }
1467
1474
 
1468
1475
  function logCliExit(ctx, manifest, command, exitCode) {
1469
- if (!manifest.runId) return
1476
+ if (!manifest.runId) return null
1470
1477
  const written = appendRunLogEvent(ctx.root, {
1471
1478
  runId: manifest.runId,
1472
1479
  change: manifest.change,
@@ -1474,9 +1481,46 @@ function logCliExit(ctx, manifest, command, exitCode) {
1474
1481
  command,
1475
1482
  exitCode
1476
1483
  })
1477
- if (!written.written) ctx.warn(`cli-exit not recorded: ${written.reason}`)
1484
+ if (written.written) return null
1485
+ ctx.warn(`cli-exit not recorded: ${written.reason}`)
1486
+ return `cli-exit ${command}: ${written.reason}`
1487
+ }
1488
+
1489
+ /**
1490
+ * The halt a failed run-trajectory append owes the run.
1491
+ *
1492
+ * `.claude/ship/runs` is `fatal: true` in `lib/doctor.mjs`'s STATE_DIRS, and
1493
+ * `wave-state` / `verify judge` in `bin/interlock` already `process.exit(1)`
1494
+ * when their append does not land. The live `interlock run` path used to warn
1495
+ * and walk on — into the task tick and the commit — so a run nobody could
1496
+ * reconstruct still ticked boxes and shipped. A warning is not a gate.
1497
+ *
1498
+ * Caller-side by construction: fatality is a policy of the SITE, not of the
1499
+ * shared writer, which is why `appendRunLogEvent` still just reports. The
1500
+ * outcome corpus and review metrics keep their deliberately different
1501
+ * semantics — a failed write there is reported and never touches the exit code.
1502
+ *
1503
+ * `site` names where the gap is, so the halt reason is enough to find it.
1504
+ */
1505
+ function trajectoryHaltStep(site, reason, extra = {}) {
1506
+ return haltStep(`trajectory append failed: ${site}: ${reason}`, extra)
1507
+ }
1508
+
1509
+ /**
1510
+ * The reason to halt with when `logWaveMutation` failed.
1511
+ *
1512
+ * It returns the filesystem's own reason where it has one. The fallback covers
1513
+ * an injected or older dep that reports only the boolean — better a named site
1514
+ * with a vague cause than a halt that says nothing at all.
1515
+ */
1516
+ function waveAppendReason(outcome) {
1517
+ const reason = outcome && typeof outcome.reason === 'string' ? outcome.reason.trim() : ''
1518
+ return reason || 'the wave-action or its paired cli-exit did not land'
1478
1519
  }
1479
1520
 
1521
+ /** Same, for `logAgentSpawns` — it returns a boolean and reports its reason to stderr. */
1522
+ const LANE_SPAWN_APPEND_FAILED = 'an implementer lane spawn did not land'
1523
+
1480
1524
  /** The shared tree's HEAD. Never throws: an unreadable base is null, never a guess. */
1481
1525
  function headCommit(ctx) {
1482
1526
  return ctx.deps.headCommit(ctx.root)
@@ -1597,10 +1641,25 @@ export function claimDerivedBanner() {
1597
1641
  * three tasks has told us nothing about the third — and "nothing" read as
1598
1642
  * success is how a task silently ships unimplemented. `not-attempted` is the one
1599
1643
  * outcome that is neither: it is reported, ticked nowhere, and counted nowhere.
1644
+ *
1645
+ * A result that is absent entirely is a lost agent — a crash, a timeout, or a
1646
+ * briefing the agent never acknowledged — not an agent that filed a bad report.
1647
+ * On a multi-task lane that costs one failure, charged to the first task, and
1648
+ * the rest are `not-attempted`. Charging every task would halt a chain on one
1649
+ * miss. A single-task lane is one failure either way, and stays that.
1600
1650
  */
1601
1651
  export function laneOutcomes(lane, result) {
1602
1652
  const failAll = error => lane.map(t => ({ id: t.id, outcome: 'failed', error, handoff: null }))
1603
- if (!result) return failAll('agent returned no result')
1653
+ if (!result) {
1654
+ if (lane.length >= 2) {
1655
+ return lane.map((t, i) =>
1656
+ i === 0
1657
+ ? { id: t.id, outcome: 'failed', error: 'agent returned no result', handoff: null }
1658
+ : { id: t.id, outcome: 'not-attempted' }
1659
+ )
1660
+ }
1661
+ return failAll('agent returned no result')
1662
+ }
1604
1663
 
1605
1664
  if (lane.length === 1) {
1606
1665
  const [only] = lane
@@ -1771,15 +1830,29 @@ function adoptPlan(ctx, manifest, plan) {
1771
1830
  // anything, and `run-log check` does not require it.
1772
1831
  sessionId: hostSessionId(manifest, ctx.env) || undefined
1773
1832
  })
1774
- if (!started.written) ctx.warn(`run-start not recorded: ${started.reason}`)
1833
+ if (!started.written) {
1834
+ ctx.warn(`run-start not recorded: ${started.reason}`)
1835
+ return trajectoryHaltStep('run-start', started.reason)
1836
+ }
1775
1837
 
1776
- const { step, ok } = ctx.deps.logWaveMutation(root, {
1838
+ const created = ctx.deps.logWaveMutation(root, {
1777
1839
  source: 'create',
1778
1840
  change: manifest.change,
1779
1841
  state
1780
1842
  })
1781
- if (!ok) ctx.warn('the wave-state trajectory append failed on run start')
1782
- ctx.deps.logAgentSpawns(root, { state, change: manifest.change, step, source: 'next' })
1843
+ const { step, ok } = created
1844
+ if (!ok) {
1845
+ ctx.warn('the wave-state trajectory append failed on run start')
1846
+ return trajectoryHaltStep('wave-state create', waveAppendReason(created))
1847
+ }
1848
+ // The lane spawns, which `logSpawns` deliberately skips (they are logged at
1849
+ // the lane, by wave-state). Its verdict is read for the same reason every
1850
+ // other append's is: `wave-state next` exits 1 on this exact boolean, and the
1851
+ // live path owes the trajectory the same fatality.
1852
+ if (ctx.deps.logAgentSpawns(root, { state, change: manifest.change, step, source: 'next' }) === false) {
1853
+ ctx.warn('an implementer agent-spawn was not recorded on run start')
1854
+ return trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED)
1855
+ }
1783
1856
  return decorate(ctx, step, manifest)
1784
1857
  }
1785
1858
 
@@ -2103,18 +2176,43 @@ export function runRecordBatch(ctx, { results }) {
2103
2176
  changedPaths: ctx.deps.observedChangedPaths(root)
2104
2177
  })
2105
2178
  writeState(root, after)
2106
- const { step: nextRaw, ok: logOk } = ctx.deps.logWaveMutation(root, {
2179
+ const recorded = ctx.deps.logWaveMutation(root, {
2107
2180
  source: 'record-batch',
2108
2181
  change: manifest.change,
2109
2182
  state: after
2110
2183
  })
2111
- if (!logOk) ctx.warn('the wave-state trajectory append failed on record-batch')
2112
- ctx.deps.logAgentSpawns(root, {
2184
+ const { step: nextRaw, ok: logOk } = recorded
2185
+ if (!logOk) {
2186
+ // Before the tick and before the commit. The batch result is already on
2187
+ // disk (`writeState` above) and there is no undo API for it, so the halt
2188
+ // names the gap instead of pretending the state never advanced — close's
2189
+ // reconstructability check reads the same trajectory and agrees.
2190
+ ctx.warn('the wave-state trajectory append failed on record-batch')
2191
+ manifest.banners.push(...banners)
2192
+ return finishStep(
2193
+ ctx,
2194
+ manifest,
2195
+ trajectoryHaltStep('wave-state record-batch', waveAppendReason(recorded), { banners })
2196
+ )
2197
+ }
2198
+ // Before the tick and before the commit, exactly like the wave-action above:
2199
+ // a batch whose implementer spawns went unrecorded is a batch nobody can
2200
+ // attribute, and `emitRecordedState` already exits 1 on this same boolean.
2201
+ const laneSpawnsLogged = ctx.deps.logAgentSpawns(root, {
2113
2202
  state: after,
2114
2203
  change: manifest.change,
2115
2204
  step: nextRaw,
2116
2205
  source: 'record-batch'
2117
2206
  })
2207
+ if (laneSpawnsLogged === false) {
2208
+ ctx.warn('an implementer agent-spawn was not recorded on record-batch')
2209
+ manifest.banners.push(...banners)
2210
+ return finishStep(
2211
+ ctx,
2212
+ manifest,
2213
+ trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED, { banners })
2214
+ )
2215
+ }
2118
2216
 
2119
2217
  // One authority, read once, for the tick and for the tallies alike.
2120
2218
  const verdict = adjudicateBatches([reported], recordedOutcomes(batchOutcomes(state, after)))
@@ -2227,7 +2325,26 @@ export function runJudge(ctx, { context, results }) {
2227
2325
  }
2228
2326
  verdict = judgeVerification(plan, reportedSteps, { context })
2229
2327
  for (const banner of verdict.banners || []) banners.push(banner)
2230
- logCliExit(ctx, manifest, 'verify judge', verdict.halt ? 1 : 0)
2328
+ const exitFailure = logCliExit(ctx, manifest, 'verify judge', verdict.halt ? 1 : 0)
2329
+ if (exitFailure) {
2330
+ // The judgement happened; the record of it did not. Halting here is what
2331
+ // `verify judge` in bin/interlock already does on the same failure —
2332
+ // before the branch into record-verify, the handoff or the commit.
2333
+ //
2334
+ // A verdict that was ALREADY halting keeps its own reason, the same rule
2335
+ // `finishStep` applies below: this run stopped because verification
2336
+ // failed, and that reason is what reaches the summary, the push and the
2337
+ // resume card. The lost record is a banner beside it, never instead of it.
2338
+ banners.push(`TRAJECTORY APPEND FAILED: ${exitFailure}`)
2339
+ manifest.banners.push(...banners)
2340
+ return finishStep(
2341
+ ctx,
2342
+ manifest,
2343
+ verdict.halt
2344
+ ? haltStep(verdict.reason || 'verification halted the run', { banners })
2345
+ : trajectoryHaltStep('cli-exit', exitFailure.replace(/^cli-exit /, ''), { banners })
2346
+ )
2347
+ }
2231
2348
  } else {
2232
2349
  banners.push(`VERIFICATION SKIPPED: reason=${reason}`)
2233
2350
  }
@@ -2243,13 +2360,21 @@ export function runJudge(ctx, { context, results }) {
2243
2360
  }
2244
2361
  const after = recordVerifyResult(state, record)
2245
2362
  writeState(root, after)
2246
- const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
2363
+ const verified = ctx.deps.logWaveMutation(root, {
2247
2364
  source: 'record-verify',
2248
2365
  change: manifest.change,
2249
2366
  state: after
2250
2367
  })
2251
- if (!ok) ctx.warn('the wave-state trajectory append failed on record-verify')
2368
+ const { step: nextRaw, ok } = verified
2252
2369
  manifest.banners.push(...banners)
2370
+ if (!ok) {
2371
+ ctx.warn('the wave-state trajectory append failed on record-verify')
2372
+ return finishStep(
2373
+ ctx,
2374
+ manifest,
2375
+ trajectoryHaltStep('wave-state record-verify', waveAppendReason(verified), { banners })
2376
+ )
2377
+ }
2253
2378
  return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
2254
2379
  }
2255
2380
 
@@ -2481,12 +2606,16 @@ export function runReplan(ctx, { results }) {
2481
2606
  return finishStep(ctx, manifest, haltStep(`replan rejected: ${(err && err.message) || String(err)}`))
2482
2607
  }
2483
2608
  writeState(root, after)
2484
- const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
2609
+ const replanned = ctx.deps.logWaveMutation(root, {
2485
2610
  source: 'replan',
2486
2611
  change: manifest.change,
2487
2612
  state: after
2488
2613
  })
2489
- if (!ok) ctx.warn('the wave-state trajectory append failed on replan')
2614
+ const { step: nextRaw, ok } = replanned
2615
+ if (!ok) {
2616
+ ctx.warn('the wave-state trajectory append failed on replan')
2617
+ return finishStep(ctx, manifest, trajectoryHaltStep('wave-state replan', waveAppendReason(replanned)))
2618
+ }
2490
2619
  return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
2491
2620
  }
2492
2621
 
@@ -2520,8 +2649,18 @@ function noManifest() {
2520
2649
  * process — each `run` subcommand is its own.
2521
2650
  */
2522
2651
  function finishStep(ctx, manifest, step) {
2523
- logSpawns(ctx, manifest, step)
2524
- return recordThen(ctx.root, manifest, step)
2652
+ const failure = logSpawns(ctx, manifest, step)
2653
+ // An agent-spawn nobody recorded is a gap in the trajectory, and the step it
2654
+ // belongs to must not run. A step that was ALREADY halting keeps its own
2655
+ // reason: replacing it would trade why the run stopped for the bookkeeping
2656
+ // failure that followed, and it halts either way.
2657
+ const final =
2658
+ failure && step.action !== 'halt'
2659
+ ? trajectoryHaltStep('agent-spawn', failure.replace(/^agent-spawn /, ''), {
2660
+ ...(Array.isArray(step.banners) ? { banners: step.banners } : {})
2661
+ })
2662
+ : step
2663
+ return recordThen(ctx.root, manifest, final)
2525
2664
  }
2526
2665
 
2527
2666
  // --- run close (design D10) -------------------------------------------------
@@ -2758,13 +2897,23 @@ export async function runClose(
2758
2897
  : undefined
2759
2898
  })
2760
2899
 
2900
+ // The trajectory writes this close owes. A failure here is the same fatal
2901
+ // class as every other `.claude/ship/runs` append — the close has no tick or
2902
+ // commit left to stop, so the halt it can still perform is a spoken
2903
+ // degradation and a non-zero exit, exactly what an unreconstructable run gets
2904
+ // below. Silence would leave a run that reads complete and cannot be replayed.
2905
+ let trajectoryComplete = true
2761
2906
  if (manifest.runId) {
2762
2907
  const written = appendRunLogEvent(root, {
2763
2908
  ...receipt,
2764
2909
  runId: manifest.runId,
2765
2910
  change: manifest.change
2766
2911
  })
2767
- if (!written.written) ctx.warn(`run-receipt not recorded: ${written.reason}`)
2912
+ if (!written.written) {
2913
+ ctx.warn(`run-receipt not recorded: ${written.reason}`)
2914
+ trajectoryComplete = false
2915
+ degradations.push(`TRAJECTORY APPEND FAILED: run-receipt: ${written.reason}`)
2916
+ }
2768
2917
 
2769
2918
  const terminal = appendRunLogEvent(root, {
2770
2919
  runId: manifest.runId,
@@ -2772,7 +2921,12 @@ export async function runClose(
2772
2921
  type: haltReason ? 'run-halt' : 'run-complete',
2773
2922
  reason: haltReason || undefined
2774
2923
  })
2775
- if (!terminal.written) ctx.warn(`${haltReason ? 'run-halt' : 'run-complete'} not recorded: ${terminal.reason}`)
2924
+ if (!terminal.written) {
2925
+ const type = haltReason ? 'run-halt' : 'run-complete'
2926
+ ctx.warn(`${type} not recorded: ${terminal.reason}`)
2927
+ trajectoryComplete = false
2928
+ degradations.push(`TRAJECTORY APPEND FAILED: ${type}: ${terminal.reason}`)
2929
+ }
2776
2930
  }
2777
2931
 
2778
2932
  // The outcome corpus. Its own write failure is reported and never touches the
@@ -2821,6 +2975,36 @@ export async function runClose(
2821
2975
  }
2822
2976
  }
2823
2977
 
2978
+ // The halt resume card. Only on a halt: a clean close has nothing to resume,
2979
+ // and its summary already ends in the archive reminder.
2980
+ //
2981
+ // Written last, so the card carries every banner this close raised — the
2982
+ // reconstructability failure and the autonomy one included. Its own failure is
2983
+ // a banner and never an exit code: it is a pointer to records that were
2984
+ // already written, so losing it costs a reader convenience, not evidence. That
2985
+ // is the outcome-corpus class, not the trajectory's.
2986
+ //
2987
+ // Nothing reads it back. The next ship still decides whether to skip the
2988
+ // classifier from the plan fingerprint, so a card that was never written, or
2989
+ // was hand-edited, cannot change what a later run does.
2990
+ let resumeCard = null
2991
+ if (haltReason) {
2992
+ const written = writeResumeCard(root, {
2993
+ change: manifest.change,
2994
+ runId: manifest.runId,
2995
+ halted: haltReason,
2996
+ leftoverTaskIds,
2997
+ plan: manifest.plan,
2998
+ planStored: Boolean(planFingerprint) && existsSync(join(root, PLAN_FILE_PATH)),
2999
+ waves: manifest.waves || [],
3000
+ degradations,
3001
+ projectSlug: slug,
3002
+ cwd: absRoot
3003
+ })
3004
+ if (written.written) resumeCard = written.path
3005
+ else degradations.push(`RESUME CARD NOT WRITTEN: ${written.reason}`)
3006
+ }
3007
+
2824
3008
  manifest.lastThen = null
2825
3009
  writeManifest(root, manifest)
2826
3010
 
@@ -2829,7 +3013,7 @@ export async function runClose(
2829
3013
  action: haltReason ? 'halt' : 'complete',
2830
3014
  then: null,
2831
3015
  spawns: [],
2832
- exitCode: haltReason || !reconstructable ? 1 : 0,
3016
+ exitCode: haltReason || !reconstructable || !trajectoryComplete ? 1 : 0,
2833
3017
  banners: degradations,
2834
3018
  summary: formatRunSummary({
2835
3019
  change: manifest.change,
@@ -2841,7 +3025,8 @@ export async function runClose(
2841
3025
  root: absRoot,
2842
3026
  projectSlug: slug,
2843
3027
  push,
2844
- unarchived
3028
+ unarchived,
3029
+ resumeCard
2845
3030
  })
2846
3031
  }
2847
3032
  }