dsh-pause-resume 0.4.3 → 0.4.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.5
4
+
5
+ A restore now leaves a durable record before every side effect, so a restart can finish it.
6
+
7
+ * Before `goals.resume`, the plan records `pendingRestore` and writes it to disk. If the process dies
8
+ after the goal was armed but before the checkpoint was saved, the next boot sees the active,
9
+ disarmed goal and the intent that proves it is ours to re-arm. A goal is no longer reported as
10
+ `skipped` with an empty attention list after such a restart.
11
+ * A goal whose state cannot be read is a retryable failure (`goal unreadable: …`) instead of being
12
+ treated as absent and consumed. Only a readable paused goal is resumed, and an unreadable goal is
13
+ never counted as work the pause can stop.
14
+ * A crash plan on boot sets `phase` to `idle` and offers `resume`; it no longer claims the page is
15
+ paused. The dot still names the crash work through attention.
16
+ * A pause plan that ended up in the crash slot can complete its restore, clear the slot and release
17
+ the heartbeat. It no longer leaves the button in `resuming`/`busy`.
18
+ * Restore attention survives the consumed plan and is shown with a focused item id even when no work
19
+ is live. Killed jobs stay excluded from the dot.
20
+ * A new pause bumps the continuation epoch only for the sessions it captured; rows owned by another
21
+ plan keep their request id.
22
+ * A reload during a cold mount stops the old probe before it reports or prompts, so stale side
23
+ effects from a replaced instance cannot reach a session.
24
+ * A full restore journals each item only after its outcome, and the plan is consumed only when every
25
+ item settled.
26
+
27
+ ## 0.4.4
28
+
29
+ Restores are now safe to hand over between instances and across an upgrade.
30
+
31
+ * An interrupted pause resets the whole restore policy (`intent: "mount"`, `epoch: 0`), so it is
32
+ mounted and never sends a continuation.
33
+ * When a merged crash plan could not be written to disk, resume restores that in-memory plan, which
34
+ holds both the old and the newly found sessions. The older `pending.json` no longer wins and hides
35
+ half the work.
36
+ * `heartbeat.release()` restores a full lifecycle: the periodic sample is re-armed, `stopped` is
37
+ cleared, and state events can persist again. Previously the file stayed frozen after a repaired
38
+ write failure.
39
+ * A pre-epoch plan keeps its original continuation request id (`…:continue:<session>`), so upgrading
40
+ cannot queue a second copy of a message the old version already admitted.
41
+ * A restore that loses ownership (plugin reload) stops before publishing, consuming or clearing. It
42
+ also compares the plan's actual work, not only its id, so additions from a newer press survive.
43
+ * A goal whose checkpoint cannot be written keeps the plan on disk with the failure named, instead of
44
+ silently skipping the goal on the next boot.
45
+ * The button hint combines the error with the focused item for partial pause/restore.
46
+
3
47
  ## 0.4.3
4
48
 
5
49
  - The dot now means one thing: something is waiting on you (a paused plan with commands that will not restart, or a stop/restore that failed). A clean idle page has no dot, and killed jobs live in the restore record instead of becoming a permanent red light.
@@ -20,9 +64,9 @@ A stop that could not finish no longer traps the button, and the dot stops lying
20
64
 
21
65
  The crash path is now a first-class restore path, and a dead button after a restart is fixed.
22
66
 
23
- * Routes are registered synchronously while `apply` runs. The web server freezes its route table
24
- when it starts, so registering after `hydrate()` left the button answering 404 on a fresh boot.
25
- Handlers now wait for hydration instead of the route table waiting for it.
67
+ * Routes are registered synchronously while `apply` runs. Registration itself is not the problem —
68
+ the server keeps a live route table — but registering after hydration meant the button could be
69
+ pressed before the table had an entry. Handlers now wait for hydration instead.
26
70
  * A crash plan is written to `pending.json` before the heartbeat is replaced. If that write fails,
27
71
  the heartbeat is held and the previous `running.json` is left untouched, so the next boot still
28
72
  sees the work.
package/README.md CHANGED
@@ -25,7 +25,9 @@ last press left something for a person to look at.
25
25
 
26
26
  Schedules are not paused: the schedule service has no disable operation, only delete, and deleting a
27
27
  person's schedule to pause it is not a trade this plugin makes. A schedule that fires while paused
28
- starts a turn; the button then shows ⏸ again, truthfully.
28
+ starts a turn, but the button keeps showing ▶ while a pause plan waits: a pending plan owns the main
29
+ action, so the only honest next step is to finish the restore. The schedule appears in that restore
30
+ summary like any other work started after the press.
29
31
 
30
32
  ## How resume works
31
33
 
package/client.js CHANGED
@@ -140,8 +140,9 @@ window.__ModuleLoader__.load({
140
140
 
141
141
  /** The one sentence behind the dot, in the tooltip only: the button itself stays a glyph. */
142
142
  function hintOf(state) {
143
- if (state.error === 'restore-partial') return COPY.partial
144
- if (state.error !== null && state.error !== undefined) return COPY.error
143
+ const focus = typeof state.focus === 'string' && state.focus !== '' ? state.focus : ''
144
+ if (state.error === 'restore-partial') return focus === '' ? COPY.partial : `${COPY.partial}: ${focus}`
145
+ if (state.error !== null && state.error !== undefined) return focus === '' ? COPY.error : `${COPY.error}: ${focus}`
145
146
  if ((state.attention ?? 0) > 0) {
146
147
  return typeof state.focus === 'string' && state.focus !== '' ? `${COPY.attention}: ${state.focus}` : COPY.attention
147
148
  }
package/index.js CHANGED
@@ -2,12 +2,12 @@ import { homedir } from 'node:os'
2
2
  import { join } from 'node:path'
3
3
  import { createStore } from './lib/snapshot.js'
4
4
  import { createEngine } from './lib/engine.js'
5
- import { nextEpoch, dropEpoch } from './lib/epoch.js'
5
+ import { nextEpoch, currentEpoch, dropEpoch } from './lib/epoch.js'
6
6
  import { createHeartbeat } from './lib/heartbeat.js'
7
7
  import { createProbe, listRunning } from './lib/probe.js'
8
8
 
9
9
  export const name = 'dsh-pause-resume'
10
- export const VERSION = '0.4.3'
10
+ export const VERSION = '0.4.5'
11
11
 
12
12
  const BASE_PATH = '/dsh-pause-resume'
13
13
  const BUTTON_HEADER = 'x-dsh-pause-resume'
@@ -135,8 +135,8 @@ function serve(ctx, engine, heartbeat, options, ready) {
135
135
  if (read === true ? sameOrigin(req, allowRemote) === false : buttonAllowed(req, allowRemote) === false) {
136
136
  return sendJson(res, 403, { status: 'forbidden' })
137
137
  }
138
- // Routes are registered synchronously so the server freezes them before it starts. Hydration
139
- // may still be reading crash residue, so every press waits for it instead of acting blind.
138
+ // Routes are registered synchronously so no press can arrive before the table has them.
139
+ // Hydration may still be reading crash residue, so every press waits for it instead of acting blind.
140
140
  if (ready !== undefined) await ready
141
141
  if (disposed === true) return sendJson(res, 503, { status: 'starting' })
142
142
  return handler(req, res)
@@ -145,7 +145,7 @@ function serve(ctx, engine, heartbeat, options, ready) {
145
145
  [
146
146
  `${BASE_PATH}/state`,
147
147
  route(async (req, res) => {
148
- sendJson(res, 200, engine.describe())
148
+ sendJson(res, 200, { ...engine.describe(), version: VERSION })
149
149
  }, true),
150
150
  ],
151
151
  [
@@ -154,7 +154,7 @@ function serve(ctx, engine, heartbeat, options, ready) {
154
154
  const outcome = await engine.pause()
155
155
  log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
156
156
  if (outcome.status === 'paused' || outcome.status === 'partial') heartbeat.release()
157
- sendJson(res, 200, { ...engine.describe(), outcome })
157
+ sendJson(res, 200, { ...engine.describe(), outcome, version: VERSION })
158
158
  }, false),
159
159
  ],
160
160
  [
@@ -162,7 +162,7 @@ function serve(ctx, engine, heartbeat, options, ready) {
162
162
  route(async (req, res) => {
163
163
  const outcome = await engine.resume()
164
164
  log(`resume: ${outcome.status}`)
165
- sendJson(res, 200, { ...engine.describe(), outcome })
165
+ sendJson(res, 200, { ...engine.describe(), outcome, version: VERSION })
166
166
  }, false),
167
167
  ],
168
168
  ]
@@ -216,6 +216,7 @@ export function apply(ctx, config = {}) {
216
216
  log,
217
217
  })
218
218
  const probe = createProbe(ctx, {
219
+ isCurrent: () => currentEpoch() === epoch,
219
220
  graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
220
221
  continueText: typeof config?.continueText === 'string' ? config.continueText : undefined,
221
222
  })
@@ -275,8 +276,8 @@ export function apply(ctx, config = {}) {
275
276
  log(`cannot follow agent events: ${error?.message ?? error}`)
276
277
  }
277
278
 
278
- // The server freezes its route table when it starts, so routes must be registered while `apply`
279
- // is still synchronous. Hydration is asynchronous; the handlers wait on this promise.
279
+ // Routes must be registered while `apply` is still synchronous, so a press can never arrive
280
+ // before the table has an entry. Hydration is asynchronous; the handlers wait on this promise.
280
281
  let releaseReady = () => {}
281
282
  const ready = new Promise((resolve) => {
282
283
  releaseReady = resolve
package/lib/engine.js CHANGED
@@ -28,6 +28,18 @@ function emptyLive() {
28
28
  return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
29
29
  }
30
30
 
31
+ /** One id for the hint: a restore attention row is either a label or a plan row. */
32
+ function labelFocus(text) {
33
+ const match = /^(?:session|goal|job) ([^:]+):/.exec(String(text))
34
+ return match !== null ? match[1] : null
35
+ }
36
+
37
+ function attentionFocus(row) {
38
+ if (row === null || row === undefined) return null
39
+ if (typeof row === 'string') return labelFocus(row) ?? row.slice(0, 60) // ui-copy-ignore
40
+ return row.id || row.detail || null
41
+ }
42
+
31
43
  /** One row per item: a retried stop must not multiply the same unfinished job in the dot. */
32
44
  function dedupeRows(rows) {
33
45
  const seen = new Set()
@@ -51,8 +63,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
51
63
  let result = null
52
64
  /** Set while a press runs, so a second caller joins the answer instead of starting work again. */
53
65
  let inflight = null
54
- /** Crash plan kept in memory only when its durable write failed; a reload loses it, the file does not. */
55
- let crashFallback = null
66
+ /**
67
+ * The complete plan kept in memory when its durable write failed. It can be a crash plan or a
68
+ * normal pause plan whose crash residue could not be merged; in both cases the file on disk is
69
+ * only an older subset and must not decide what this press restores.
70
+ */
71
+ let planFallback = null
56
72
  let counter = 0
57
73
 
58
74
  function newPlanId(kind) {
@@ -65,13 +81,45 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
65
81
  }
66
82
 
67
83
  function crashPlan() {
68
- const stored = typeof store.readPending === 'function' ? store.readPending() : null
69
- return stored ?? crashFallback
84
+ return typeof store.readPending === 'function' ? store.readPending() : null
70
85
  }
71
86
 
72
- /** Exactly one plan is pending, and a finished pause outranks a leftover crash record. */
87
+ /**
88
+ * Exactly one plan is pending, and a finished pause outranks a leftover crash record. The
89
+ * in-memory fallback outranks both files: when a write failed it holds the complete work, while
90
+ * the older file would hide the half that was never saved.
91
+ */
73
92
  function currentPlan() {
74
- return pausedPlan() ?? crashPlan()
93
+ return planFallback ?? pausedPlan() ?? crashPlan()
94
+ }
95
+
96
+ /** Identity and content of one plan, used to find which store actually holds it. */
97
+ function samePlan(left, right) {
98
+ return left !== null && right !== null && left.planId === right.planId && planKey(left) === planKey(right)
99
+ }
100
+
101
+ /** The work a plan asks for, without timestamps or reporting fields. */
102
+ function planKey(plan) {
103
+ const row = (item) => [item?.sessionId ?? '', item?.intent ?? (item?.running === true ? 'continue' : 'mount'), item?.goalId ?? '']
104
+ return JSON.stringify({
105
+ sessions: (plan?.sessions ?? []).map(row),
106
+ goals: (plan?.goals ?? []).map((goal) => [goal?.sessionId ?? '', goal?.goalId ?? '']),
107
+ jobs: (plan?.jobs ?? []).map((job) => job?.id ?? ''),
108
+ })
109
+ }
110
+
111
+ /**
112
+ * A plan is only ours to spend if the durable copy still asks for exactly the same work. A
113
+ * merged plan keeps the planId, so an id comparison alone would delete another press's additions.
114
+ */
115
+ function stillMine(plan) {
116
+ if (isCurrent() === false) return false
117
+ // When the merge could not be written, this in-memory plan is the complete set and the file
118
+ // is only its older subset. The epoch check above already proved no newer instance wrote.
119
+ if (planFallback === plan) return true
120
+ // A plan can live in either file: a crash plan converted to a pause plan may have fallen back
121
+ // to pending.json when snapshot.json could not be written. Find it by content, not by source.
122
+ return samePlan(pausedPlan(), plan) || samePlan(crashPlan(), plan)
75
123
  }
76
124
 
77
125
  function isCrashPlan(plan) {
@@ -141,6 +189,16 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
141
189
  return work.sessions.length + work.goals.length + work.jobs.length
142
190
  }
143
191
 
192
+ /** The item a partial restore could not finish, so the tooltip can name it. */
193
+ function failureFocus(summary) {
194
+ for (const line of Array.isArray(summary?.failed) ? summary.failed : []) {
195
+ const match = /^(?:session|goal) ([^:]+):/.exec(String(line))
196
+ if (match !== null) return match[1]
197
+ if (typeof line === 'string' && line !== '') return line.slice(0, 60) // ui-copy-ignore
198
+ }
199
+ return null
200
+ }
201
+
144
202
  /**
145
203
  * What the button shows. `action` is the one decision the client may not make itself:
146
204
  * `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
@@ -159,14 +217,17 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
159
217
  if (busy) action = 'busy'
160
218
  else if (plan !== null) action = 'resume'
161
219
  else if (current.readable === true && workCount > 0) action = 'pause'
162
- // The dot means "something still needs you". A clean idle page has nothing to point at: the
163
- // jobs a pause killed stay in the last-restore record, but they are not an open obligation.
164
- const attentionRows = plan !== null
165
- ? dedupeRows(Array.isArray(plan.attention) ? plan.attention : [])
166
- : phase === 'idle' && lastError === null ? null : result?.attention
167
- const attention = Array.isArray(attentionRows) ? attentionRows.length : 0
220
+ // The dot means "something still needs you". Stopped jobs are reported separately and must not
221
+ // turn a clean idle page into a permanent dot, but restore attention also covers "the message
222
+ // was admitted and no turn started" and "the turn cannot be proven finished": those are open
223
+ // obligations even when no plan is left, so they are always shown.
224
+ const attentionRows = dedupeRows([
225
+ ...(Array.isArray(plan?.attention) ? plan.attention : []),
226
+ ...(Array.isArray(result?.attention) ? result.attention : []),
227
+ ])
228
+ const attention = attentionRows.length
168
229
  const first = attention > 0 ? attentionRows[0] : null
169
- const focus = first === null ? null : (typeof first === 'string' ? first : (first.id || first.detail || null))
230
+ const focus = first !== null ? attentionFocus(first) : failureFocus(result)
170
231
  return {
171
232
  phase,
172
233
  action,
@@ -245,17 +306,20 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
245
306
  return out
246
307
  }
247
308
 
309
+ /** Which durable file currently holds this plan; a converted pause plan may still live in pending. */
310
+ function usesPending(plan) {
311
+ if (isCrashPlan(plan) === true) return true
312
+ return samePlan(crashPlan(), plan) && !samePlan(pausedPlan(), plan)
313
+ }
314
+
248
315
  function persist(plan) {
249
316
  if (isCurrent() === false) {
250
317
  log('stale instance: refusing to write over a newer plan')
251
318
  return null
252
319
  }
253
- if (isCrashPlan(plan) === true) {
254
- const stored = store.writePending(plan)
255
- if (stored !== null) crashFallback = null
256
- return stored
257
- }
258
- return store.write(plan)
320
+ const stored = usesPending(plan) ? store.writePending(plan) : store.write(plan)
321
+ if (stored !== null) planFallback = null
322
+ return stored
259
323
  }
260
324
 
261
325
  /**
@@ -292,8 +356,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
292
356
  const before = new Map((basePlan?.sessions ?? []).filter((row) => typeof row?.sessionId === 'string').map((row) => [row.sessionId, row]))
293
357
  const restoredBefore = Number.isSafeInteger(basePlan?.restore?.attempts) && basePlan.restore.attempts > 0
294
358
  plan.sessions = unionSessions(plan.sessions ?? [], work.sessions.map(freshSession))
359
+ // Only rows this press actually captured may get a new cycle. A row left from an earlier
360
+ // restore attempt belongs to a different pause: incrementing it would change its request id
361
+ // and admit a second continuation while the first is still sitting in the queue.
362
+ const captured = new Set(work.sessions.map((session) => session.sessionId))
295
363
  plan.sessions = plan.sessions.map((row) => {
296
- if (row.intent !== 'continue') return row
364
+ if (row.intent !== 'continue' || captured.has(row.sessionId) === false) return row
297
365
  const prior = before.get(row.sessionId)
298
366
  const priorEpoch = Number.isSafeInteger(prior?.epoch) ? prior.epoch : 0
299
367
  if (Number.isSafeInteger(row.epoch) && row.epoch > priorEpoch) return row
@@ -373,16 +441,21 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
373
441
  // The crash record described work that was not paused. This press did pause it, so the plan
374
442
  // becomes a normal pause plan and the crash evidence is no longer a separate obligation.
375
443
  plan.source = 'pause' // ui-copy-ignore
376
- if (store.write(plan) === null) {
377
- const kept = store.writePending(plan)
444
+ // The two files are a migration, not a plain write: if snapshot.json cannot be written, the
445
+ // pending fallback is the only durable copy. Removing it after a failed snapshot write would
446
+ // discard the plan, and reading only snapshot.json would then call this plan a stranger.
447
+ if (store.write(plan) !== null) {
448
+ planFallback = null
449
+ if (typeof store.clearPending === 'function' && store.clearPending() === false) lastError = 'store'
450
+ } else {
451
+ const kept = typeof store.writePending === 'function' ? store.writePending(plan) : null
378
452
  if (kept === null) {
379
- crashFallback = plan
453
+ planFallback = plan
380
454
  lastError = 'store'
455
+ } else {
456
+ planFallback = null
381
457
  }
382
- } else if (typeof store.clearPending === 'function') {
383
- if (store.clearPending() === false) lastError = 'store'
384
458
  }
385
- crashFallback = null
386
459
  } else if (store.write(plan) === null) {
387
460
  // The durable copy still says `pausing` or `paused`: the next boot reports it instead of
388
461
  // claiming a stop that was never written.
@@ -408,7 +481,11 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
408
481
  if (plan.status === 'pausing') {
409
482
  plan.source = plan.source === 'crash' ? 'crash' : 'interrupted'
410
483
  plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
411
- plan.sessions = (plan.sessions ?? []).map((session) => ({ ...session, running: false, lastTurn: null }))
484
+ // `intent` outranks `running` in the probe, so an interrupted pause must reset the whole
485
+ // policy: mount only, no admission, and no earlier cycle's request identity.
486
+ plan.sessions = (plan.sessions ?? []).map((session) => ({
487
+ ...session, intent: 'mount', running: false, epoch: 0, lastTurn: null,
488
+ }))
412
489
  plan.status = 'paused'
413
490
  if (persist(plan) === null) {
414
491
  phase = 'paused'
@@ -425,7 +502,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
425
502
  log(`cannot journal ${key}`)
426
503
  }
427
504
  }
505
+ const stale = () => isCurrent() === false
428
506
  const step = async (key, label, run) => {
507
+ if (stale()) return { status: 'failed', detail: 'reloaded' }
429
508
  let outcome
430
509
  try {
431
510
  outcome = await run()
@@ -433,6 +512,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
433
512
  outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
434
513
  }
435
514
  const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
515
+ if (status === 'reloaded') return outcome
436
516
  if (TERMINAL.has(status) === false) {
437
517
  summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
438
518
  return outcome
@@ -459,21 +539,64 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
459
539
  // flight turns a long serial restore into one batch without flooding the Harness.
460
540
  await Promise.all(Array.from({ length: Math.min(4, sessionRows.length) }, sessionWorker))
461
541
  for (const goal of plan.goals ?? []) {
542
+ if (stale()) break
462
543
  const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
463
- const outcome = await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
544
+ const label = `goal ${goal.goalId ?? goal.sessionId}`
545
+ const setGoal = (change) => {
546
+ plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
547
+ ? { ...row, ...change }
548
+ : row))
549
+ }
550
+ // Persist the intent before the side effect. A crash or a checkpoint failure after
551
+ // `goals.resume()` leaves an active, disarmed goal on the next boot; without this record the
552
+ // plan cannot prove that goal was its own work and would silently skip it.
553
+ if (goal.pendingRestore === undefined || goal.pendingRestore === null) {
554
+ setGoal({ pendingRestore: { revision: Number.isSafeInteger(goal.revision) ? goal.revision : null, at: now() } }) // ui-copy-ignore
555
+ if (persist(plan) === null) {
556
+ summary.failed.push(`${label}: restore intent not saved`)
557
+ lastError = 'store'
558
+ phase = 'paused'
559
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
560
+ }
561
+ }
562
+ const outcome = await step(key, label, () => probe.restoreGoal(goal, { plan }))
563
+ if (outcome?.status === 'reloaded') break
464
564
  if (outcome?.status === 'restored' && Number.isSafeInteger(outcome.revision)) {
465
565
  // Remember what this plan armed. A restarted process sees `active + disarmed`, and that
466
566
  // exact revision is the only proof the goal is ours to arm again rather than someone else's.
467
- plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
468
- ? { ...row, armedRevision: outcome.revision }
469
- : row))
470
- if (persist(plan) === null) lastError = 'store'
567
+ const row = (plan.goals ?? []).find((item) => item.sessionId === goal.sessionId && item.goalId === goal.goalId)
568
+ setGoal({ armedRevision: outcome.revision, pendingRestore: null })
569
+ if (persist(plan) === null) {
570
+ // Keep the intent on disk so the next boot can still re-arm the goal; returning partial
571
+ // without it was exactly how a successful resume became a silent skip after a restart.
572
+ setGoal({ armedRevision: row?.armedRevision ?? null, pendingRestore: goal.pendingRestore ?? { revision: Number.isSafeInteger(goal.revision) ? goal.revision : null, at: now() } })
573
+ summary.failed.push(`${label}: checkpoint not saved`)
574
+ lastError = 'store'
575
+ phase = 'paused'
576
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
577
+ }
578
+ } else if (outcome?.status === 'restored' || TERMINAL.has(outcome?.status)) {
579
+ // Restored without a revision, or terminal for another reason: the intent is spent.
580
+ setGoal({ pendingRestore: null })
581
+ if (persist(plan) === null) {
582
+ summary.failed.push(`${label}: checkpoint not saved`)
583
+ lastError = 'store'
584
+ phase = 'paused'
585
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
586
+ }
471
587
  }
472
588
  }
473
589
  // A killed job cannot be replayed: the same command may have had an unknown effect, so it is
474
590
  // reported once and left to the person. It never blocks the plan and is never restarted.
475
591
  for (const job of plan.jobs ?? []) summary.jobsStopped.push(`job ${job.id}: stopped, not restarted`)
476
592
 
593
+ if (stale()) {
594
+ // A reload owns the plan now. Publishing a summary or consuming anything here would erase
595
+ // work the new instance captured while these mounts were in flight.
596
+ log('stale instance: restore result discarded')
597
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
598
+ }
599
+
477
600
  result = summary
478
601
  if (summary.failed.length > 0) {
479
602
  plan.restore = { at: summary.at, attempts: (plan.restore?.attempts ?? 0) + 1, failed: summary.failed } // ui-copy-ignore
@@ -483,35 +606,62 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
483
606
  return { status: 'partial', summary, outstanding: outstandingOf(plan) }
484
607
  }
485
608
 
486
- if (isCrashPlan(plan) === true) {
487
- if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
609
+ // A pending copy of the same plan means the migration's cleanup failed after snapshot.json was
610
+ // written. Finish that cleanup before consuming, or the next boot would offer the same restore
611
+ // again from a plan that is already complete.
612
+ if (usesPending(plan) === false && samePlan(crashPlan(), plan) && typeof store.clearPending === 'function') {
613
+ if (store.clearPending() === false) {
488
614
  lastError = 'store'
489
615
  phase = 'paused'
616
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
617
+ }
618
+ }
619
+ // A plan may live in pending.json even after it became a normal pause plan (the snapshot
620
+ // write failed during the crash-to-pause migration). Completion follows the file that holds it.
621
+ if (usesPending(plan)) {
622
+ if (stillMine(plan) === false) {
623
+ log('stale instance: crash record left alone')
624
+ phase = 'paused'
490
625
  return { status: 'partial', summary, outstanding: 0 }
491
626
  }
492
- if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
627
+ if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
493
628
  lastError = 'store'
494
629
  phase = 'paused'
495
630
  return { status: 'partial', summary, outstanding: 0 }
496
631
  }
497
- crashFallback = null
498
- if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
499
- if (isCurrent() === false) {
632
+ if (stillMine(plan) === false) {
500
633
  log('stale instance: crash record left alone')
634
+ phase = 'paused'
501
635
  return { status: 'partial', summary, outstanding: 0 }
502
636
  }
503
- try {
504
- heartbeat?.release?.()
505
- } catch (error) {
506
- log(`cannot release crash record: ${error?.message ?? error}`)
637
+ if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
638
+ lastError = 'store'
639
+ phase = 'paused'
640
+ return { status: 'partial', summary, outstanding: 0 }
507
641
  }
642
+ planFallback = null
643
+ if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
508
644
  } else {
645
+ if (stillMine(plan) === false) {
646
+ log('stale instance: pause plan left alone')
647
+ phase = 'paused'
648
+ return { status: 'partial', summary, outstanding: 0 }
649
+ }
509
650
  const consumed = store.consume(plan, summary)
510
651
  if (!consumed?.ok) {
511
652
  lastError = 'store'
512
653
  phase = 'paused'
513
654
  return { status: 'partial', summary, outstanding: 0 }
514
655
  }
656
+ planFallback = null
657
+ }
658
+ // The plan is complete, including every crash row that was merged into it. Only now may the
659
+ // heartbeat stop protecting the previous process's record: releasing earlier would overwrite
660
+ // the only evidence of a session the merge had not yet brought into this plan.
661
+ try {
662
+ heartbeat?.release?.()
663
+ } catch (error) {
664
+ log(`cannot release heartbeat: ${error?.message ?? error}`)
515
665
  }
516
666
  phase = 'idle'
517
667
  return { status: 'resumed', summary }
@@ -568,7 +718,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
568
718
  * overwrites the previous process's file; it is persisted here so a second restart still sees it.
569
719
  */
570
720
  async function hydrate(options = {}) {
571
- crashFallback = null
721
+ planFallback = null
572
722
  lastError = null
573
723
  const residue = options?.residue ?? heartbeat?.residue ?? null
574
724
  const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
@@ -591,7 +741,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
591
741
  // A failed write stays in memory for this process, but the caller must not let the
592
742
  // heartbeat overwrite the file the next process would have read the same residue from.
593
743
  if (stored === null) {
594
- crashFallback = crash
744
+ planFallback = crash
595
745
  durable = false
596
746
  lastError = 'store'
597
747
  heartbeat?.hold?.()
@@ -624,10 +774,13 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
624
774
  if (stored === null) {
625
775
  durable = false
626
776
  lastError = 'store'
627
- if (isCrash) crashFallback = plan
777
+ // Whatever the source, the in-memory plan now holds the complete set. Resume must
778
+ // use it, or the sessions that only exist here would be skipped forever.
779
+ planFallback = plan
628
780
  heartbeat?.hold?.()
629
781
  log('cannot merge the crash plan; keeping the previous heartbeat')
630
782
  } else {
783
+ planFallback = null
631
784
  log(`crash sessions merged (${added.length})`)
632
785
  }
633
786
  }
@@ -635,7 +788,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
635
788
  }
636
789
  }
637
790
 
638
- phase = pausedPlan() !== null ? 'paused' : 'idle'
791
+ const hydrated = currentPlan()
792
+ // A crash record is not a pause: the button offers restore, but the page is not paused.
793
+ phase = hydrated !== null && isCrashPlan(hydrated) === false ? 'paused' : 'idle'
639
794
  return { phase, durable }
640
795
  }
641
796
 
package/lib/heartbeat.js CHANGED
@@ -204,16 +204,21 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
204
204
  }
205
205
  }
206
206
 
207
+ function arm() {
208
+ if (timer !== null) return
209
+ timer = setInterval(() => {
210
+ void refresh()
211
+ }, intervalMs)
212
+ if (typeof timer.unref === 'function') timer.unref()
213
+ }
214
+
207
215
  function start() {
208
216
  if (started) return
209
217
  started = true
210
218
  stopped = false
211
219
  capture()
212
220
  void refresh()
213
- timer = setInterval(() => {
214
- void refresh()
215
- }, intervalMs)
216
- if (typeof timer.unref === 'function') timer.unref()
221
+ arm()
217
222
  }
218
223
 
219
224
  function stop() {
@@ -234,10 +239,23 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
234
239
  stop()
235
240
  }
236
241
 
237
- /** The residue was restored: from here on the heartbeat describes this process again. */
242
+ /**
243
+ * The residue was restored: this process owns the file again. Clear `stopped`, re-arm the timer
244
+ * and allow `note()`; a permission flip alone would leave both write paths dead.
245
+ */
238
246
  function release() {
239
247
  writable = true
240
248
  residue = null
249
+ started = true
250
+ stopped = false
251
+ if (dirty() && noteTimer === null) {
252
+ noteTimer = setTimeout(() => {
253
+ noteTimer = null
254
+ void flush()
255
+ }, DEBOUNCE_MS)
256
+ if (typeof noteTimer.unref === 'function') noteTimer.unref()
257
+ }
258
+ arm()
241
259
  void refresh(true)
242
260
  }
243
261
 
package/lib/probe.js CHANGED
@@ -76,9 +76,12 @@ function goalView(services, agent) {
76
76
  if (goals === undefined || goals === null || typeof goals.get !== 'function') return undefined
77
77
  try {
78
78
  const view = goals.get(agent)
79
- return view === undefined || view === null ? undefined : view
80
- } catch {
81
- return undefined
79
+ // `null` is the service's answer for "no goal"; a throw is "cannot tell right now". Collapsing
80
+ // both into `undefined` made a transient read error look like the goal had been deleted, which
81
+ // the restore then recorded as a terminal skip and never retried.
82
+ return view === undefined || view === null ? null : view
83
+ } catch (error) {
84
+ return { unreadable: true, detail: String(error?.message ?? error).slice(0, 120) }
82
85
  }
83
86
  }
84
87
 
@@ -132,6 +135,10 @@ export function createProbe(services, options = {}) {
132
135
  const continueText = typeof options.continueText === 'string' && options.continueText.trim() !== ''
133
136
  ? options.continueText
134
137
  : '继续'
138
+ // A reload replaces the probe while an old restore is still awaiting a mount. The old probe must
139
+ // stop before its side effects, not after: a sent message cannot be taken back.
140
+ const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true
141
+ const reloaded = () => isCurrent() === false
135
142
 
136
143
  /** Everything this press can stop, read from the live registry only. */
137
144
  function live() {
@@ -151,7 +158,7 @@ export function createProbe(services, options = {}) {
151
158
  sessions.push({ sessionId: agent.id, running: agent.status === 'running', lastTurn: lastTurnOf(agent) })
152
159
  // A disarmed goal is not work: it will not start a round, and pausing it would throw. Only an
153
160
  // 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') {
161
+ if (goal !== undefined && goal !== null && goal.unreadable !== true && goal.phase === 'active' && goal.activation === 'armed') {
155
162
  goals.push({ sessionId: agent.id, goalId: goal.id, revision: goal.revision })
156
163
  }
157
164
  for (const job of owned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: agent.id, status: job.status })
@@ -236,10 +243,15 @@ export function createProbe(services, options = {}) {
236
243
  // The epoch is the pause cycle. A retry of the same pause reuses the id (official dedupe keeps
237
244
  // admission idempotent), while a new pause after a partial restore gets a fresh one, so the
238
245
  // earlier admitted message cannot swallow this cycle's continuation.
239
- const cycle = Number.isSafeInteger(epoch) && epoch > 0 ? epoch : 1
246
+ const cycle = Number.isSafeInteger(epoch) && epoch >= 0 ? epoch : 1
247
+ // Cycle 0 is a pre-epoch plan: keep the exact id that may already sit in the official queue,
248
+ // so upgrading the plugin cannot duplicate a continuation the old version admitted.
249
+ const requestId = cycle === 0
250
+ ? `${plan.planId}:continue:${sessionId}`
251
+ : `${plan.planId}:continue:${sessionId}:${cycle}`
240
252
  return {
241
253
  sessionId,
242
- requestId: `${plan.planId}:continue:${sessionId}:${cycle}`,
254
+ requestId,
243
255
  content: [{ type: 'text', text: continueText }],
244
256
  // `queue` is the shipped public mode: deliver after the current turn, start one when idle.
245
257
  mode: 'queue',
@@ -288,6 +300,7 @@ export function createProbe(services, options = {}) {
288
300
  return { status: 'failed', detail }
289
301
  }
290
302
  }
303
+ if (reloaded()) return { status: 'reloaded' }
291
304
 
292
305
  // Version 2 plans carry only `running`; a normalized version 3 plan carries `intent`. Reading
293
306
  // the old field here keeps a plan written by 0.3.0 working across the upgrade.
@@ -312,6 +325,7 @@ export function createProbe(services, options = {}) {
312
325
  if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
313
326
  return { status: 'failed', detail: 'prompt unavailable', continued: false }
314
327
  }
328
+ if (reloaded()) return { status: 'reloaded' }
315
329
  const request = continuation(plan, sessionId, session.epoch)
316
330
  try {
317
331
  // Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
@@ -356,12 +370,16 @@ export function createProbe(services, options = {}) {
356
370
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
357
371
  }
358
372
  }
373
+ if (reloaded()) return { status: 'reloaded' }
359
374
  const services_goals = service(services, 'goals')
360
375
  if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
361
376
  return { status: 'failed', detail: 'goals unavailable' }
362
377
  }
363
378
  const view = goalView(services, agent)
364
- if (view === undefined || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
379
+ if (view !== undefined && view !== null && view.unreadable === true) {
380
+ return { status: 'failed', detail: `goal unreadable: ${view.detail}` }
381
+ }
382
+ if (view === undefined || view === null || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
365
383
  return { status: 'skipped', detail: 'goal is gone' }
366
384
  }
367
385
  // An active goal is normally not ours to touch. The one exception is a goal this same plan
@@ -369,13 +387,22 @@ export function createProbe(services, options = {}) {
369
387
  // exactly the revision the plan recorded. Another person's goal has no such record.
370
388
  if (view.phase === 'active') {
371
389
  if (view.activation === 'armed') return { status: 'skipped', detail: 'already active' }
372
- if (Number.isSafeInteger(goal.armedRevision) && goal.armedRevision === view.revision) {
390
+ const pending = goal.pendingRestore !== null && typeof goal.pendingRestore === 'object' ? goal.pendingRestore : null
391
+ const pendingRevision = Number.isSafeInteger(pending?.revision) ? pending.revision : null
392
+ // `pendingRestore` was written before the side effect. If the process died after resume but
393
+ // before the checkpoint, the goal is active and disarmed at the revision our resume produced.
394
+ const ours = pending !== null && (pendingRevision === null || view.revision >= pendingRevision)
395
+ if (ours || (Number.isSafeInteger(goal.armedRevision) && goal.armedRevision === view.revision)) {
396
+ if (reloaded()) return { status: 'reloaded' }
373
397
  try {
374
398
  services_goals.resume(agent, { id: view.id, revision: view.revision })
375
399
  } catch (error) {
376
400
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
377
401
  }
378
402
  const after = goalView(services, agent)
403
+ if (after !== undefined && after !== null && after.unreadable === true) {
404
+ return { status: 'failed', detail: 'goal unreadable after resume' }
405
+ }
379
406
  return { status: 'restored', detail: 're-armed after restart', revision: after?.revision ?? null }
380
407
  }
381
408
  return { status: 'skipped', detail: 'goal disarmed' }
@@ -390,12 +417,16 @@ export function createProbe(services, options = {}) {
390
417
  const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
391
418
  if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
392
419
  if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
420
+ if (reloaded()) return { status: 'reloaded' }
393
421
  try {
394
422
  services_goals.resume(agent, { id: view.id, revision: view.revision })
395
423
  } catch (error) {
396
424
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
397
425
  }
398
426
  const after = goalView(services, agent)
427
+ if (after !== undefined && after !== null && after.unreadable === true) {
428
+ return { status: 'failed', detail: 'goal unreadable after resume' }
429
+ }
399
430
  return { status: 'restored', detail: 'armed', revision: after?.revision ?? null }
400
431
  }
401
432
 
package/lib/snapshot.js CHANGED
@@ -152,9 +152,9 @@ export function createStore(dir, options = {}) {
152
152
  const intent = row.intent === 'continue' || row.intent === 'mount'
153
153
  ? row.intent
154
154
  : (row.running === true ? 'continue' : 'mount')
155
- const epoch = Number.isSafeInteger(row.epoch) && row.epoch > 0
156
- ? row.epoch
157
- : (intent === 'continue' ? 1 : 0)
155
+ // A plan written before epochs existed has no cycle number. Zero is the legacy identity:
156
+ // the continuation keeps its original request id instead of looking like a new operation.
157
+ const epoch = Number.isSafeInteger(row.epoch) && row.epoch > 0 ? row.epoch : 0
158
158
  return { ...row, intent, epoch, running: intent === 'continue' }
159
159
  })
160
160
  plan.goals = (Array.isArray(plan.goals) ? plan.goals : []).map((row) => (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pause-resume",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "One header button stops running turns, goals and jobs, writes a plan to disk, and restores that work after a restart or crash; the button shape is the only status.",
6
6
  "license": "MIT",