dsh-pause-resume 0.4.2 → 0.4.4

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,6 +1,26 @@
1
1
  # Changelog
2
2
 
3
- ## 0.4.2
3
+ ## 0.4.4
4
+
5
+ Restores are now safe to hand over between instances and across an upgrade.
6
+
7
+ * An interrupted pause resets the whole restore policy (`intent: "mount"`, `epoch: 0`), so it is
8
+ mounted and never sends a continuation.
9
+ * When a merged crash plan could not be written to disk, resume restores that in-memory plan, which
10
+ holds both the old and the newly found sessions. The older `pending.json` no longer wins and hides
11
+ half the work.
12
+ * `heartbeat.release()` restores a full lifecycle: the periodic sample is re-armed, `stopped` is
13
+ cleared, and state events can persist again. Previously the file stayed frozen after a repaired
14
+ write failure.
15
+ * A pre-epoch plan keeps its original continuation request id (`…:continue:<session>`), so upgrading
16
+ cannot queue a second copy of a message the old version already admitted.
17
+ * A restore that loses ownership (plugin reload) stops before publishing, consuming or clearing. It
18
+ also compares the plan's actual work, not only its id, so additions from a newer press survive.
19
+ * A goal whose checkpoint cannot be written keeps the plan on disk with the failure named, instead of
20
+ silently skipping the goal on the next boot.
21
+ * The button hint combines the error with the focused item for partial pause/restore.
22
+
23
+ ## 0.4.3
4
24
 
5
25
  - 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.
6
26
  - Restore mounts sessions in bounded parallel batches, so wall time is the slowest batch instead of the sum of every session.
@@ -20,9 +40,9 @@ A stop that could not finish no longer traps the button, and the dot stops lying
20
40
 
21
41
  The crash path is now a first-class restore path, and a dead button after a restart is fixed.
22
42
 
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.
43
+ * Routes are registered synchronously while `apply` runs. Registration itself is not the problem —
44
+ the server keeps a live route table — but registering after hydration meant the button could be
45
+ pressed before the table had an entry. Handlers now wait for hydration instead.
26
46
  * A crash plan is written to `pending.json` before the heartbeat is replaced. If that write fails,
27
47
  the heartbeat is held and the previous `running.json` is left untouched, so the next boot still
28
48
  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
@@ -7,7 +7,7 @@ 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.1'
10
+ export const VERSION = '0.4.4'
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
  ]
@@ -275,8 +275,8 @@ export function apply(ctx, config = {}) {
275
275
  log(`cannot follow agent events: ${error?.message ?? error}`)
276
276
  }
277
277
 
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.
278
+ // Routes must be registered while `apply` is still synchronous, so a press can never arrive
279
+ // before the table has an entry. Hydration is asynchronous; the handlers wait on this promise.
280
280
  let releaseReady = () => {}
281
281
  const ready = new Promise((resolve) => {
282
282
  releaseReady = resolve
package/lib/engine.js CHANGED
@@ -66,7 +66,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
66
66
 
67
67
  function crashPlan() {
68
68
  const stored = typeof store.readPending === 'function' ? store.readPending() : null
69
- return stored ?? crashFallback
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
70
72
  }
71
73
 
72
74
  /** Exactly one plan is pending, and a finished pause outranks a leftover crash record. */
@@ -74,6 +76,32 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
74
76
  return pausedPlan() ?? crashPlan()
75
77
  }
76
78
 
79
+ /** The work a plan asks for, without timestamps or reporting fields. */
80
+ function planKey(plan) {
81
+ const row = (item) => [item?.sessionId ?? '', item?.intent ?? (item?.running === true ? 'continue' : 'mount'), item?.goalId ?? '']
82
+ return JSON.stringify({
83
+ sessions: (plan?.sessions ?? []).map(row),
84
+ goals: (plan?.goals ?? []).map((goal) => [goal?.sessionId ?? '', goal?.goalId ?? '']),
85
+ jobs: (plan?.jobs ?? []).map((job) => job?.id ?? ''),
86
+ })
87
+ }
88
+
89
+ /**
90
+ * A plan is only ours to spend if the durable copy still asks for exactly the same work. A
91
+ * merged plan keeps the planId, so an id comparison alone would delete another press's additions.
92
+ */
93
+ function stillMine(plan) {
94
+ if (isCurrent() === false) return false
95
+ // When the merge could not be written, this in-memory plan is the complete set and the file
96
+ // 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)
103
+ }
104
+
77
105
  function isCrashPlan(plan) {
78
106
  return plan !== null && plan !== undefined && plan.source === 'crash'
79
107
  }
@@ -408,7 +436,11 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
408
436
  if (plan.status === 'pausing') {
409
437
  plan.source = plan.source === 'crash' ? 'crash' : 'interrupted'
410
438
  plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
411
- plan.sessions = (plan.sessions ?? []).map((session) => ({ ...session, running: false, lastTurn: null }))
439
+ // `intent` outranks `running` in the probe, so an interrupted pause must reset the whole
440
+ // policy: mount only, no admission, and no earlier cycle's request identity.
441
+ plan.sessions = (plan.sessions ?? []).map((session) => ({
442
+ ...session, intent: 'mount', running: false, epoch: 0, lastTurn: null,
443
+ }))
412
444
  plan.status = 'paused'
413
445
  if (persist(plan) === null) {
414
446
  phase = 'paused'
@@ -425,7 +457,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
425
457
  log(`cannot journal ${key}`)
426
458
  }
427
459
  }
460
+ const stale = () => isCurrent() === false
428
461
  const step = async (key, label, run) => {
462
+ if (stale()) return { status: 'failed', detail: 'reloaded' }
429
463
  let outcome
430
464
  try {
431
465
  outcome = await run()
@@ -433,6 +467,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
433
467
  outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
434
468
  }
435
469
  const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
470
+ if (status === 'reloaded') return outcome
436
471
  if (TERMINAL.has(status) === false) {
437
472
  summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
438
473
  return outcome
@@ -467,13 +502,27 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
467
502
  plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
468
503
  ? { ...row, armedRevision: outcome.revision }
469
504
  : row))
470
- if (persist(plan) === null) lastError = 'store'
505
+ 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`)
509
+ lastError = 'store'
510
+ phase = 'paused'
511
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
512
+ }
471
513
  }
472
514
  }
473
515
  // A killed job cannot be replayed: the same command may have had an unknown effect, so it is
474
516
  // reported once and left to the person. It never blocks the plan and is never restarted.
475
517
  for (const job of plan.jobs ?? []) summary.jobsStopped.push(`job ${job.id}: stopped, not restarted`)
476
518
 
519
+ if (stale()) {
520
+ // A reload owns the plan now. Publishing a summary or consuming anything here would erase
521
+ // work the new instance captured while these mounts were in flight.
522
+ log('stale instance: restore result discarded')
523
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
524
+ }
525
+
477
526
  result = summary
478
527
  if (summary.failed.length > 0) {
479
528
  plan.restore = { at: summary.at, attempts: (plan.restore?.attempts ?? 0) + 1, failed: summary.failed } // ui-copy-ignore
@@ -484,11 +533,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
484
533
  }
485
534
 
486
535
  if (isCrashPlan(plan) === true) {
536
+ if (stillMine(plan) === false) {
537
+ log('stale instance: crash record left alone')
538
+ return { status: 'partial', summary, outstanding: 0 }
539
+ }
487
540
  if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
488
541
  lastError = 'store'
489
542
  phase = 'paused'
490
543
  return { status: 'partial', summary, outstanding: 0 }
491
544
  }
545
+ if (stillMine(plan) === false) {
546
+ log('stale instance: crash record left alone')
547
+ return { status: 'partial', summary, outstanding: 0 }
548
+ }
492
549
  if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
493
550
  lastError = 'store'
494
551
  phase = 'paused'
@@ -496,16 +553,16 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
496
553
  }
497
554
  crashFallback = null
498
555
  if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
499
- if (isCurrent() === false) {
500
- log('stale instance: crash record left alone')
501
- return { status: 'partial', summary, outstanding: 0 }
502
- }
503
556
  try {
504
557
  heartbeat?.release?.()
505
558
  } catch (error) {
506
559
  log(`cannot release crash record: ${error?.message ?? error}`)
507
560
  }
508
561
  } else {
562
+ if (stillMine(plan) === false) {
563
+ log('stale instance: pause plan left alone')
564
+ return { status: 'partial', summary, outstanding: 0 }
565
+ }
509
566
  const consumed = store.consume(plan, summary)
510
567
  if (!consumed?.ok) {
511
568
  lastError = 'store'
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
@@ -236,10 +236,15 @@ export function createProbe(services, options = {}) {
236
236
  // The epoch is the pause cycle. A retry of the same pause reuses the id (official dedupe keeps
237
237
  // admission idempotent), while a new pause after a partial restore gets a fresh one, so the
238
238
  // earlier admitted message cannot swallow this cycle's continuation.
239
- const cycle = Number.isSafeInteger(epoch) && epoch > 0 ? epoch : 1
239
+ const cycle = Number.isSafeInteger(epoch) && epoch >= 0 ? epoch : 1
240
+ // Cycle 0 is a pre-epoch plan: keep the exact id that may already sit in the official queue,
241
+ // so upgrading the plugin cannot duplicate a continuation the old version admitted.
242
+ const requestId = cycle === 0
243
+ ? `${plan.planId}:continue:${sessionId}`
244
+ : `${plan.planId}:continue:${sessionId}:${cycle}`
240
245
  return {
241
246
  sessionId,
242
- requestId: `${plan.planId}:continue:${sessionId}:${cycle}`,
247
+ requestId,
243
248
  content: [{ type: 'text', text: continueText }],
244
249
  // `queue` is the shipped public mode: deliver after the current turn, start one when idle.
245
250
  mode: 'queue',
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.2",
3
+ "version": "0.4.4",
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",