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