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 +39 -0
- package/README.md +15 -7
- package/client.js +26 -11
- package/index.js +38 -11
- package/lib/engine.js +394 -157
- package/lib/epoch.js +27 -0
- package/lib/heartbeat.js +34 -4
- package/lib/probe.js +113 -36
- package/lib/snapshot.js +169 -29
- package/package.json +2 -1
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
|
|
44
|
-
|
|
45
|
-
and the
|
|
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.
|
|
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.**
|
|
78
|
-
the
|
|
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
|
-
| `
|
|
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)
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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.
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
heartbeat.
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|