dsh-pause-resume 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ The crash path is now a first-class restore path, and a dead button after a restart is fixed.
6
+
7
+ * Routes are registered synchronously while `apply` runs. The web server freezes its route table
8
+ when it starts, so registering after `hydrate()` left the button answering 404 on a fresh boot.
9
+ Handlers now wait for hydration instead of the route table waiting for it.
10
+ * A crash plan is written to `pending.json` before the heartbeat is replaced. If that write fails,
11
+ the heartbeat is held and the previous `running.json` is left untouched, so the next boot still
12
+ sees the work.
13
+ * Crash rows record `intent: "mount"` and are mounted only; they can never turn into a continuation
14
+ by accident. A pause row records `intent: "continue"`.
15
+ * A subagent session that cannot be mounted directly is reported once as attention for its parent,
16
+ not retried forever as a failure that keeps the plan open.
17
+ * Partial pauses and partial restores name the first item that needs a person in `focus`, so the
18
+ button's dot has an explanation without adding text to the button.
19
+ * A hot reload bumps a shared epoch; an old instance's in-flight press can no longer overwrite the
20
+ plan written by the new instance.
21
+ * A continuation request id gets a new epoch after a restore attempt, so pausing again after a
22
+ partial restore is not silently deduplicated by the Host.
23
+ * Directed regression tests for the route-registration order, crash-plan durability, subagent rows
24
+ and stale-instance writes.
25
+
26
+ ## 0.3.0
27
+
28
+ * Crash residue is turned into a plan and offered as a restore, with no press needed.
29
+ * Version 3 plans distinguish mount-only crash rows from continuation rows.
30
+ * Restores re-check live state instead of trusting the journal.
31
+
32
+ ## 0.2.0
33
+
34
+ * One header button: pause stops turns, goals and jobs; resume restores them after a restart.
35
+ * Durable snapshots, per-item outcomes, journaled restore attempts.
36
+
37
+ ## 0.1.0
38
+
39
+ * First release.
package/README.md CHANGED
@@ -46,6 +46,9 @@ request id is derived from the plan and the session, so a retry before a turn st
46
46
  by the Host. A failure leaves the plan on disk and the next press retries every item that still needs
47
47
  work.
48
48
 
49
+ A subagent session cannot be mounted by this process: its parent chat owns the routing. That row is
50
+ reported once as attention for the parent, not retried as a failure, so it never keeps the plan open.
51
+
49
52
  ## Crash, not just a press
50
53
 
51
54
  A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
package/client.js CHANGED
@@ -43,6 +43,7 @@ window.__ModuleLoader__.load({
43
43
  counts: { sessions: 0, goals: 0, jobs: 0 },
44
44
  live: { sessions: 0, goals: 0, jobs: 0 },
45
45
  attention: 0,
46
+ focus: null, // ui-copy-ignore
46
47
  error: null, // ui-copy-ignore
47
48
  }
48
49
 
@@ -141,7 +142,9 @@ window.__ModuleLoader__.load({
141
142
  function hintOf(state) {
142
143
  if (state.error === 'restore-partial') return COPY.partial
143
144
  if (state.error !== null && state.error !== undefined) return COPY.error
144
- if ((state.attention ?? 0) > 0) return COPY.attention
145
+ if ((state.attention ?? 0) > 0) {
146
+ return typeof state.focus === 'string' && state.focus !== '' ? `${COPY.attention}: ${state.focus}` : COPY.attention
147
+ }
145
148
  if ((state.outstanding ?? 0) > 0) return COPY.stopped
146
149
  return ''
147
150
  }
package/index.js CHANGED
@@ -2,11 +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
6
  import { createHeartbeat } from './lib/heartbeat.js'
6
7
  import { createProbe, listRunning } from './lib/probe.js'
7
8
 
8
9
  export const name = 'dsh-pause-resume'
9
- export const VERSION = '0.3.0'
10
+ export const VERSION = '0.4.0'
10
11
 
11
12
  const BASE_PATH = '/dsh-pause-resume'
12
13
  const BUTTON_HEADER = 'x-dsh-pause-resume'
@@ -117,13 +118,14 @@ function guard(handler) {
117
118
  }
118
119
  }
119
120
 
120
- function serve(ctx, engine, heartbeat, options) {
121
+ function serve(ctx, engine, heartbeat, options, ready) {
121
122
  const server = service(ctx, 'webServer')
122
123
  if (server === undefined || server === null || typeof server.register !== 'function') {
123
124
  log('no web server: the header button cannot ask for a pause')
124
125
  return () => {}
125
126
  }
126
127
  const allowRemote = options.allowRemote === true
128
+ let disposed = false
127
129
  const route = (handler, read) => guard(async (req, res) => {
128
130
  const method = String(req?.method ?? 'GET').toUpperCase()
129
131
  if (read === true ? method !== 'GET' && method !== 'HEAD' : method !== 'POST') {
@@ -133,6 +135,10 @@ function serve(ctx, engine, heartbeat, options) {
133
135
  if (read === true ? sameOrigin(req, allowRemote) === false : buttonAllowed(req, allowRemote) === false) {
134
136
  return sendJson(res, 403, { status: 'forbidden' })
135
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.
140
+ if (ready !== undefined) await ready
141
+ if (disposed === true) return sendJson(res, 503, { status: 'starting' })
136
142
  return handler(req, res)
137
143
  })
138
144
  const handlers = [
@@ -170,6 +176,7 @@ function serve(ctx, engine, heartbeat, options) {
170
176
  }
171
177
  }
172
178
  return () => {
179
+ disposed = true
173
180
  for (const dispose of disposers) {
174
181
  try {
175
182
  dispose()
@@ -212,10 +219,12 @@ export function apply(ctx, config = {}) {
212
219
  graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
213
220
  continueText: typeof config?.continueText === 'string' ? config.continueText : undefined,
214
221
  })
222
+ const epoch = nextEpoch()
215
223
  const engine = createEngine({
216
224
  store,
217
225
  probe,
218
226
  heartbeat,
227
+ epoch,
219
228
  bootId: bootId(),
220
229
  graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
221
230
  log,
@@ -225,6 +234,8 @@ export function apply(ctx, config = {}) {
225
234
  let disposeRoutes = () => {}
226
235
  const cleanup = () => {
227
236
  disposed = true
237
+ // The in-flight press, if any, checks this and refuses to write its stale result.
238
+ dropEpoch(epoch)
228
239
  try {
229
240
  disposeRoutes()
230
241
  } catch {}
@@ -264,21 +275,35 @@ export function apply(ctx, config = {}) {
264
275
  log(`cannot follow agent events: ${error?.message ?? error}`)
265
276
  }
266
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.
280
+ let releaseReady = () => {}
281
+ const ready = new Promise((resolve) => {
282
+ releaseReady = resolve
283
+ })
284
+ const dispose = serve(ctx, engine, heartbeat, { allowRemote }, ready)
285
+ if (disposed) dispose()
286
+ else disposeRoutes = dispose
287
+
267
288
  void (async () => {
268
289
  try {
269
290
  // Read the previous process's record before `start()` overwrites it, and persist the crash
270
291
  // plan before the heartbeat goes away: a second restart then still sees the same work.
271
292
  const residue = heartbeat.peek()
272
- await engine.hydrate({ residue })
293
+ const hydrated = await engine.hydrate({ residue })
273
294
  if (disposed) return
295
+ // Only overwrite the previous process's record after the residue is durable. Otherwise the
296
+ // next boot would read this process's nearly-empty heartbeat and lose the work entirely.
297
+ if (hydrated?.durable === false) {
298
+ log('crash residue is not durable; heartbeat not started')
299
+ return
300
+ }
274
301
  heartbeat.start()
275
- if (disposed) return
276
- const dispose = serve(ctx, engine, heartbeat, { allowRemote })
277
- if (disposed) dispose()
278
- else disposeRoutes = dispose
279
302
  log(`ready (${engine.phase})`)
280
303
  } catch (error) {
281
304
  log(`cannot start: ${error?.message ?? error}`)
305
+ } finally {
306
+ releaseReady()
282
307
  }
283
308
  })()
284
309
 
package/lib/engine.js CHANGED
@@ -19,6 +19,8 @@
19
19
  * still sees the same pending work.
20
20
  */
21
21
 
22
+ import { currentEpoch } from './epoch.js'
23
+
22
24
  const TRANSIENT = new Set(['pausing', 'resuming'])
23
25
  const TERMINAL = new Set(['restored', 'skipped', 'attention'])
24
26
 
@@ -26,7 +28,12 @@ function emptyLive() {
26
28
  return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
27
29
  }
28
30
 
29
- export function createEngine({ store, probe, heartbeat = null, bootId = 'boot', now = () => Date.now(), graceMs = 2000, log = () => {} }) {
31
+ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot', epoch = null, now = () => Date.now(), graceMs = 2000, log = () => {} }) {
32
+ /**
33
+ * A reload creates a new instance and bumps the shared epoch. An old instance's press may still
34
+ * be awaiting a cancelled turn; it must not write its stale plan over the new instance's one.
35
+ */
36
+ const isCurrent = () => epoch === null || currentEpoch() === epoch
30
37
  let phase = 'idle'
31
38
  let lastError = null
32
39
  let result = null
@@ -136,9 +143,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
136
143
  if (busy) action = 'busy'
137
144
  else if (current.readable === true && workCount > 0) action = 'pause'
138
145
  else if (plan !== null) action = 'resume'
139
- const attention = plan !== null
140
- ? (Array.isArray(plan.attention) ? plan.attention.length : 0)
141
- : (Array.isArray(result?.attention) ? result.attention.length : 0)
146
+ const attentionRows = plan !== null ? plan.attention : result?.attention
147
+ const attention = Array.isArray(attentionRows) ? attentionRows.length : 0
148
+ const first = attention > 0 ? attentionRows[0] : null
149
+ const focus = first === null ? null : (typeof first === 'string' ? first : (first.id || first.detail || null))
142
150
  return {
143
151
  phase,
144
152
  action,
@@ -153,15 +161,21 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
153
161
  jobs: current.jobs.length,
154
162
  },
155
163
  attention,
164
+ focus, // ui-copy-ignore
156
165
  error: lastError ?? (plan?.status === 'pausing' ? 'pause-interrupted' : plan?.status === 'partial' ? 'pause-partial' : null), // ui-copy-ignore
157
166
  result,
158
167
  }
159
168
  }
160
169
 
161
170
  function freshSession(session) {
171
+ // A session in the captured live set had its turn open, so the press owns a continuation for it.
172
+ // A crash row is different: it is evidence that a turn was open, not permission to send text.
173
+ const running = session.running === true
162
174
  return {
163
175
  sessionId: session.sessionId,
164
- running: session.running === true,
176
+ running,
177
+ intent: running && session.intent !== 'mount' ? 'continue' : 'mount',
178
+ epoch: running && Number.isSafeInteger(session.epoch) ? session.epoch : 0,
165
179
  lastTurn: Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null,
166
180
  }
167
181
  }
@@ -183,7 +197,13 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
183
197
  if (existing !== undefined) {
184
198
  // Stopping again refreshes the baseline; a plain union would keep a stale turn number.
185
199
  if (Number.isSafeInteger(row.lastTurn)) existing.lastTurn = row.lastTurn
186
- if (row.running === false) existing.running = false
200
+ // A new capture of an actually-running session upgrades a crash-only row: the person then
201
+ // pressed pause while it was running, which is an explicit request to continue it later.
202
+ if (row.intent === 'continue' || row.running === true) {
203
+ existing.intent = 'continue'
204
+ existing.running = true
205
+ }
206
+ if (Number.isSafeInteger(row.epoch) && row.epoch > 0) existing.epoch = row.epoch
187
207
  continue
188
208
  }
189
209
  const copy = { ...row }
@@ -206,6 +226,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
206
226
  }
207
227
 
208
228
  function persist(plan) {
229
+ if (isCurrent() === false) {
230
+ log('stale instance: refusing to write over a newer plan')
231
+ return null
232
+ }
209
233
  if (isCrashPlan(plan) === true) {
210
234
  const stored = store.writePending(plan)
211
235
  if (stored !== null) crashFallback = null
@@ -245,7 +269,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
245
269
  attention: [], // ui-copy-ignore
246
270
  }
247
271
  : { ...basePlan, status: 'pausing', attention: Array.isArray(basePlan.attention) ? basePlan.attention : [] }
272
+ const before = new Map((basePlan?.sessions ?? []).filter((row) => typeof row?.sessionId === 'string').map((row) => [row.sessionId, row]))
273
+ const restoredBefore = Number.isSafeInteger(basePlan?.restore?.attempts) && basePlan.restore.attempts > 0
248
274
  plan.sessions = unionSessions(plan.sessions ?? [], work.sessions.map(freshSession))
275
+ plan.sessions = plan.sessions.map((row) => {
276
+ if (row.intent !== 'continue') return row
277
+ const prior = before.get(row.sessionId)
278
+ const priorEpoch = Number.isSafeInteger(prior?.epoch) ? prior.epoch : 0
279
+ if (Number.isSafeInteger(row.epoch) && row.epoch > priorEpoch) return row
280
+ // A new pause cycle after a restore attempt needs a new request id: the previous one was
281
+ // already admitted, and the official prompt dedupe would silently swallow the new message.
282
+ // Retrying the same pause (no restore attempt yet) keeps the id, so admission stays idempotent.
283
+ return { ...row, epoch: restoredBefore && priorEpoch >= 1 ? priorEpoch + 1 : Math.max(priorEpoch, 1) }
284
+ })
249
285
  plan.goals = unionById(plan.goals ?? [], work.goals.map(freshGoal), (goal) => `${goal?.sessionId}:${goal?.goalId}`)
250
286
  plan.jobs = unionById(plan.jobs ?? [], work.jobs.map(freshJob), (job) => job?.id)
251
287
  if (persist(plan) === null) {
@@ -262,6 +298,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
262
298
  log(`stopping work failed: ${error?.message ?? error}`)
263
299
  stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [], sessions: [] }
264
300
  }
301
+ if (isCurrent() === false) {
302
+ // A reload happened while this press was awaiting the stop. The new instance owns the plan
303
+ // now; writing here would erase whatever it captured in the meantime.
304
+ log('stale instance: pause result discarded')
305
+ return { status: 'failed', reason: 'reloaded' }
306
+ }
265
307
 
266
308
  // A cancelled turn bumps the agent's turn counter, so the baseline is read after the stop.
267
309
  // Without this a restore would mistake its own cancelled turn for work the person continued.
@@ -279,7 +321,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
279
321
  const keptGoals = (plan.goals ?? []).filter((goal) => !(stopped.pausedGoals ?? []).some((row) => row.sessionId === goal.sessionId && row.goalId === goal.goalId))
280
322
  plan.goals = [...keptGoals, ...pausedGoals]
281
323
  const stillRunning = stopped.stillRunning ?? []
282
- plan.status = stillRunning.length > 0 ? 'partial' : 'paused'
324
+ // A session poll alone is not proof the stop was clean: a goal whose pause threw and a job whose
325
+ // kill threw are still live work, and reporting `paused` there hid exactly what needs a person.
326
+ const incomplete = stillRunning.length + (stopped.goalErrors ?? []).length + (stopped.jobErrors ?? []).length
327
+ plan.status = incomplete > 0 ? 'partial' : 'paused'
283
328
  plan.stoppedAt = new Date(now()).toISOString()
284
329
  plan.stopped = {
285
330
  canceled: stopped.canceled ?? [],
@@ -289,11 +334,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
289
334
  killedJobs: stopped.killedJobs ?? [],
290
335
  jobErrors: stopped.jobErrors ?? [],
291
336
  }
337
+ const errorId = (kind, detail) => {
338
+ const match = new RegExp(`^${kind} ([^:]+):`).exec(String(detail))
339
+ return match === null ? '' : match[1].trim()
340
+ }
292
341
  plan.attention = [
293
342
  ...(Array.isArray(basePlan?.attention) ? basePlan.attention : []),
294
343
  ...stillRunning.map((id) => ({ kind: 'session', id, detail: 'still running' })),
295
- ...(stopped.goalErrors ?? []).map((detail) => ({ kind: 'goal', id: '', detail })),
296
- ...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: '', detail })),
344
+ ...(stopped.goalErrors ?? []).map((detail) => {
345
+ const goalId = errorId('goal', detail)
346
+ const owner = [...(basePlan?.goals ?? []), ...(current.goals ?? [])].find((row) => row.goalId === goalId)
347
+ return { kind: 'goal', id: owner?.sessionId ?? goalId, detail }
348
+ }),
349
+ ...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: errorId('job', detail), detail })),
297
350
  ]
298
351
  if (isCrashPlan(basePlan) === true) {
299
352
  // The crash record described work that was not paused. This press did pause it, so the plan
@@ -361,19 +414,29 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
361
414
  const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
362
415
  if (TERMINAL.has(status) === false) {
363
416
  summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
364
- return
417
+ return outcome
365
418
  }
366
419
  record(key, status)
367
420
  if (status === 'restored') summary.restored.push(label)
368
421
  else if (status === 'skipped') summary.skipped.push(label)
369
422
  else summary.attention.push(`${label}: ${outcome?.detail ?? 'needs a look'}`)
423
+ return outcome
370
424
  }
371
425
 
372
426
  for (const session of plan.sessions ?? []) {
373
427
  await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
374
428
  }
375
429
  for (const goal of plan.goals ?? []) {
376
- await step(`g:${goal.sessionId}:${goal.goalId ?? ''}`, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
430
+ const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
431
+ const outcome = await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
432
+ if (outcome?.status === 'restored' && Number.isSafeInteger(outcome.revision)) {
433
+ // Remember what this plan armed. A restarted process sees `active + disarmed`, and that
434
+ // exact revision is the only proof the goal is ours to arm again rather than someone else's.
435
+ plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
436
+ ? { ...row, armedRevision: outcome.revision }
437
+ : row))
438
+ if (persist(plan) === null) lastError = 'store'
439
+ }
377
440
  }
378
441
  // A killed job cannot be replayed: the same command may have had an unknown effect, so it is
379
442
  // reported once and left to the person. It never blocks the plan and is never restarted.
@@ -401,6 +464,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
401
464
  }
402
465
  crashFallback = null
403
466
  if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
467
+ if (isCurrent() === false) {
468
+ log('stale instance: crash record left alone')
469
+ return { status: 'partial', summary, outstanding: 0 }
470
+ }
404
471
  try {
405
472
  heartbeat?.release?.()
406
473
  } catch (error) {
@@ -470,27 +537,74 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
470
537
  */
471
538
  async function hydrate(options = {}) {
472
539
  crashFallback = null
540
+ lastError = null
473
541
  const residue = options?.residue ?? heartbeat?.residue ?? null
474
- if (residue !== null && residue !== undefined && pausedPlan() === null && crashPlan() === null) {
475
- let plan = null
542
+ const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
543
+ result = restored !== null && typeof restored === 'object' ? (restored.summary ?? null) : null
544
+ let durable = true
545
+
546
+ if (residue !== null && residue !== undefined) {
547
+ let crash = null
476
548
  try {
477
- plan = probe.crashPlan(residue, { planId: `crash-${Number.isFinite(residue.at) ? residue.at : 0}-${Array.isArray(residue.sessions) ? residue.sessions.length : 0}` })
549
+ crash = probe.crashPlan(residue, {
550
+ planId: `crash-${Number.isFinite(residue.at) ? residue.at : 0}-${Array.isArray(residue.sessions) ? residue.sessions.length : 0}`,
551
+ })
478
552
  } catch (error) {
479
553
  log(`cannot derive crash plan: ${error?.message ?? error}`)
480
554
  }
481
- if (plan !== null) {
482
- const stored = typeof store.writePending === 'function' ? store.writePending(plan) : null
483
- // A failed write keeps the plan in memory so this process can still restore it; only the
484
- // next restart loses it.
485
- crashFallback = stored ?? plan
486
- log('crash plan recorded')
555
+ if (crash !== null) {
556
+ const existing = pausedPlan() ?? crashPlan()
557
+ if (existing === null) {
558
+ const stored = typeof store.writePending === 'function' ? store.writePending(crash) : null
559
+ // A failed write stays in memory for this process, but the caller must not let the
560
+ // heartbeat overwrite the file the next process would have read the same residue from.
561
+ if (stored === null) {
562
+ crashFallback = crash
563
+ durable = false
564
+ lastError = 'store'
565
+ heartbeat?.hold?.()
566
+ log('cannot record the crash plan; keeping the previous heartbeat')
567
+ } else {
568
+ log('crash plan recorded')
569
+ }
570
+ } else {
571
+ // A plan from an earlier pause outranks the crash record, but it must not hide it: every
572
+ // session the crash residue names is merged in as mount-only work. Otherwise a crash that
573
+ // happened after a partial restore would silently drop sessions the next press captured.
574
+ const known = new Set((existing.sessions ?? []).map((row) => row?.sessionId))
575
+ const added = crash.sessions
576
+ .filter((row) => known.has(row.sessionId) === false)
577
+ .map((row) => ({ sessionId: row.sessionId, intent: 'mount', epoch: 0, running: false, lastTurn: null, crash: true }))
578
+ if (added.length > 0) {
579
+ const plan = {
580
+ ...existing,
581
+ sessions: [...(existing.sessions ?? []), ...added],
582
+ crashes: (Number.isSafeInteger(existing.crashes) ? existing.crashes : 0) + added.length,
583
+ attention: [
584
+ ...(Array.isArray(existing.attention) ? existing.attention : []),
585
+ ...added.map((row) => ({ kind: 'session', id: row.sessionId, detail: 'crashed: mounted only' })),
586
+ ],
587
+ }
588
+ const isCrash = isCrashPlan(plan)
589
+ const stored = isCrash
590
+ ? (typeof store.writePending === 'function' ? store.writePending(plan) : null)
591
+ : store.write(plan)
592
+ if (stored === null) {
593
+ durable = false
594
+ lastError = 'store'
595
+ if (isCrash) crashFallback = plan
596
+ heartbeat?.hold?.()
597
+ log('cannot merge the crash plan; keeping the previous heartbeat')
598
+ } else {
599
+ log(`crash sessions merged (${added.length})`)
600
+ }
601
+ }
602
+ }
487
603
  }
488
604
  }
489
- const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
490
- result = restored !== null && typeof restored === 'object' ? (restored.summary ?? null) : null
605
+
491
606
  phase = pausedPlan() !== null ? 'paused' : 'idle'
492
- lastError = null
493
- return phase
607
+ return { phase, durable }
494
608
  }
495
609
 
496
610
  return {
package/lib/epoch.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * One epoch per plugin instance, shared across HMR module evaluations.
3
+ *
4
+ * A reload can leave the previous engine's press mid-await. Without a generation marker that old
5
+ * press must not overwrite the plan the new instance wrote. The marker lives on `globalThis`, so a
6
+ * re-evaluated module sees the same counter, and every write checks it.
7
+ */
8
+
9
+ const KEY = Symbol.for('dsh-pause-resume.epoch')
10
+
11
+ export function nextEpoch() {
12
+ const holder = globalThis
13
+ const value = Number.isSafeInteger(holder[KEY]) ? holder[KEY] : 0
14
+ holder[KEY] = value + 1
15
+ return holder[KEY]
16
+ }
17
+
18
+ export function currentEpoch() {
19
+ const value = globalThis[KEY]
20
+ return Number.isSafeInteger(value) ? value : 0
21
+ }
22
+
23
+ /** A disposed instance invalidates its own epoch, so its in-flight writes become no-ops. */
24
+ export function dropEpoch(epoch) {
25
+ if (currentEpoch() !== epoch) return
26
+ nextEpoch()
27
+ }
package/lib/heartbeat.js CHANGED
@@ -34,6 +34,8 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
34
34
  let stopped = false
35
35
  let pending = []
36
36
  let lastWritten = ''
37
+ /** A boot whose crash residue could not be recorded must not overwrite the previous heartbeat. */
38
+ let writable = true
37
39
 
38
40
  function noteError(message) {
39
41
  try {
@@ -149,6 +151,7 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
149
151
  }
150
152
 
151
153
  function writePending() {
154
+ if (writable === false) return false
152
155
  return dirty() ? persist(pending) : true
153
156
  }
154
157
 
@@ -225,8 +228,15 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
225
228
  }
226
229
  }
227
230
 
231
+ /** Freeze the file after a failed crash-plan write: it is the only remaining copy of that work. */
232
+ function hold() {
233
+ writable = false
234
+ stop()
235
+ }
236
+
228
237
  /** The residue was restored: from here on the heartbeat describes this process again. */
229
238
  function release() {
239
+ writable = true
230
240
  residue = null
231
241
  void refresh(true)
232
242
  }
@@ -236,6 +246,7 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
236
246
  peek,
237
247
  start,
238
248
  stop,
249
+ hold,
239
250
  release,
240
251
  flush,
241
252
  get residue() {
package/lib/probe.js CHANGED
@@ -232,10 +232,14 @@ export function createProbe(services, options = {}) {
232
232
  }
233
233
 
234
234
  /** The continuation message: a real user message, so the session history shows why it moved. */
235
- function continuation(plan, sessionId) {
235
+ function continuation(plan, sessionId, epoch) {
236
+ // The epoch is the pause cycle. A retry of the same pause reuses the id (official dedupe keeps
237
+ // admission idempotent), while a new pause after a partial restore gets a fresh one, so the
238
+ // earlier admitted message cannot swallow this cycle's continuation.
239
+ const cycle = Number.isSafeInteger(epoch) && epoch > 0 ? epoch : 1
236
240
  return {
237
241
  sessionId,
238
- requestId: `${plan.planId}:continue:${sessionId}`,
242
+ requestId: `${plan.planId}:continue:${sessionId}:${cycle}`,
239
243
  content: [{ type: 'text', text: continueText }],
240
244
  // `queue` is the shipped public mode: deliver after the current turn, start one when idle.
241
245
  mode: 'queue',
@@ -275,24 +279,40 @@ export function createProbe(services, options = {}) {
275
279
  try {
276
280
  agent = await mount(sessionId)
277
281
  } catch (error) {
278
- return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
282
+ const detail = String(error?.message ?? error).slice(0, 120)
283
+ // A subagent row belongs to the parent chat that owns its routing; this process may not
284
+ // claim it directly, and a retry would fail the same way forever. The parent restores it.
285
+ if (/owned by subagent routing/i.test(detail)) {
286
+ return { status: 'attention', detail: 'subagent session; its parent restores it' }
287
+ }
288
+ return { status: 'failed', detail }
279
289
  }
280
290
  }
281
291
 
282
- if (session.running !== true) return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
292
+ // Version 2 plans carry only `running`; a normalized version 3 plan carries `intent`. Reading
293
+ // the old field here keeps a plan written by 0.3.0 working across the upgrade.
294
+ const intent = session.intent === undefined && session.running === true ? 'continue' : session.intent
295
+ if (intent !== 'continue') {
296
+ return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
297
+ }
283
298
 
284
- // Someone continued by hand after the pause: the press must not add a second turn.
299
+ // A turn that is already open is not evidence the continuation arrived, but it is work in
300
+ // progress: starting a second one would be a duplicate. A turn that advanced and then ended is
301
+ // different: it may have been the continuation, or it may have been interrupted after the
302
+ // request was admitted. Neither case allows resending, and neither may be reported as a clean
303
+ // `skipped` with no attention.
285
304
  const baseline = Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null
286
305
  const turnBefore = lastTurnOf(agent)
287
- if (agent.status === 'running' || (baseline !== null && turnBefore !== null && turnBefore > baseline)) {
288
- return { status: 'skipped', detail: 'already continued', continued: false }
306
+ if (agent.status === 'running') return { status: 'skipped', detail: 'already running', continued: false }
307
+ if (baseline !== null && turnBefore !== null && turnBefore > baseline) {
308
+ return { status: 'attention', detail: 'turn advanced after the pause; not sent again', continued: false }
289
309
  }
290
310
 
291
311
  const controller = service(services, 'sessionController')
292
312
  if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
293
313
  return { status: 'failed', detail: 'prompt unavailable', continued: false }
294
314
  }
295
- const request = continuation(plan, sessionId)
315
+ const request = continuation(plan, sessionId, session.epoch)
296
316
  try {
297
317
  // Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
298
318
  // here made every restore fail on a live Harness while the fake in the tests accepted it.
@@ -344,12 +364,21 @@ export function createProbe(services, options = {}) {
344
364
  if (view === undefined || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
345
365
  return { status: 'skipped', detail: 'goal is gone' }
346
366
  }
347
- // A goal that is already active and armed was restored by an earlier press, or re-armed by the
348
- // person: it must not be touched again. A disarmed active goal is not ours to arm either.
367
+ // An active goal is normally not ours to touch. The one exception is a goal this same plan
368
+ // armed in an earlier process: activation is per-process, so a restart leaves it `disarmed` at
369
+ // exactly the revision the plan recorded. Another person's goal has no such record.
349
370
  if (view.phase === 'active') {
350
- return view.activation === 'armed'
351
- ? { status: 'skipped', detail: 'already active' }
352
- : { status: 'skipped', detail: 'goal disarmed' }
371
+ if (view.activation === 'armed') return { status: 'skipped', detail: 'already active' }
372
+ if (Number.isSafeInteger(goal.armedRevision) && goal.armedRevision === view.revision) {
373
+ try {
374
+ services_goals.resume(agent, { id: view.id, revision: view.revision })
375
+ } catch (error) {
376
+ return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
377
+ }
378
+ const after = goalView(services, agent)
379
+ return { status: 'restored', detail: 're-armed after restart', revision: after?.revision ?? null }
380
+ }
381
+ return { status: 'skipped', detail: 'goal disarmed' }
353
382
  }
354
383
  if (view.phase !== 'paused') return { status: 'skipped', detail: `goal is ${view.phase}` }
355
384
  // The saved revision is the person's veto: a goal edited after the pause is not ours to resume.
@@ -366,7 +395,8 @@ export function createProbe(services, options = {}) {
366
395
  } catch (error) {
367
396
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
368
397
  }
369
- return { status: 'restored', detail: 'armed' }
398
+ const after = goalView(services, agent)
399
+ return { status: 'restored', detail: 'armed', revision: after?.revision ?? null }
370
400
  }
371
401
 
372
402
  /**
@@ -379,7 +409,7 @@ export function createProbe(services, options = {}) {
379
409
  const rows = Array.isArray(residue.sessions) ? residue.sessions : []
380
410
  const sessions = rows
381
411
  .filter((row) => typeof row?.sessionId === 'string')
382
- .map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null, crash: true }))
412
+ .map((row) => ({ sessionId: row.sessionId, intent: 'mount', epoch: 0, running: false, lastTurn: null, crash: true }))
383
413
  if (sessions.length === 0) return null
384
414
  return {
385
415
  version: 3, // ui-copy-ignore
package/lib/snapshot.js CHANGED
@@ -144,6 +144,24 @@ export function createStore(dir, options = {}) {
144
144
  if (typeof plan.planId !== 'string' || plan.planId === '') plan.planId = `plan-${plan.createdAt ?? stamp}`
145
145
  if (Array.isArray(plan.attention) === false) plan.attention = []
146
146
  if (plan.status === 'pausing') plan.interrupted = true
147
+ // `running` used to mean two different things: "this press stopped a turn here" (continue on
148
+ // resume) and "this session is merely in the plan" (mount only). Version 3 records the intent
149
+ // explicitly so a crash-only row can never turn into a continuation by accident.
150
+ plan.sessions = (Array.isArray(plan.sessions) ? plan.sessions : []).map((row) => {
151
+ if (typeof row?.sessionId !== 'string') return row
152
+ const intent = row.intent === 'continue' || row.intent === 'mount'
153
+ ? row.intent
154
+ : (row.running === true ? 'continue' : 'mount')
155
+ const epoch = Number.isSafeInteger(row.epoch) && row.epoch > 0
156
+ ? row.epoch
157
+ : (intent === 'continue' ? 1 : 0)
158
+ return { ...row, intent, epoch, running: intent === 'continue' }
159
+ })
160
+ plan.goals = (Array.isArray(plan.goals) ? plan.goals : []).map((row) => (
161
+ row !== null && typeof row === 'object' && Number.isSafeInteger(row.armedRevision) === false
162
+ ? { ...row, armedRevision: null }
163
+ : row
164
+ ))
147
165
  return plan
148
166
  }
149
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pause-resume",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",
@@ -18,6 +18,7 @@
18
18
  "locale/*.json",
19
19
  "icon.svg",
20
20
  "README.md",
21
+ "CHANGELOG.md",
21
22
  "LICENSE"
22
23
  ],
23
24
  "keywords": [