dsh-pause-resume 0.3.0 → 0.4.1

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,49 @@
1
+ # Changelog
2
+
3
+ ## 0.4.1
4
+
5
+ A stop that could not finish no longer traps the button, and the dot stops lying after a resume.
6
+
7
+ * An unfinished stop now always offers the resume that releases it. The button used to prefer
8
+ `pause` whenever live work remained, so a partial stop re-ran the same stop on the same
9
+ uncancellable turn: the icon never changed, the attention dot stayed, and pressing it did
10
+ nothing a person could see.
11
+ * Work that starts while a plan waits no longer flips the button back to pause. The plan owns the
12
+ action until it is consumed.
13
+ ## 0.4.0
14
+
15
+ The crash path is now a first-class restore path, and a dead button after a restart is fixed.
16
+
17
+ * Routes are registered synchronously while `apply` runs. The web server freezes its route table
18
+ when it starts, so registering after `hydrate()` left the button answering 404 on a fresh boot.
19
+ Handlers now wait for hydration instead of the route table waiting for it.
20
+ * A crash plan is written to `pending.json` before the heartbeat is replaced. If that write fails,
21
+ the heartbeat is held and the previous `running.json` is left untouched, so the next boot still
22
+ sees the work.
23
+ * Crash rows record `intent: "mount"` and are mounted only; they can never turn into a continuation
24
+ by accident. A pause row records `intent: "continue"`.
25
+ * A subagent session that cannot be mounted directly is reported once as attention for its parent,
26
+ not retried forever as a failure that keeps the plan open.
27
+ * Partial pauses and partial restores name the first item that needs a person in `focus`, so the
28
+ button's dot has an explanation without adding text to the button.
29
+ * A hot reload bumps a shared epoch; an old instance's in-flight press can no longer overwrite the
30
+ plan written by the new instance.
31
+ * A continuation request id gets a new epoch after a restore attempt, so pausing again after a
32
+ partial restore is not silently deduplicated by the Host.
33
+ * Directed regression tests for the route-registration order, crash-plan durability, subagent rows
34
+ and stale-instance writes.
35
+
36
+ ## 0.3.0
37
+
38
+ * Crash residue is turned into a plan and offered as a restore, with no press needed.
39
+ * Version 3 plans distinguish mount-only crash rows from continuation rows.
40
+ * Restores re-check live state instead of trusting the journal.
41
+
42
+ ## 0.2.0
43
+
44
+ * One header button: pause stops turns, goals and jobs; resume restores them after a restart.
45
+ * Durable snapshots, per-item outcomes, journaled restore attempts.
46
+
47
+ ## 0.1.0
48
+
49
+ * 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.1'
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
@@ -132,13 +139,18 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
132
139
  const work = workOf(current)
133
140
  const busy = TRANSIENT.has(phase)
134
141
  const workCount = liveWorkCount(work)
142
+ // A plan is an unfinished stop: the only way forward is to resume it, even when the stop left
143
+ // work behind. Preferring `pause` there used to trap a partial stop forever: every press
144
+ // re-ran the same stop, the residue could never be cancelled, and the button never offered the
145
+ // resume that would have released the plan.
135
146
  let action = 'none'
136
147
  if (busy) action = 'busy'
137
- else if (current.readable === true && workCount > 0) action = 'pause'
138
148
  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)
149
+ else if (current.readable === true && workCount > 0) action = 'pause'
150
+ const attentionRows = plan !== null ? plan.attention : result?.attention
151
+ const attention = Array.isArray(attentionRows) ? attentionRows.length : 0
152
+ const first = attention > 0 ? attentionRows[0] : null
153
+ const focus = first === null ? null : (typeof first === 'string' ? first : (first.id || first.detail || null))
142
154
  return {
143
155
  phase,
144
156
  action,
@@ -153,15 +165,21 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
153
165
  jobs: current.jobs.length,
154
166
  },
155
167
  attention,
168
+ focus, // ui-copy-ignore
156
169
  error: lastError ?? (plan?.status === 'pausing' ? 'pause-interrupted' : plan?.status === 'partial' ? 'pause-partial' : null), // ui-copy-ignore
157
170
  result,
158
171
  }
159
172
  }
160
173
 
161
174
  function freshSession(session) {
175
+ // A session in the captured live set had its turn open, so the press owns a continuation for it.
176
+ // A crash row is different: it is evidence that a turn was open, not permission to send text.
177
+ const running = session.running === true
162
178
  return {
163
179
  sessionId: session.sessionId,
164
- running: session.running === true,
180
+ running,
181
+ intent: running && session.intent !== 'mount' ? 'continue' : 'mount',
182
+ epoch: running && Number.isSafeInteger(session.epoch) ? session.epoch : 0,
165
183
  lastTurn: Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null,
166
184
  }
167
185
  }
@@ -183,7 +201,13 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
183
201
  if (existing !== undefined) {
184
202
  // Stopping again refreshes the baseline; a plain union would keep a stale turn number.
185
203
  if (Number.isSafeInteger(row.lastTurn)) existing.lastTurn = row.lastTurn
186
- if (row.running === false) existing.running = false
204
+ // A new capture of an actually-running session upgrades a crash-only row: the person then
205
+ // pressed pause while it was running, which is an explicit request to continue it later.
206
+ if (row.intent === 'continue' || row.running === true) {
207
+ existing.intent = 'continue'
208
+ existing.running = true
209
+ }
210
+ if (Number.isSafeInteger(row.epoch) && row.epoch > 0) existing.epoch = row.epoch
187
211
  continue
188
212
  }
189
213
  const copy = { ...row }
@@ -206,6 +230,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
206
230
  }
207
231
 
208
232
  function persist(plan) {
233
+ if (isCurrent() === false) {
234
+ log('stale instance: refusing to write over a newer plan')
235
+ return null
236
+ }
209
237
  if (isCrashPlan(plan) === true) {
210
238
  const stored = store.writePending(plan)
211
239
  if (stored !== null) crashFallback = null
@@ -245,7 +273,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
245
273
  attention: [], // ui-copy-ignore
246
274
  }
247
275
  : { ...basePlan, status: 'pausing', attention: Array.isArray(basePlan.attention) ? basePlan.attention : [] }
276
+ const before = new Map((basePlan?.sessions ?? []).filter((row) => typeof row?.sessionId === 'string').map((row) => [row.sessionId, row]))
277
+ const restoredBefore = Number.isSafeInteger(basePlan?.restore?.attempts) && basePlan.restore.attempts > 0
248
278
  plan.sessions = unionSessions(plan.sessions ?? [], work.sessions.map(freshSession))
279
+ plan.sessions = plan.sessions.map((row) => {
280
+ if (row.intent !== 'continue') return row
281
+ const prior = before.get(row.sessionId)
282
+ const priorEpoch = Number.isSafeInteger(prior?.epoch) ? prior.epoch : 0
283
+ if (Number.isSafeInteger(row.epoch) && row.epoch > priorEpoch) return row
284
+ // A new pause cycle after a restore attempt needs a new request id: the previous one was
285
+ // already admitted, and the official prompt dedupe would silently swallow the new message.
286
+ // Retrying the same pause (no restore attempt yet) keeps the id, so admission stays idempotent.
287
+ return { ...row, epoch: restoredBefore && priorEpoch >= 1 ? priorEpoch + 1 : Math.max(priorEpoch, 1) }
288
+ })
249
289
  plan.goals = unionById(plan.goals ?? [], work.goals.map(freshGoal), (goal) => `${goal?.sessionId}:${goal?.goalId}`)
250
290
  plan.jobs = unionById(plan.jobs ?? [], work.jobs.map(freshJob), (job) => job?.id)
251
291
  if (persist(plan) === null) {
@@ -262,6 +302,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
262
302
  log(`stopping work failed: ${error?.message ?? error}`)
263
303
  stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [], sessions: [] }
264
304
  }
305
+ if (isCurrent() === false) {
306
+ // A reload happened while this press was awaiting the stop. The new instance owns the plan
307
+ // now; writing here would erase whatever it captured in the meantime.
308
+ log('stale instance: pause result discarded')
309
+ return { status: 'failed', reason: 'reloaded' }
310
+ }
265
311
 
266
312
  // A cancelled turn bumps the agent's turn counter, so the baseline is read after the stop.
267
313
  // Without this a restore would mistake its own cancelled turn for work the person continued.
@@ -279,7 +325,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
279
325
  const keptGoals = (plan.goals ?? []).filter((goal) => !(stopped.pausedGoals ?? []).some((row) => row.sessionId === goal.sessionId && row.goalId === goal.goalId))
280
326
  plan.goals = [...keptGoals, ...pausedGoals]
281
327
  const stillRunning = stopped.stillRunning ?? []
282
- plan.status = stillRunning.length > 0 ? 'partial' : 'paused'
328
+ // A session poll alone is not proof the stop was clean: a goal whose pause threw and a job whose
329
+ // kill threw are still live work, and reporting `paused` there hid exactly what needs a person.
330
+ const incomplete = stillRunning.length + (stopped.goalErrors ?? []).length + (stopped.jobErrors ?? []).length
331
+ plan.status = incomplete > 0 ? 'partial' : 'paused'
283
332
  plan.stoppedAt = new Date(now()).toISOString()
284
333
  plan.stopped = {
285
334
  canceled: stopped.canceled ?? [],
@@ -289,11 +338,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
289
338
  killedJobs: stopped.killedJobs ?? [],
290
339
  jobErrors: stopped.jobErrors ?? [],
291
340
  }
341
+ const errorId = (kind, detail) => {
342
+ const match = new RegExp(`^${kind} ([^:]+):`).exec(String(detail))
343
+ return match === null ? '' : match[1].trim()
344
+ }
292
345
  plan.attention = [
293
346
  ...(Array.isArray(basePlan?.attention) ? basePlan.attention : []),
294
347
  ...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 })),
348
+ ...(stopped.goalErrors ?? []).map((detail) => {
349
+ const goalId = errorId('goal', detail)
350
+ const owner = [...(basePlan?.goals ?? []), ...(current.goals ?? [])].find((row) => row.goalId === goalId)
351
+ return { kind: 'goal', id: owner?.sessionId ?? goalId, detail }
352
+ }),
353
+ ...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: errorId('job', detail), detail })),
297
354
  ]
298
355
  if (isCrashPlan(basePlan) === true) {
299
356
  // The crash record described work that was not paused. This press did pause it, so the plan
@@ -361,19 +418,29 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
361
418
  const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
362
419
  if (TERMINAL.has(status) === false) {
363
420
  summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
364
- return
421
+ return outcome
365
422
  }
366
423
  record(key, status)
367
424
  if (status === 'restored') summary.restored.push(label)
368
425
  else if (status === 'skipped') summary.skipped.push(label)
369
426
  else summary.attention.push(`${label}: ${outcome?.detail ?? 'needs a look'}`)
427
+ return outcome
370
428
  }
371
429
 
372
430
  for (const session of plan.sessions ?? []) {
373
431
  await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
374
432
  }
375
433
  for (const goal of plan.goals ?? []) {
376
- await step(`g:${goal.sessionId}:${goal.goalId ?? ''}`, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
434
+ const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
435
+ const outcome = await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
436
+ if (outcome?.status === 'restored' && Number.isSafeInteger(outcome.revision)) {
437
+ // Remember what this plan armed. A restarted process sees `active + disarmed`, and that
438
+ // exact revision is the only proof the goal is ours to arm again rather than someone else's.
439
+ plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
440
+ ? { ...row, armedRevision: outcome.revision }
441
+ : row))
442
+ if (persist(plan) === null) lastError = 'store'
443
+ }
377
444
  }
378
445
  // A killed job cannot be replayed: the same command may have had an unknown effect, so it is
379
446
  // reported once and left to the person. It never blocks the plan and is never restarted.
@@ -401,6 +468,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
401
468
  }
402
469
  crashFallback = null
403
470
  if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
471
+ if (isCurrent() === false) {
472
+ log('stale instance: crash record left alone')
473
+ return { status: 'partial', summary, outstanding: 0 }
474
+ }
404
475
  try {
405
476
  heartbeat?.release?.()
406
477
  } catch (error) {
@@ -470,27 +541,74 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
470
541
  */
471
542
  async function hydrate(options = {}) {
472
543
  crashFallback = null
544
+ lastError = null
473
545
  const residue = options?.residue ?? heartbeat?.residue ?? null
474
- if (residue !== null && residue !== undefined && pausedPlan() === null && crashPlan() === null) {
475
- let plan = null
546
+ const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
547
+ result = restored !== null && typeof restored === 'object' ? (restored.summary ?? null) : null
548
+ let durable = true
549
+
550
+ if (residue !== null && residue !== undefined) {
551
+ let crash = null
476
552
  try {
477
- plan = probe.crashPlan(residue, { planId: `crash-${Number.isFinite(residue.at) ? residue.at : 0}-${Array.isArray(residue.sessions) ? residue.sessions.length : 0}` })
553
+ crash = probe.crashPlan(residue, {
554
+ planId: `crash-${Number.isFinite(residue.at) ? residue.at : 0}-${Array.isArray(residue.sessions) ? residue.sessions.length : 0}`,
555
+ })
478
556
  } catch (error) {
479
557
  log(`cannot derive crash plan: ${error?.message ?? error}`)
480
558
  }
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')
559
+ if (crash !== null) {
560
+ const existing = pausedPlan() ?? crashPlan()
561
+ if (existing === null) {
562
+ const stored = typeof store.writePending === 'function' ? store.writePending(crash) : null
563
+ // A failed write stays in memory for this process, but the caller must not let the
564
+ // heartbeat overwrite the file the next process would have read the same residue from.
565
+ if (stored === null) {
566
+ crashFallback = crash
567
+ durable = false
568
+ lastError = 'store'
569
+ heartbeat?.hold?.()
570
+ log('cannot record the crash plan; keeping the previous heartbeat')
571
+ } else {
572
+ log('crash plan recorded')
573
+ }
574
+ } else {
575
+ // A plan from an earlier pause outranks the crash record, but it must not hide it: every
576
+ // session the crash residue names is merged in as mount-only work. Otherwise a crash that
577
+ // happened after a partial restore would silently drop sessions the next press captured.
578
+ const known = new Set((existing.sessions ?? []).map((row) => row?.sessionId))
579
+ const added = crash.sessions
580
+ .filter((row) => known.has(row.sessionId) === false)
581
+ .map((row) => ({ sessionId: row.sessionId, intent: 'mount', epoch: 0, running: false, lastTurn: null, crash: true }))
582
+ if (added.length > 0) {
583
+ const plan = {
584
+ ...existing,
585
+ sessions: [...(existing.sessions ?? []), ...added],
586
+ crashes: (Number.isSafeInteger(existing.crashes) ? existing.crashes : 0) + added.length,
587
+ attention: [
588
+ ...(Array.isArray(existing.attention) ? existing.attention : []),
589
+ ...added.map((row) => ({ kind: 'session', id: row.sessionId, detail: 'crashed: mounted only' })),
590
+ ],
591
+ }
592
+ const isCrash = isCrashPlan(plan)
593
+ const stored = isCrash
594
+ ? (typeof store.writePending === 'function' ? store.writePending(plan) : null)
595
+ : store.write(plan)
596
+ if (stored === null) {
597
+ durable = false
598
+ lastError = 'store'
599
+ if (isCrash) crashFallback = plan
600
+ heartbeat?.hold?.()
601
+ log('cannot merge the crash plan; keeping the previous heartbeat')
602
+ } else {
603
+ log(`crash sessions merged (${added.length})`)
604
+ }
605
+ }
606
+ }
487
607
  }
488
608
  }
489
- const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
490
- result = restored !== null && typeof restored === 'object' ? (restored.summary ?? null) : null
609
+
491
610
  phase = pausedPlan() !== null ? 'paused' : 'idle'
492
- lastError = null
493
- return phase
611
+ return { phase, durable }
494
612
  }
495
613
 
496
614
  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.1",
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": [