dsh-pause-resume 0.4.4 → 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,29 @@
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
+
3
27
  ## 0.4.4
4
28
 
5
29
  Restores are now safe to hand over between instances and across an upgrade.
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.4'
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'
@@ -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
  })
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,15 +81,21 @@ 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
- // When the merge could not be written, the in-memory plan holds the old crash sessions *and*
70
- // the ones this process found; the older file would hide the half that was never saved.
71
- return crashFallback ?? stored
84
+ return typeof store.readPending === 'function' ? store.readPending() : null
72
85
  }
73
86
 
74
- /** 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
+ */
75
92
  function currentPlan() {
76
- 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)
77
99
  }
78
100
 
79
101
  /** The work a plan asks for, without timestamps or reporting fields. */
@@ -94,12 +116,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
94
116
  if (isCurrent() === false) return false
95
117
  // When the merge could not be written, this in-memory plan is the complete set and the file
96
118
  // is only its older subset. The epoch check above already proved no newer instance wrote.
97
- if (isCrashPlan(plan) === true && crashFallback === plan) return true
98
- const current = isCrashPlan(plan)
99
- ? (typeof store.readPending === 'function' ? store.readPending() : null)
100
- : store.read()
101
- if (current === null) return crashFallback === plan && isCrashPlan(plan) === true
102
- return current.planId === plan.planId && planKey(current) === planKey(plan)
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)
103
123
  }
104
124
 
105
125
  function isCrashPlan(plan) {
@@ -169,6 +189,16 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
169
189
  return work.sessions.length + work.goals.length + work.jobs.length
170
190
  }
171
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
+
172
202
  /**
173
203
  * What the button shows. `action` is the one decision the client may not make itself:
174
204
  * `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
@@ -187,14 +217,17 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
187
217
  if (busy) action = 'busy'
188
218
  else if (plan !== null) action = 'resume'
189
219
  else if (current.readable === true && workCount > 0) action = 'pause'
190
- // The dot means "something still needs you". A clean idle page has nothing to point at: the
191
- // jobs a pause killed stay in the last-restore record, but they are not an open obligation.
192
- const attentionRows = plan !== null
193
- ? dedupeRows(Array.isArray(plan.attention) ? plan.attention : [])
194
- : phase === 'idle' && lastError === null ? null : result?.attention
195
- 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
196
229
  const first = attention > 0 ? attentionRows[0] : null
197
- const focus = first === null ? null : (typeof first === 'string' ? first : (first.id || first.detail || null))
230
+ const focus = first !== null ? attentionFocus(first) : failureFocus(result)
198
231
  return {
199
232
  phase,
200
233
  action,
@@ -273,17 +306,20 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
273
306
  return out
274
307
  }
275
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
+
276
315
  function persist(plan) {
277
316
  if (isCurrent() === false) {
278
317
  log('stale instance: refusing to write over a newer plan')
279
318
  return null
280
319
  }
281
- if (isCrashPlan(plan) === true) {
282
- const stored = store.writePending(plan)
283
- if (stored !== null) crashFallback = null
284
- return stored
285
- }
286
- return store.write(plan)
320
+ const stored = usesPending(plan) ? store.writePending(plan) : store.write(plan)
321
+ if (stored !== null) planFallback = null
322
+ return stored
287
323
  }
288
324
 
289
325
  /**
@@ -320,8 +356,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
320
356
  const before = new Map((basePlan?.sessions ?? []).filter((row) => typeof row?.sessionId === 'string').map((row) => [row.sessionId, row]))
321
357
  const restoredBefore = Number.isSafeInteger(basePlan?.restore?.attempts) && basePlan.restore.attempts > 0
322
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))
323
363
  plan.sessions = plan.sessions.map((row) => {
324
- if (row.intent !== 'continue') return row
364
+ if (row.intent !== 'continue' || captured.has(row.sessionId) === false) return row
325
365
  const prior = before.get(row.sessionId)
326
366
  const priorEpoch = Number.isSafeInteger(prior?.epoch) ? prior.epoch : 0
327
367
  if (Number.isSafeInteger(row.epoch) && row.epoch > priorEpoch) return row
@@ -401,16 +441,21 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
401
441
  // The crash record described work that was not paused. This press did pause it, so the plan
402
442
  // becomes a normal pause plan and the crash evidence is no longer a separate obligation.
403
443
  plan.source = 'pause' // ui-copy-ignore
404
- if (store.write(plan) === null) {
405
- 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
406
452
  if (kept === null) {
407
- crashFallback = plan
453
+ planFallback = plan
408
454
  lastError = 'store'
455
+ } else {
456
+ planFallback = null
409
457
  }
410
- } else if (typeof store.clearPending === 'function') {
411
- if (store.clearPending() === false) lastError = 'store'
412
458
  }
413
- crashFallback = null
414
459
  } else if (store.write(plan) === null) {
415
460
  // The durable copy still says `pausing` or `paused`: the next boot reports it instead of
416
461
  // claiming a stop that was never written.
@@ -494,18 +539,47 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
494
539
  // flight turns a long serial restore into one batch without flooding the Harness.
495
540
  await Promise.all(Array.from({ length: Math.min(4, sessionRows.length) }, sessionWorker))
496
541
  for (const goal of plan.goals ?? []) {
542
+ if (stale()) break
497
543
  const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
498
- 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
499
564
  if (outcome?.status === 'restored' && Number.isSafeInteger(outcome.revision)) {
500
565
  // Remember what this plan armed. A restarted process sees `active + disarmed`, and that
501
566
  // exact revision is the only proof the goal is ours to arm again rather than someone else's.
502
- plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
503
- ? { ...row, armedRevision: outcome.revision }
504
- : row))
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 })
505
581
  if (persist(plan) === null) {
506
- // Without the checkpoint a later boot cannot prove this goal is ours to arm again.
507
- // Keep the plan (sessions mount idempotently) instead of silently skipping the goal.
508
- summary.failed.push(`goal ${goal.goalId ?? goal.sessionId}: checkpoint not saved`)
582
+ summary.failed.push(`${label}: checkpoint not saved`)
509
583
  lastError = 'store'
510
584
  phase = 'paused'
511
585
  return { status: 'partial', summary, outstanding: outstandingOf(plan) }
@@ -532,9 +606,22 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
532
606
  return { status: 'partial', summary, outstanding: outstandingOf(plan) }
533
607
  }
534
608
 
535
- if (isCrashPlan(plan) === true) {
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) {
614
+ lastError = 'store'
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)) {
536
622
  if (stillMine(plan) === false) {
537
623
  log('stale instance: crash record left alone')
624
+ phase = 'paused'
538
625
  return { status: 'partial', summary, outstanding: 0 }
539
626
  }
540
627
  if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
@@ -544,6 +631,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
544
631
  }
545
632
  if (stillMine(plan) === false) {
546
633
  log('stale instance: crash record left alone')
634
+ phase = 'paused'
547
635
  return { status: 'partial', summary, outstanding: 0 }
548
636
  }
549
637
  if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
@@ -551,16 +639,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
551
639
  phase = 'paused'
552
640
  return { status: 'partial', summary, outstanding: 0 }
553
641
  }
554
- crashFallback = null
642
+ planFallback = null
555
643
  if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
556
- try {
557
- heartbeat?.release?.()
558
- } catch (error) {
559
- log(`cannot release crash record: ${error?.message ?? error}`)
560
- }
561
644
  } else {
562
645
  if (stillMine(plan) === false) {
563
646
  log('stale instance: pause plan left alone')
647
+ phase = 'paused'
564
648
  return { status: 'partial', summary, outstanding: 0 }
565
649
  }
566
650
  const consumed = store.consume(plan, summary)
@@ -569,6 +653,15 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
569
653
  phase = 'paused'
570
654
  return { status: 'partial', summary, outstanding: 0 }
571
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}`)
572
665
  }
573
666
  phase = 'idle'
574
667
  return { status: 'resumed', summary }
@@ -625,7 +718,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
625
718
  * overwrites the previous process's file; it is persisted here so a second restart still sees it.
626
719
  */
627
720
  async function hydrate(options = {}) {
628
- crashFallback = null
721
+ planFallback = null
629
722
  lastError = null
630
723
  const residue = options?.residue ?? heartbeat?.residue ?? null
631
724
  const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
@@ -648,7 +741,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
648
741
  // A failed write stays in memory for this process, but the caller must not let the
649
742
  // heartbeat overwrite the file the next process would have read the same residue from.
650
743
  if (stored === null) {
651
- crashFallback = crash
744
+ planFallback = crash
652
745
  durable = false
653
746
  lastError = 'store'
654
747
  heartbeat?.hold?.()
@@ -681,10 +774,13 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
681
774
  if (stored === null) {
682
775
  durable = false
683
776
  lastError = 'store'
684
- 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
685
780
  heartbeat?.hold?.()
686
781
  log('cannot merge the crash plan; keeping the previous heartbeat')
687
782
  } else {
783
+ planFallback = null
688
784
  log(`crash sessions merged (${added.length})`)
689
785
  }
690
786
  }
@@ -692,7 +788,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
692
788
  }
693
789
  }
694
790
 
695
- 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'
696
794
  return { phase, durable }
697
795
  }
698
796
 
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 })
@@ -293,6 +300,7 @@ export function createProbe(services, options = {}) {
293
300
  return { status: 'failed', detail }
294
301
  }
295
302
  }
303
+ if (reloaded()) return { status: 'reloaded' }
296
304
 
297
305
  // Version 2 plans carry only `running`; a normalized version 3 plan carries `intent`. Reading
298
306
  // the old field here keeps a plan written by 0.3.0 working across the upgrade.
@@ -317,6 +325,7 @@ export function createProbe(services, options = {}) {
317
325
  if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
318
326
  return { status: 'failed', detail: 'prompt unavailable', continued: false }
319
327
  }
328
+ if (reloaded()) return { status: 'reloaded' }
320
329
  const request = continuation(plan, sessionId, session.epoch)
321
330
  try {
322
331
  // Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
@@ -361,12 +370,16 @@ export function createProbe(services, options = {}) {
361
370
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
362
371
  }
363
372
  }
373
+ if (reloaded()) return { status: 'reloaded' }
364
374
  const services_goals = service(services, 'goals')
365
375
  if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
366
376
  return { status: 'failed', detail: 'goals unavailable' }
367
377
  }
368
378
  const view = goalView(services, agent)
369
- 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)) {
370
383
  return { status: 'skipped', detail: 'goal is gone' }
371
384
  }
372
385
  // An active goal is normally not ours to touch. The one exception is a goal this same plan
@@ -374,13 +387,22 @@ export function createProbe(services, options = {}) {
374
387
  // exactly the revision the plan recorded. Another person's goal has no such record.
375
388
  if (view.phase === 'active') {
376
389
  if (view.activation === 'armed') return { status: 'skipped', detail: 'already active' }
377
- 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' }
378
397
  try {
379
398
  services_goals.resume(agent, { id: view.id, revision: view.revision })
380
399
  } catch (error) {
381
400
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
382
401
  }
383
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
+ }
384
406
  return { status: 'restored', detail: 're-armed after restart', revision: after?.revision ?? null }
385
407
  }
386
408
  return { status: 'skipped', detail: 'goal disarmed' }
@@ -395,12 +417,16 @@ export function createProbe(services, options = {}) {
395
417
  const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
396
418
  if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
397
419
  if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
420
+ if (reloaded()) return { status: 'reloaded' }
398
421
  try {
399
422
  services_goals.resume(agent, { id: view.id, revision: view.revision })
400
423
  } catch (error) {
401
424
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
402
425
  }
403
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
+ }
404
430
  return { status: 'restored', detail: 'armed', revision: after?.revision ?? null }
405
431
  }
406
432
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pause-resume",
3
- "version": "0.4.4",
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",