dsh-pause-resume 0.2.0 → 0.4.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/lib/epoch.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * One epoch per plugin instance, shared across HMR module evaluations.
3
+ *
4
+ * A reload can leave the previous engine's press mid-await. Without a generation marker that old
5
+ * press must not overwrite the plan the new instance wrote. The marker lives on `globalThis`, so a
6
+ * re-evaluated module sees the same counter, and every write checks it.
7
+ */
8
+
9
+ const KEY = Symbol.for('dsh-pause-resume.epoch')
10
+
11
+ export function nextEpoch() {
12
+ const holder = globalThis
13
+ const value = Number.isSafeInteger(holder[KEY]) ? holder[KEY] : 0
14
+ holder[KEY] = value + 1
15
+ return holder[KEY]
16
+ }
17
+
18
+ export function currentEpoch() {
19
+ const value = globalThis[KEY]
20
+ return Number.isSafeInteger(value) ? value : 0
21
+ }
22
+
23
+ /** A disposed instance invalidates its own epoch, so its in-flight writes become no-ops. */
24
+ export function dropEpoch(epoch) {
25
+ if (currentEpoch() !== epoch) return
26
+ nextEpoch()
27
+ }
package/lib/heartbeat.js CHANGED
@@ -34,6 +34,8 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
34
34
  let stopped = false
35
35
  let pending = []
36
36
  let lastWritten = ''
37
+ /** A boot whose crash residue could not be recorded must not overwrite the previous heartbeat. */
38
+ let writable = true
37
39
 
38
40
  function noteError(message) {
39
41
  try {
@@ -66,10 +68,18 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
66
68
 
67
69
  /** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
68
70
  function syncDir(path) {
71
+ if (process.platform === 'win32') return // ui-copy-ignore
69
72
  let fd = null
70
73
  try {
71
74
  fd = fsImpl.openSync(path, 'r')
72
75
  fsImpl.fsyncSync(fd)
76
+ } catch (error) {
77
+ // Some filesystems refuse a directory fsync after a rename that already landed. The record is
78
+ // published either way; only the power-loss guarantee is weaker, so this is not a failure.
79
+ const code = error?.code
80
+ if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOTSUP' && code !== 'EINVAL' && code !== 'ENOSYS' && code !== 'EBADF' && code !== 'EISDIR') {
81
+ noteError(`cannot flush ${path}: ${error?.message ?? error}`)
82
+ }
73
83
  } finally {
74
84
  if (fd !== null) closeQuietly(fsImpl, fd)
75
85
  }
@@ -112,10 +122,13 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
112
122
  }
113
123
  }
114
124
 
115
- /** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
116
- function capture() {
117
- if (captured) return residue
118
- captured = true
125
+ /**
126
+ * Read the previous process's record without claiming it. The caller has to read this before
127
+ * `start()` overwrites the file, and turning it into a durable plan may take a moment: a read that
128
+ * also claimed would leave the very next read empty if that write failed.
129
+ */
130
+ function peek() {
131
+ if (residue !== null) return residue
119
132
  let parsed = null
120
133
  try {
121
134
  parsed = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
@@ -130,7 +143,15 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
130
143
  return residue
131
144
  }
132
145
 
146
+ /** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
147
+ function capture() {
148
+ if (captured) return residue
149
+ captured = true
150
+ return peek()
151
+ }
152
+
133
153
  function writePending() {
154
+ if (writable === false) return false
134
155
  return dirty() ? persist(pending) : true
135
156
  }
136
157
 
@@ -207,16 +228,25 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
207
228
  }
208
229
  }
209
230
 
231
+ /** Freeze the file after a failed crash-plan write: it is the only remaining copy of that work. */
232
+ function hold() {
233
+ writable = false
234
+ stop()
235
+ }
236
+
210
237
  /** The residue was restored: from here on the heartbeat describes this process again. */
211
238
  function release() {
239
+ writable = true
212
240
  residue = null
213
241
  void refresh(true)
214
242
  }
215
243
 
216
244
  return {
217
245
  note,
246
+ peek,
218
247
  start,
219
248
  stop,
249
+ hold,
220
250
  release,
221
251
  flush,
222
252
  get residue() {
package/lib/probe.js CHANGED
@@ -10,9 +10,13 @@
10
10
  * - `sessionController.cancel({ sessionId })` stops one turn and keeps the pending inbox.
11
11
  * - `sessionController.resolveAgent(sessionId)` is the public resume path: it mounts the recorded
12
12
  * preset and model selection, unlike a raw `agents.resume`.
13
- * - `sessionController.prompt(...)` admits one real user message and wakes the agent. That is the
14
- * only supported way to make a resumed session continue work, so a restore that must continue
15
- * says so with a visible message instead of pretending the turn came back by itself.
13
+ * - `sessionController.prompt(request, signal)` admits one real user message after resuming the
14
+ * session itself. The shipped service validates the AbortSignal before it does anything, so the
15
+ * second argument is that signal, never the source metadata (the service builds the source).
16
+ * That is the only supported way to make a resumed session continue work, so a restore that must
17
+ * continue says so with a visible message instead of pretending the turn came back by itself.
18
+ * - `sessionController.resolveAgent(sessionId)` answers an already live Agent directly and wraps a
19
+ * cold one as `{ agent }`, or `{ error }`; both shapes are handled here.
16
20
  * - `goals.pause` / `goals.resume` require the exact current revision, and every transition bumps
17
21
  * it. The revision captured after pausing is the one a later resume must present.
18
22
  */
@@ -145,7 +149,11 @@ export function createProbe(services, options = {}) {
145
149
  const goal = goalView(services, agent)
146
150
  const owned = listJobs(services, [agent.id])
147
151
  sessions.push({ sessionId: agent.id, running: agent.status === 'running', lastTurn: lastTurnOf(agent) })
148
- if (goal !== undefined && goal.phase === 'active') goals.push({ sessionId: agent.id, goalId: goal.id, revision: goal.revision })
152
+ // A disarmed goal is not work: it will not start a round, and pausing it would throw. Only an
153
+ // active, armed goal is something this press can stop and this restore can re-arm.
154
+ if (goal !== undefined && goal.phase === 'active' && goal.activation === 'armed') {
155
+ goals.push({ sessionId: agent.id, goalId: goal.id, revision: goal.revision })
156
+ }
149
157
  for (const job of owned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: agent.id, status: job.status })
150
158
  }
151
159
  const unique = new Map()
@@ -224,12 +232,17 @@ export function createProbe(services, options = {}) {
224
232
  }
225
233
 
226
234
  /** The continuation message: a real user message, so the session history shows why it moved. */
227
- function continuation(plan, sessionId) {
235
+ function continuation(plan, sessionId, epoch) {
236
+ // The epoch is the pause cycle. A retry of the same pause reuses the id (official dedupe keeps
237
+ // admission idempotent), while a new pause after a partial restore gets a fresh one, so the
238
+ // earlier admitted message cannot swallow this cycle's continuation.
239
+ const cycle = Number.isSafeInteger(epoch) && epoch > 0 ? epoch : 1
228
240
  return {
229
241
  sessionId,
230
- requestId: `${plan.planId}:continue:${sessionId}`,
242
+ requestId: `${plan.planId}:continue:${sessionId}:${cycle}`,
231
243
  content: [{ type: 'text', text: continueText }],
232
- mode: 'followup',
244
+ // `queue` is the shipped public mode: deliver after the current turn, start one when idle.
245
+ mode: 'queue',
233
246
  }
234
247
  }
235
248
 
@@ -237,7 +250,11 @@ export function createProbe(services, options = {}) {
237
250
  async function mount(sessionId) {
238
251
  const controller = service(services, 'sessionController')
239
252
  if (controller !== undefined && controller !== null && typeof controller.resolveAgent === 'function') {
240
- const agent = await controller.resolveAgent(sessionId)
253
+ const resolved = await controller.resolveAgent(sessionId)
254
+ // The shipped service returns the Agent itself when it is already live, `{ agent }` after a
255
+ // cold resume, and `{ error }` when the resume failed. All three are official shapes.
256
+ if (resolved !== null && typeof resolved === 'object' && 'error' in resolved) throw resolved.error
257
+ const agent = resolved !== null && typeof resolved === 'object' && 'agent' in resolved ? resolved.agent : resolved
241
258
  if (agent === undefined || agent === null || typeof agent.id !== 'string') throw new Error('resume returned no agent')
242
259
  return agent
243
260
  }
@@ -262,29 +279,70 @@ export function createProbe(services, options = {}) {
262
279
  try {
263
280
  agent = await mount(sessionId)
264
281
  } catch (error) {
265
- return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
282
+ const detail = String(error?.message ?? error).slice(0, 120)
283
+ // A subagent row belongs to the parent chat that owns its routing; this process may not
284
+ // claim it directly, and a retry would fail the same way forever. The parent restores it.
285
+ if (/owned by subagent routing/i.test(detail)) {
286
+ return { status: 'attention', detail: 'subagent session; its parent restores it' }
287
+ }
288
+ return { status: 'failed', detail }
266
289
  }
267
290
  }
268
291
 
269
- if (session.running !== true) return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
292
+ // Version 2 plans carry only `running`; a normalized version 3 plan carries `intent`. Reading
293
+ // the old field here keeps a plan written by 0.3.0 working across the upgrade.
294
+ const intent = session.intent === undefined && session.running === true ? 'continue' : session.intent
295
+ if (intent !== 'continue') {
296
+ return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
297
+ }
270
298
 
271
- // Someone continued by hand after the pause: the press must not add a second turn.
272
- const lastTurn = lastTurnOf(agent)
273
- if (agent.status === 'running' || (Number.isSafeInteger(session.lastTurn) && lastTurn !== null && lastTurn > session.lastTurn)) {
274
- return { status: 'skipped', detail: 'already continued', continued: false }
299
+ // A turn that is already open is not evidence the continuation arrived, but it is work in
300
+ // progress: starting a second one would be a duplicate. A turn that advanced and then ended is
301
+ // different: it may have been the continuation, or it may have been interrupted after the
302
+ // request was admitted. Neither case allows resending, and neither may be reported as a clean
303
+ // `skipped` with no attention.
304
+ const baseline = Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null
305
+ const turnBefore = lastTurnOf(agent)
306
+ if (agent.status === 'running') return { status: 'skipped', detail: 'already running', continued: false }
307
+ if (baseline !== null && turnBefore !== null && turnBefore > baseline) {
308
+ return { status: 'attention', detail: 'turn advanced after the pause; not sent again', continued: false }
275
309
  }
276
310
 
277
311
  const controller = service(services, 'sessionController')
278
312
  if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
279
313
  return { status: 'failed', detail: 'prompt unavailable', continued: false }
280
314
  }
281
- const request = continuation(plan, sessionId)
315
+ const request = continuation(plan, sessionId, session.epoch)
282
316
  try {
283
- await controller.prompt(request, { kind: 'user', rpcId: request.requestId }, new AbortController().signal)
317
+ // Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
318
+ // here made every restore fail on a live Harness while the fake in the tests accepted it.
319
+ await controller.prompt(request, new AbortController().signal)
284
320
  } catch (error) {
285
321
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120), continued: false }
286
322
  }
287
- return { status: 'restored', detail: mounted ? 'continued' : 'resumed and continued', continued: true }
323
+
324
+ // Admission is not execution. The shipped prompt answers `{ accepted: true }` without waking a
325
+ // queue when the same request id was already admitted, so the press waits for the visible sign
326
+ // of a turn (running, or a newer lastTurn) before it calls this item restored.
327
+ const started = await waitForTurn(sessionId, baseline, options.settleMs)
328
+ if (started === true) return { status: 'restored', detail: mounted ? 'continued' : 'resumed and continued', continued: true }
329
+ return { status: 'attention', detail: 'continuation admitted, no turn started', continued: false }
330
+ }
331
+
332
+ /** Bounded poll for the visible sign that an admitted continuation actually opened a turn. */
333
+ async function waitForTurn(sessionId, baseline, settle) {
334
+ if (settle === 0) return false
335
+ const deadline = Date.now() + (Number.isFinite(settle) ? settle : 1200)
336
+ for (;;) {
337
+ const agent = agentOf(services, sessionId)
338
+ if (agent !== undefined) {
339
+ if (agent.status === 'running') return true
340
+ const turn = lastTurnOf(agent)
341
+ if (baseline !== null && turn !== null && turn > baseline) return true
342
+ }
343
+ if (Date.now() >= deadline) return false
344
+ await new Promise((resolve) => setTimeout(resolve, 60))
345
+ }
288
346
  }
289
347
 
290
348
  /** Re-arm one paused goal. The saved revision is the person's veto: a changed goal is not ours. */
@@ -298,29 +356,47 @@ export function createProbe(services, options = {}) {
298
356
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
299
357
  }
300
358
  }
301
- const goals = service(services, 'goals')
302
- if (goals === undefined || goals === null || typeof goals.get !== 'function') return { status: 'skipped', detail: 'goals unavailable' }
303
- let view
304
- try {
305
- view = goals.get(agent)
306
- } catch (error) {
307
- return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
359
+ const services_goals = service(services, 'goals')
360
+ if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
361
+ return { status: 'failed', detail: 'goals unavailable' }
362
+ }
363
+ const view = goalView(services, agent)
364
+ if (view === undefined || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
365
+ return { status: 'skipped', detail: 'goal is gone' }
366
+ }
367
+ // An active goal is normally not ours to touch. The one exception is a goal this same plan
368
+ // armed in an earlier process: activation is per-process, so a restart leaves it `disarmed` at
369
+ // exactly the revision the plan recorded. Another person's goal has no such record.
370
+ if (view.phase === 'active') {
371
+ if (view.activation === 'armed') return { status: 'skipped', detail: 'already active' }
372
+ if (Number.isSafeInteger(goal.armedRevision) && goal.armedRevision === view.revision) {
373
+ try {
374
+ services_goals.resume(agent, { id: view.id, revision: view.revision })
375
+ } catch (error) {
376
+ return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
377
+ }
378
+ const after = goalView(services, agent)
379
+ return { status: 'restored', detail: 're-armed after restart', revision: after?.revision ?? null }
380
+ }
381
+ return { status: 'skipped', detail: 'goal disarmed' }
308
382
  }
309
- if (view === undefined || view === null) return { status: 'skipped', detail: 'goal cleared' }
310
- if (view.id !== goal.goalId) return { status: 'skipped', detail: 'another goal' }
311
- if (view.phase === 'active' && view.activation === 'armed') return { status: 'restored', detail: 'already active' }
312
- if (view.phase === 'complete' || view.phase === 'blocked') return { status: 'attention', detail: `goal is ${view.phase}` }
313
- // The plan stores the revision observed after `pause`; older plans called it `pausedRevision`.
314
- const expected = Number.isSafeInteger(goal.pausedRevision) ? goal.pausedRevision : goal.revision
315
- if (view.phase === 'paused' && Number.isSafeInteger(expected) && view.revision !== expected) {
316
- return { status: 'skipped', detail: 'goal changed by hand' }
383
+ if (view.phase !== 'paused') return { status: 'skipped', detail: `goal is ${view.phase}` }
384
+ // The saved revision is the person's veto: a goal edited after the pause is not ours to resume.
385
+ if (Number.isSafeInteger(goal.revision) && view.revision !== goal.revision) {
386
+ return { status: 'skipped', detail: 'goal changed' }
317
387
  }
388
+ // The plan stores the revision observed after `pause`; 0.3 plans keep it in `revision`, 0.2
389
+ // plans called it `pausedRevision`.
390
+ const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
391
+ if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
392
+ if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
318
393
  try {
319
- goals.resume(agent, { id: view.id, revision: view.revision })
394
+ services_goals.resume(agent, { id: view.id, revision: view.revision })
320
395
  } catch (error) {
321
396
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
322
397
  }
323
- return { status: 'restored', detail: 'armed' }
398
+ const after = goalView(services, agent)
399
+ return { status: 'restored', detail: 'armed', revision: after?.revision ?? null }
324
400
  }
325
401
 
326
402
  /**
@@ -333,10 +409,10 @@ export function createProbe(services, options = {}) {
333
409
  const rows = Array.isArray(residue.sessions) ? residue.sessions : []
334
410
  const sessions = rows
335
411
  .filter((row) => typeof row?.sessionId === 'string')
336
- .map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null, crash: true }))
412
+ .map((row) => ({ sessionId: row.sessionId, intent: 'mount', epoch: 0, running: false, lastTurn: null, crash: true }))
337
413
  if (sessions.length === 0) return null
338
414
  return {
339
- version: 2, // ui-copy-ignore
415
+ version: 3, // ui-copy-ignore
340
416
  planId: planId ?? `crash-${Date.now().toString(36)}`,
341
417
  status: 'paused', // ui-copy-ignore
342
418
  source: 'crash', // ui-copy-ignore
@@ -344,6 +420,7 @@ export function createProbe(services, options = {}) {
344
420
  sessions,
345
421
  goals: [], // ui-copy-ignore
346
422
  jobs: [], // ui-copy-ignore
423
+ attention: [], // ui-copy-ignore
347
424
  crashes: sessions.length, // ui-copy-ignore
348
425
  }
349
426
  }
package/lib/snapshot.js CHANGED
@@ -2,16 +2,24 @@ import * as fs from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
4
  /**
5
- * The durable half of the pause button: one plan file, a per-plan restore journal, one archive per
6
- * consumed plan, and the last restore record.
5
+ * The durable half of the pause button: the active plan, the crash plan a restart inherited, one
6
+ * restore journal, one archive per consumed plan, and the last restore record.
7
7
  *
8
- * Every write is temp file + fsync + rename + directory fsync, so a reader never sees half a plan
9
- * and a crash leaves the previous complete file in place. Nothing here throws: a disk that cannot
10
- * be written answers `null`, and the caller decides whether to stay in its current phase instead
11
- * of claiming a state it could not record.
8
+ * Every write is temp file + fsync + rename + best-effort directory fsync, so a reader never sees
9
+ * half a plan and a crash leaves the previous complete file in place. A directory fsync that the
10
+ * platform refuses (EPERM/ENOTSUP on some network and sandboxed filesystems) does not undo a
11
+ * rename that already happened: the record is published, only its power-loss guarantee is weaker.
12
+ * Nothing here throws: a disk that cannot be written answers `false`/`null`, and the caller
13
+ * decides whether to stay in its current phase instead of claiming a state it could not record.
14
+ *
15
+ * Formats read: version 3 (this release), version 2 (0.2.x), version 1 (0.1.x). A version 1 plan is
16
+ * migrated conservatively: it has no turn baseline, so its sessions are mounted and never sent a
17
+ * continuation message, and its goal rows carry no revision, so they are reported, not resumed.
12
18
  */
13
19
 
14
20
  const UNSAFE = /[^A-Za-z0-9._-]/gu
21
+ const CURRENT_VERSION = 3
22
+ const DIR_SYNC_CODES = new Set(['EPERM', 'EACCES', 'ENOTSUP', 'EINVAL', 'ENOSYS', 'EBADF', 'EISDIR'])
15
23
 
16
24
  let counter = 0
17
25
 
@@ -32,6 +40,7 @@ export function createStore(dir, options = {}) {
32
40
  const log = typeof options.log === 'function' ? options.log : () => {}
33
41
 
34
42
  const snapshotPath = join(dir, 'snapshot.json')
43
+ const pendingPath = join(dir, 'pending.json')
35
44
  const journalDir = join(dir, 'journal')
36
45
  const restoresDir = join(dir, 'restores')
37
46
  const lastRestorePath = join(dir, 'last-restore.json')
@@ -40,15 +49,20 @@ export function createStore(dir, options = {}) {
40
49
 
41
50
  /** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
42
51
  function syncDir(path) {
52
+ if (process.platform === 'win32') return // ui-copy-ignore
43
53
  let fd = null
44
54
  try {
45
55
  fd = fsv.openSync(path, 'r')
46
56
  fsv.fsyncSync(fd)
57
+ } catch (error) {
58
+ const code = error?.code
59
+ if (DIR_SYNC_CODES.has(code) === false) log(`cannot flush ${path}: ${error?.message ?? error}`)
47
60
  } finally {
48
61
  if (fd !== null) closeQuietly(fsv, fd)
49
62
  }
50
63
  }
51
64
 
65
+ /** `published` is the truth: the rename landed, so readers see the new record. */
52
66
  function atomicWrite(target, value) {
53
67
  let body = null
54
68
  try {
@@ -85,29 +99,124 @@ export function createStore(dir, options = {}) {
85
99
  }
86
100
  }
87
101
 
88
- function read() {
102
+ function parse(path) {
89
103
  let parsed = null
90
104
  try {
91
- parsed = JSON.parse(fsv.readFileSync(snapshotPath, 'utf8'))
105
+ parsed = JSON.parse(fsv.readFileSync(path, 'utf8'))
92
106
  } catch {
93
107
  return null
94
108
  }
95
109
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
96
- if (parsed.version !== 2) return null
97
- if (parsed.status !== 'pausing' && parsed.status !== 'paused') return null
98
110
  return parsed
99
111
  }
100
112
 
113
+ /**
114
+ * A version 1 plan predates the turn baseline and the goal revision. Mounting is safe; claiming
115
+ * that a turn was interrupted is not, and resuming a goal without its revision is not either.
116
+ */
117
+ function migrateLegacy(legacy, stamp) {
118
+ const sessions = (Array.isArray(legacy.sessions) ? legacy.sessions : [])
119
+ .filter((row) => typeof row?.sessionId === 'string')
120
+ .map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null }))
121
+ const goals = (Array.isArray(legacy.goals) ? legacy.goals : [])
122
+ .filter((row) => typeof row?.sessionId === 'string')
123
+ .map((row) => ({ sessionId: row.sessionId, goalId: typeof row.goalId === 'string' ? row.goalId : null, revision: null }))
124
+ const jobs = (Array.isArray(legacy.jobs) ? legacy.jobs : []).map((row) => ({ id: String(row?.id ?? ''), owner: row?.owner ?? null }))
125
+ return {
126
+ planId: `legacy-${stamp}`, // ui-copy-ignore
127
+ status: 'paused', // ui-copy-ignore
128
+ source: 'legacy', // ui-copy-ignore
129
+ legacy: true, // ui-copy-ignore
130
+ createdAt: legacy.createdAt ?? stamp,
131
+ sessions,
132
+ goals,
133
+ jobs,
134
+ attention: ['legacy plan: sessions are mounted only; goals need a new pause'],
135
+ }
136
+ }
137
+
138
+ function normalize(parsed, stamp = now()) {
139
+ if (parsed === null) return null
140
+ if (parsed.version === 1) return migrateLegacy(parsed, typeof parsed.createdAt === 'number' ? parsed.createdAt : stamp)
141
+ if (parsed.version !== 2 && parsed.version !== CURRENT_VERSION) return null
142
+ if (parsed.status !== 'pausing' && parsed.status !== 'paused' && parsed.status !== 'partial') return null
143
+ const plan = { ...parsed, version: CURRENT_VERSION }
144
+ if (typeof plan.planId !== 'string' || plan.planId === '') plan.planId = `plan-${plan.createdAt ?? stamp}`
145
+ if (Array.isArray(plan.attention) === false) plan.attention = []
146
+ if (plan.status === 'pausing') plan.interrupted = true
147
+ // `running` used to mean two different things: "this press stopped a turn here" (continue on
148
+ // resume) and "this session is merely in the plan" (mount only). Version 3 records the intent
149
+ // explicitly so a crash-only row can never turn into a continuation by accident.
150
+ plan.sessions = (Array.isArray(plan.sessions) ? plan.sessions : []).map((row) => {
151
+ if (typeof row?.sessionId !== 'string') return row
152
+ const intent = row.intent === 'continue' || row.intent === 'mount'
153
+ ? row.intent
154
+ : (row.running === true ? 'continue' : 'mount')
155
+ const epoch = Number.isSafeInteger(row.epoch) && row.epoch > 0
156
+ ? row.epoch
157
+ : (intent === 'continue' ? 1 : 0)
158
+ return { ...row, intent, epoch, running: intent === 'continue' }
159
+ })
160
+ plan.goals = (Array.isArray(plan.goals) ? plan.goals : []).map((row) => (
161
+ row !== null && typeof row === 'object' && Number.isSafeInteger(row.armedRevision) === false
162
+ ? { ...row, armedRevision: null }
163
+ : row
164
+ ))
165
+ return plan
166
+ }
167
+
168
+ function readFrom(path) {
169
+ const parsed = parse(path)
170
+ if (parsed === null) return null
171
+ // A version 1 plan has no plan id; the file stamp keeps one journal per legacy press.
172
+ let stamp = now()
173
+ try {
174
+ stamp = Math.trunc(fsv.statSync(path).mtimeMs)
175
+ } catch {}
176
+ return normalize(parsed, stamp)
177
+ }
178
+
179
+ function read() {
180
+ return readFrom(snapshotPath)
181
+ }
182
+
101
183
  function write(plan) {
102
184
  if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
103
- const record = { ...plan, version: 2 }
185
+ const record = { ...plan, version: CURRENT_VERSION }
104
186
  return atomicWrite(snapshotPath, record) ? record : null
105
187
  }
106
188
 
107
- function appendJournal(planId, key) {
189
+ /** The crash plan survives this process: it is written before the heartbeat overwrites its file. */
190
+ function readPending() {
191
+ return readFrom(pendingPath)
192
+ }
193
+
194
+ function writePending(plan) {
195
+ if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
196
+ const record = { ...plan, version: CURRENT_VERSION }
197
+ return atomicWrite(pendingPath, record) ? record : null
198
+ }
199
+
200
+ function clearPending() {
201
+ try {
202
+ fsv.rmSync(pendingPath, { force: true })
203
+ syncDir(dir)
204
+ return true
205
+ } catch (error) {
206
+ log(`cannot clear pending plan: ${error?.message ?? error}`)
207
+ return false
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Journals carry the boot that wrote each row. A row from an earlier process is evidence for the
213
+ * summary, never permission to skip work: the only things a new process can trust are the live
214
+ * Harness and the plan itself.
215
+ */
216
+ function appendJournal(planId, key, status, bootId) {
108
217
  let line = null
109
218
  try {
110
- line = JSON.stringify({ k: key })
219
+ line = JSON.stringify({ k: key, s: status ?? 'ok', b: bootId ?? null, at: now() })
111
220
  } catch {
112
221
  return false
113
222
  }
@@ -127,23 +236,23 @@ export function createStore(dir, options = {}) {
127
236
  }
128
237
 
129
238
  function readJournal(planId) {
130
- const done = new Set()
239
+ const rows = new Map()
131
240
  let raw = ''
132
241
  try {
133
242
  raw = fsv.readFileSync(journalPath(planId), 'utf8')
134
243
  } catch {
135
- return done
244
+ return rows
136
245
  }
137
246
  for (const line of raw.split('\n')) {
138
247
  if (line === '') continue
139
248
  try {
140
249
  const row = JSON.parse(line)
141
- if (typeof row?.k === 'string') done.add(row.k)
250
+ if (typeof row?.k === 'string') rows.set(row.k, { status: typeof row.s === 'string' ? row.s : 'ok', boot: typeof row.b === 'string' ? row.b : null })
142
251
  } catch {
143
252
  // A torn last line is a write that never finished, not a key that was restored.
144
253
  }
145
254
  }
146
- return done
255
+ return rows
147
256
  }
148
257
 
149
258
  function clearJournal(planId) {
@@ -152,47 +261,78 @@ export function createStore(dir, options = {}) {
152
261
  } catch {}
153
262
  }
154
263
 
264
+ function writeResult(record) {
265
+ return atomicWrite(lastRestorePath, record)
266
+ }
267
+
155
268
  /**
156
269
  * The plan is spent: archive it, publish the record the UI reads, and only then remove it. A plan
157
- * that could not be archived stays on disk, so the same work is never claimed twice.
270
+ * that could not be archived stays on disk, so the same work is never claimed twice. A snapshot
271
+ * that could not be removed is reported as a failure even though the archive landed: the next
272
+ * reader would otherwise see a plan that is already restored and try to restore it again.
158
273
  */
159
274
  function consume(plan, summary) {
160
275
  const source = plan !== null && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
161
276
  const archivePath = join(restoresDir, `${sanitize(source.planId)}.json`)
162
277
  const record = { ...source, status: 'restored', restoredAt: new Date(now()).toISOString(), summary }
163
278
  const archived = atomicWrite(archivePath, record)
164
- const published = archived && atomicWrite(lastRestorePath, record)
279
+ const published = archived && writeResult(record)
165
280
  if (published === false) {
166
281
  log('cannot archive the restored plan: the snapshot stays')
167
- return { ok: false, archivePath: null }
282
+ return { ok: false, archivePath: null, published: false }
168
283
  }
169
284
  const current = read()
170
285
  // Another press may have written a newer plan while this one restored; that one is not ours.
171
- if (current !== null && current.planId === source.planId) {
286
+ if (current === null || current.planId === source.planId) {
172
287
  try {
173
288
  fsv.rmSync(snapshotPath, { force: true })
174
- } catch {}
289
+ syncDir(dir)
290
+ } catch (error) {
291
+ log(`cannot clear the restored plan: ${error?.message ?? error}`)
292
+ return { ok: false, archivePath, published: true }
293
+ }
175
294
  }
176
295
  clearJournal(source.planId)
177
- return { ok: true, archivePath }
296
+ return { ok: true, archivePath, published: true }
178
297
  }
179
298
 
180
299
  function lastRestore() {
300
+ const parsed = parse(lastRestorePath)
301
+ return parsed
302
+ }
303
+
304
+ function hasSnapshot() {
181
305
  try {
182
- const parsed = JSON.parse(fsv.readFileSync(lastRestorePath, 'utf8'))
183
- return parsed !== null && typeof parsed === 'object' ? parsed : null
306
+ return fsv.existsSync(snapshotPath) === true
184
307
  } catch {
185
- return null
308
+ return false
186
309
  }
187
310
  }
188
311
 
189
- function hasSnapshot() {
312
+ function hasPending() {
190
313
  try {
191
- return fsv.existsSync(snapshotPath) === true
314
+ return fsv.existsSync(pendingPath) === true
192
315
  } catch {
193
316
  return false
194
317
  }
195
318
  }
196
319
 
197
- return { dir, snapshotPath, read, write, appendJournal, readJournal, clearJournal, consume, lastRestore, hasSnapshot }
320
+ return {
321
+ dir,
322
+ snapshotPath,
323
+ pendingPath,
324
+ read,
325
+ write,
326
+ readPending,
327
+ writePending,
328
+ clearPending,
329
+ appendJournal,
330
+ readJournal,
331
+ clearJournal,
332
+ writeResult,
333
+ consume,
334
+ lastRestore,
335
+ hasSnapshot,
336
+ hasPending,
337
+ }
198
338
  }