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 +49 -0
- package/README.md +3 -0
- package/client.js +4 -1
- package/index.js +32 -7
- package/lib/engine.js +143 -25
- package/lib/epoch.js +27 -0
- package/lib/heartbeat.js +11 -0
- package/lib/probe.js +45 -15
- package/lib/snapshot.js +18 -0
- package/package.json +2 -1
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)
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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 (
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
490
|
-
result = restored !== null && typeof restored === 'object' ? (restored.summary ?? null) : null
|
|
609
|
+
|
|
491
610
|
phase = pausedPlan() !== null ? 'paused' : 'idle'
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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'
|
|
288
|
-
|
|
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
|
-
//
|
|
348
|
-
//
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
+
"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": [
|