dsh-pause-resume 0.2.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
@@ -40,15 +40,22 @@ A restore is not a claim that work continued. It is three honest steps per item:
40
40
  3. **Re-arm a goal** with the revision it had when paused. The revision is the person's veto: if the
41
41
  goal changed while paused, it is not ours to resume and the item is `skipped`.
42
42
 
43
- Every finished item is journaled before the plan is archived, so a crash in the middle of a restore
44
- continues with the remainder instead of restoring the same session twice. A failure leaves the plan
45
- and the journal on disk: the next press retries only the failed item.
43
+ Every attempt is journaled with the id of the process that made it, but a journal is reporting, not a
44
+ promise: the next boot asks the live Harness again instead of trusting an old row. The continuation
45
+ request id is derived from the plan and the session, so a retry before a turn starts is deduplicated
46
+ by the Host. A failure leaves the plan on disk and the next press retries every item that still needs
47
+ work.
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.
46
51
 
47
52
  ## Crash, not just a press
48
53
 
49
54
  A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
50
55
  `running.json` holds the sessions whose turn is open right now and the id of the process that wrote
51
- it. A new process finds the previous process's record and offers ▶ with no press needed.
56
+ it. The previous process's record is turned into a crash plan in `pending.json` before the heartbeat
57
+ file is replaced, so a second or third restart still sees the same work and offers ▶ with no press
58
+ needed.
52
59
 
53
60
  A crash record is deliberately weaker than a press: it proves a turn was open, not whether the person
54
61
  wanted it continued. So restore mounts those sessions and lists them in the outcome; it does not
@@ -74,8 +81,8 @@ machine; local-only is the default.
74
81
  a double press and a tool call cannot overlap. The second caller is told `busy`.
75
82
  * **Durable before destructive.** No turn is cancelled until the plan is on disk. A failed write means
76
83
  nothing was stopped.
77
- * **Idempotent.** A restore journals every finished item; the continuation request id is derived from
78
- the plan, so a retry cannot post the same message twice.
84
+ * **Idempotent.** The continuation request id is derived from the plan and the session, so a retry
85
+ cannot post the same message twice; a turn that already moved is not continued again.
79
86
  * **Honest.** Unreadable state never reports `paused`. A session deleted by hand, a goal changed by
80
87
  hand, a session already running: each is skipped and named in the outcome.
81
88
  * **No hidden takeover.** There is no public API to detach an agent, so the pause guarantee is
@@ -92,7 +99,8 @@ All state lives under `$DSH_HOME/pause-resume/`:
92
99
  | File | Purpose |
93
100
  | --- | --- |
94
101
  | `snapshot.json` | the work a press stopped, `pausing` or `paused` |
95
- | `journal/<planId>.jsonl` | restore items already finished |
102
+ | `pending.json` | a crash plan, kept until a restore finishes |
103
+ | `journal/<planId>.jsonl` | restore attempts, tagged with the boot that made them |
96
104
  | `restores/<planId>.json` | the archived plan, kept as a record of the last restore |
97
105
  | `last-restore.json` | summary of the last restore |
98
106
  | `running.json` | sessions with an open turn, for crash residue |
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
  }
@@ -172,24 +175,36 @@ window.__ModuleLoader__.load({
172
175
  ensureStyle()
173
176
  mounted.current = true
174
177
  let timer = null
178
+ let stopped = false
175
179
  let current = null
176
- const beat = () => {
177
- if (document.visibilityState !== 'visible' || inflight.current !== null) return
178
- try {
179
- current?.abort()
180
- } catch {}
180
+ // One request at a time, and the next one starts after this one settles. A fixed interval
181
+ // that aborts its own predecessor made any answer slower than the interval invisible.
182
+ const beat = async () => {
183
+ timer = null
184
+ if (stopped || document.visibilityState !== 'visible' || inflight.current !== null) { schedule() ; return }
181
185
  current = typeof AbortController === 'function' ? new AbortController() : null
182
- void pull(current?.signal)
186
+ try {
187
+ await pull(current?.signal)
188
+ } finally {
189
+ current = null
190
+ schedule()
191
+ }
192
+ }
193
+ const schedule = () => {
194
+ if (stopped || timer !== null) return
195
+ timer = setTimeout(() => { void beat() }, POLL_MS)
183
196
  }
184
- beat()
185
- timer = setInterval(beat, POLL_MS)
186
197
  const onVisible = () => {
187
- if (document.visibilityState === 'visible') beat()
198
+ if (document.visibilityState !== 'visible') return
199
+ if (timer !== null) { clearTimeout(timer); timer = null }
200
+ void beat()
188
201
  }
202
+ void beat()
189
203
  document.addEventListener('visibilitychange', onVisible)
190
204
  return () => {
205
+ stopped = true
191
206
  mounted.current = false
192
- if (timer !== null) clearInterval(timer)
207
+ if (timer !== null) clearTimeout(timer)
193
208
  try {
194
209
  current?.abort()
195
210
  } catch {}
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.2.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 = [
@@ -147,7 +153,7 @@ function serve(ctx, engine, heartbeat, options) {
147
153
  route(async (req, res) => {
148
154
  const outcome = await engine.pause()
149
155
  log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
150
- if (outcome.status === 'paused') heartbeat.release()
156
+ if (outcome.status === 'paused' || outcome.status === 'partial') heartbeat.release()
151
157
  sendJson(res, 200, { ...engine.describe(), outcome })
152
158
  }, false),
153
159
  ],
@@ -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,13 @@ 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,
228
+ bootId: bootId(),
219
229
  graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
220
230
  log,
221
231
  })
@@ -224,6 +234,8 @@ export function apply(ctx, config = {}) {
224
234
  let disposeRoutes = () => {}
225
235
  const cleanup = () => {
226
236
  disposed = true
237
+ // The in-flight press, if any, checks this and refuses to write its stale result.
238
+ dropEpoch(epoch)
227
239
  try {
228
240
  disposeRoutes()
229
241
  } catch {}
@@ -263,20 +275,35 @@ export function apply(ctx, config = {}) {
263
275
  log(`cannot follow agent events: ${error?.message ?? error}`)
264
276
  }
265
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
+
266
288
  void (async () => {
267
289
  try {
268
- // The heartbeat claims the previous process's record in `start`, and the engine's crash plan
269
- // is derived from that claim: start first, or the residue is read as absent and lost.
270
- heartbeat.start()
271
- if (disposed) return
272
- await engine.hydrate()
290
+ // Read the previous process's record before `start()` overwrites it, and persist the crash
291
+ // plan before the heartbeat goes away: a second restart then still sees the same work.
292
+ const residue = heartbeat.peek()
293
+ const hydrated = await engine.hydrate({ residue })
273
294
  if (disposed) return
274
- const dispose = serve(ctx, engine, heartbeat, { allowRemote })
275
- if (disposed) dispose()
276
- else disposeRoutes = dispose
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
+ }
301
+ heartbeat.start()
277
302
  log(`ready (${engine.phase})`)
278
303
  } catch (error) {
279
304
  log(`cannot start: ${error?.message ?? error}`)
305
+ } finally {
306
+ releaseReady()
280
307
  }
281
308
  })()
282
309