dsh-pause-resume 0.4.3 → 0.4.5
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 +47 -3
- package/README.md +3 -1
- package/client.js +3 -2
- package/index.js +10 -9
- package/lib/engine.js +201 -46
- package/lib/heartbeat.js +23 -5
- package/lib/probe.js +39 -8
- package/lib/snapshot.js +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.5
|
|
4
|
+
|
|
5
|
+
A restore now leaves a durable record before every side effect, so a restart can finish it.
|
|
6
|
+
|
|
7
|
+
* Before `goals.resume`, the plan records `pendingRestore` and writes it to disk. If the process dies
|
|
8
|
+
after the goal was armed but before the checkpoint was saved, the next boot sees the active,
|
|
9
|
+
disarmed goal and the intent that proves it is ours to re-arm. A goal is no longer reported as
|
|
10
|
+
`skipped` with an empty attention list after such a restart.
|
|
11
|
+
* A goal whose state cannot be read is a retryable failure (`goal unreadable: …`) instead of being
|
|
12
|
+
treated as absent and consumed. Only a readable paused goal is resumed, and an unreadable goal is
|
|
13
|
+
never counted as work the pause can stop.
|
|
14
|
+
* A crash plan on boot sets `phase` to `idle` and offers `resume`; it no longer claims the page is
|
|
15
|
+
paused. The dot still names the crash work through attention.
|
|
16
|
+
* A pause plan that ended up in the crash slot can complete its restore, clear the slot and release
|
|
17
|
+
the heartbeat. It no longer leaves the button in `resuming`/`busy`.
|
|
18
|
+
* Restore attention survives the consumed plan and is shown with a focused item id even when no work
|
|
19
|
+
is live. Killed jobs stay excluded from the dot.
|
|
20
|
+
* A new pause bumps the continuation epoch only for the sessions it captured; rows owned by another
|
|
21
|
+
plan keep their request id.
|
|
22
|
+
* A reload during a cold mount stops the old probe before it reports or prompts, so stale side
|
|
23
|
+
effects from a replaced instance cannot reach a session.
|
|
24
|
+
* A full restore journals each item only after its outcome, and the plan is consumed only when every
|
|
25
|
+
item settled.
|
|
26
|
+
|
|
27
|
+
## 0.4.4
|
|
28
|
+
|
|
29
|
+
Restores are now safe to hand over between instances and across an upgrade.
|
|
30
|
+
|
|
31
|
+
* An interrupted pause resets the whole restore policy (`intent: "mount"`, `epoch: 0`), so it is
|
|
32
|
+
mounted and never sends a continuation.
|
|
33
|
+
* When a merged crash plan could not be written to disk, resume restores that in-memory plan, which
|
|
34
|
+
holds both the old and the newly found sessions. The older `pending.json` no longer wins and hides
|
|
35
|
+
half the work.
|
|
36
|
+
* `heartbeat.release()` restores a full lifecycle: the periodic sample is re-armed, `stopped` is
|
|
37
|
+
cleared, and state events can persist again. Previously the file stayed frozen after a repaired
|
|
38
|
+
write failure.
|
|
39
|
+
* A pre-epoch plan keeps its original continuation request id (`…:continue:<session>`), so upgrading
|
|
40
|
+
cannot queue a second copy of a message the old version already admitted.
|
|
41
|
+
* A restore that loses ownership (plugin reload) stops before publishing, consuming or clearing. It
|
|
42
|
+
also compares the plan's actual work, not only its id, so additions from a newer press survive.
|
|
43
|
+
* A goal whose checkpoint cannot be written keeps the plan on disk with the failure named, instead of
|
|
44
|
+
silently skipping the goal on the next boot.
|
|
45
|
+
* The button hint combines the error with the focused item for partial pause/restore.
|
|
46
|
+
|
|
3
47
|
## 0.4.3
|
|
4
48
|
|
|
5
49
|
- The dot now means one thing: something is waiting on you (a paused plan with commands that will not restart, or a stop/restore that failed). A clean idle page has no dot, and killed jobs live in the restore record instead of becoming a permanent red light.
|
|
@@ -20,9 +64,9 @@ A stop that could not finish no longer traps the button, and the dot stops lying
|
|
|
20
64
|
|
|
21
65
|
The crash path is now a first-class restore path, and a dead button after a restart is fixed.
|
|
22
66
|
|
|
23
|
-
* Routes are registered synchronously while `apply` runs.
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
* Routes are registered synchronously while `apply` runs. Registration itself is not the problem —
|
|
68
|
+
the server keeps a live route table — but registering after hydration meant the button could be
|
|
69
|
+
pressed before the table had an entry. Handlers now wait for hydration instead.
|
|
26
70
|
* A crash plan is written to `pending.json` before the heartbeat is replaced. If that write fails,
|
|
27
71
|
the heartbeat is held and the previous `running.json` is left untouched, so the next boot still
|
|
28
72
|
sees the work.
|
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ last press left something for a person to look at.
|
|
|
25
25
|
|
|
26
26
|
Schedules are not paused: the schedule service has no disable operation, only delete, and deleting a
|
|
27
27
|
person's schedule to pause it is not a trade this plugin makes. A schedule that fires while paused
|
|
28
|
-
starts a turn
|
|
28
|
+
starts a turn, but the button keeps showing ▶ while a pause plan waits: a pending plan owns the main
|
|
29
|
+
action, so the only honest next step is to finish the restore. The schedule appears in that restore
|
|
30
|
+
summary like any other work started after the press.
|
|
29
31
|
|
|
30
32
|
## How resume works
|
|
31
33
|
|
package/client.js
CHANGED
|
@@ -140,8 +140,9 @@ window.__ModuleLoader__.load({
|
|
|
140
140
|
|
|
141
141
|
/** The one sentence behind the dot, in the tooltip only: the button itself stays a glyph. */
|
|
142
142
|
function hintOf(state) {
|
|
143
|
-
|
|
144
|
-
if (state.error
|
|
143
|
+
const focus = typeof state.focus === 'string' && state.focus !== '' ? state.focus : ''
|
|
144
|
+
if (state.error === 'restore-partial') return focus === '' ? COPY.partial : `${COPY.partial}: ${focus}`
|
|
145
|
+
if (state.error !== null && state.error !== undefined) return focus === '' ? COPY.error : `${COPY.error}: ${focus}`
|
|
145
146
|
if ((state.attention ?? 0) > 0) {
|
|
146
147
|
return typeof state.focus === 'string' && state.focus !== '' ? `${COPY.attention}: ${state.focus}` : COPY.attention
|
|
147
148
|
}
|
package/index.js
CHANGED
|
@@ -2,12 +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
|
+
import { nextEpoch, currentEpoch, dropEpoch } from './lib/epoch.js'
|
|
6
6
|
import { createHeartbeat } from './lib/heartbeat.js'
|
|
7
7
|
import { createProbe, listRunning } from './lib/probe.js'
|
|
8
8
|
|
|
9
9
|
export const name = 'dsh-pause-resume'
|
|
10
|
-
export const VERSION = '0.4.
|
|
10
|
+
export const VERSION = '0.4.5'
|
|
11
11
|
|
|
12
12
|
const BASE_PATH = '/dsh-pause-resume'
|
|
13
13
|
const BUTTON_HEADER = 'x-dsh-pause-resume'
|
|
@@ -135,8 +135,8 @@ function serve(ctx, engine, heartbeat, options, ready) {
|
|
|
135
135
|
if (read === true ? sameOrigin(req, allowRemote) === false : buttonAllowed(req, allowRemote) === false) {
|
|
136
136
|
return sendJson(res, 403, { status: 'forbidden' })
|
|
137
137
|
}
|
|
138
|
-
// Routes are registered synchronously so
|
|
139
|
-
// may still be reading crash residue, so every press waits for it instead of acting blind.
|
|
138
|
+
// Routes are registered synchronously so no press can arrive before the table has them.
|
|
139
|
+
// Hydration may still be reading crash residue, so every press waits for it instead of acting blind.
|
|
140
140
|
if (ready !== undefined) await ready
|
|
141
141
|
if (disposed === true) return sendJson(res, 503, { status: 'starting' })
|
|
142
142
|
return handler(req, res)
|
|
@@ -145,7 +145,7 @@ function serve(ctx, engine, heartbeat, options, ready) {
|
|
|
145
145
|
[
|
|
146
146
|
`${BASE_PATH}/state`,
|
|
147
147
|
route(async (req, res) => {
|
|
148
|
-
sendJson(res, 200, engine.describe())
|
|
148
|
+
sendJson(res, 200, { ...engine.describe(), version: VERSION })
|
|
149
149
|
}, true),
|
|
150
150
|
],
|
|
151
151
|
[
|
|
@@ -154,7 +154,7 @@ function serve(ctx, engine, heartbeat, options, ready) {
|
|
|
154
154
|
const outcome = await engine.pause()
|
|
155
155
|
log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
|
|
156
156
|
if (outcome.status === 'paused' || outcome.status === 'partial') heartbeat.release()
|
|
157
|
-
sendJson(res, 200, { ...engine.describe(), outcome })
|
|
157
|
+
sendJson(res, 200, { ...engine.describe(), outcome, version: VERSION })
|
|
158
158
|
}, false),
|
|
159
159
|
],
|
|
160
160
|
[
|
|
@@ -162,7 +162,7 @@ function serve(ctx, engine, heartbeat, options, ready) {
|
|
|
162
162
|
route(async (req, res) => {
|
|
163
163
|
const outcome = await engine.resume()
|
|
164
164
|
log(`resume: ${outcome.status}`)
|
|
165
|
-
sendJson(res, 200, { ...engine.describe(), outcome })
|
|
165
|
+
sendJson(res, 200, { ...engine.describe(), outcome, version: VERSION })
|
|
166
166
|
}, false),
|
|
167
167
|
],
|
|
168
168
|
]
|
|
@@ -216,6 +216,7 @@ export function apply(ctx, config = {}) {
|
|
|
216
216
|
log,
|
|
217
217
|
})
|
|
218
218
|
const probe = createProbe(ctx, {
|
|
219
|
+
isCurrent: () => currentEpoch() === epoch,
|
|
219
220
|
graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
|
|
220
221
|
continueText: typeof config?.continueText === 'string' ? config.continueText : undefined,
|
|
221
222
|
})
|
|
@@ -275,8 +276,8 @@ export function apply(ctx, config = {}) {
|
|
|
275
276
|
log(`cannot follow agent events: ${error?.message ?? error}`)
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
//
|
|
279
|
-
//
|
|
279
|
+
// Routes must be registered while `apply` is still synchronous, so a press can never arrive
|
|
280
|
+
// before the table has an entry. Hydration is asynchronous; the handlers wait on this promise.
|
|
280
281
|
let releaseReady = () => {}
|
|
281
282
|
const ready = new Promise((resolve) => {
|
|
282
283
|
releaseReady = resolve
|
package/lib/engine.js
CHANGED
|
@@ -28,6 +28,18 @@ function emptyLive() {
|
|
|
28
28
|
return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** One id for the hint: a restore attention row is either a label or a plan row. */
|
|
32
|
+
function labelFocus(text) {
|
|
33
|
+
const match = /^(?:session|goal|job) ([^:]+):/.exec(String(text))
|
|
34
|
+
return match !== null ? match[1] : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function attentionFocus(row) {
|
|
38
|
+
if (row === null || row === undefined) return null
|
|
39
|
+
if (typeof row === 'string') return labelFocus(row) ?? row.slice(0, 60) // ui-copy-ignore
|
|
40
|
+
return row.id || row.detail || null
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
/** One row per item: a retried stop must not multiply the same unfinished job in the dot. */
|
|
32
44
|
function dedupeRows(rows) {
|
|
33
45
|
const seen = new Set()
|
|
@@ -51,8 +63,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
51
63
|
let result = null
|
|
52
64
|
/** Set while a press runs, so a second caller joins the answer instead of starting work again. */
|
|
53
65
|
let inflight = null
|
|
54
|
-
/**
|
|
55
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The complete plan kept in memory when its durable write failed. It can be a crash plan or a
|
|
68
|
+
* normal pause plan whose crash residue could not be merged; in both cases the file on disk is
|
|
69
|
+
* only an older subset and must not decide what this press restores.
|
|
70
|
+
*/
|
|
71
|
+
let planFallback = null
|
|
56
72
|
let counter = 0
|
|
57
73
|
|
|
58
74
|
function newPlanId(kind) {
|
|
@@ -65,13 +81,45 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
function crashPlan() {
|
|
68
|
-
|
|
69
|
-
return stored ?? crashFallback
|
|
84
|
+
return typeof store.readPending === 'function' ? store.readPending() : null
|
|
70
85
|
}
|
|
71
86
|
|
|
72
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Exactly one plan is pending, and a finished pause outranks a leftover crash record. The
|
|
89
|
+
* in-memory fallback outranks both files: when a write failed it holds the complete work, while
|
|
90
|
+
* the older file would hide the half that was never saved.
|
|
91
|
+
*/
|
|
73
92
|
function currentPlan() {
|
|
74
|
-
return pausedPlan() ?? crashPlan()
|
|
93
|
+
return planFallback ?? pausedPlan() ?? crashPlan()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Identity and content of one plan, used to find which store actually holds it. */
|
|
97
|
+
function samePlan(left, right) {
|
|
98
|
+
return left !== null && right !== null && left.planId === right.planId && planKey(left) === planKey(right)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The work a plan asks for, without timestamps or reporting fields. */
|
|
102
|
+
function planKey(plan) {
|
|
103
|
+
const row = (item) => [item?.sessionId ?? '', item?.intent ?? (item?.running === true ? 'continue' : 'mount'), item?.goalId ?? '']
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
sessions: (plan?.sessions ?? []).map(row),
|
|
106
|
+
goals: (plan?.goals ?? []).map((goal) => [goal?.sessionId ?? '', goal?.goalId ?? '']),
|
|
107
|
+
jobs: (plan?.jobs ?? []).map((job) => job?.id ?? ''),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A plan is only ours to spend if the durable copy still asks for exactly the same work. A
|
|
113
|
+
* merged plan keeps the planId, so an id comparison alone would delete another press's additions.
|
|
114
|
+
*/
|
|
115
|
+
function stillMine(plan) {
|
|
116
|
+
if (isCurrent() === false) return false
|
|
117
|
+
// When the merge could not be written, this in-memory plan is the complete set and the file
|
|
118
|
+
// is only its older subset. The epoch check above already proved no newer instance wrote.
|
|
119
|
+
if (planFallback === plan) return true
|
|
120
|
+
// A plan can live in either file: a crash plan converted to a pause plan may have fallen back
|
|
121
|
+
// to pending.json when snapshot.json could not be written. Find it by content, not by source.
|
|
122
|
+
return samePlan(pausedPlan(), plan) || samePlan(crashPlan(), plan)
|
|
75
123
|
}
|
|
76
124
|
|
|
77
125
|
function isCrashPlan(plan) {
|
|
@@ -141,6 +189,16 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
141
189
|
return work.sessions.length + work.goals.length + work.jobs.length
|
|
142
190
|
}
|
|
143
191
|
|
|
192
|
+
/** The item a partial restore could not finish, so the tooltip can name it. */
|
|
193
|
+
function failureFocus(summary) {
|
|
194
|
+
for (const line of Array.isArray(summary?.failed) ? summary.failed : []) {
|
|
195
|
+
const match = /^(?:session|goal) ([^:]+):/.exec(String(line))
|
|
196
|
+
if (match !== null) return match[1]
|
|
197
|
+
if (typeof line === 'string' && line !== '') return line.slice(0, 60) // ui-copy-ignore
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
144
202
|
/**
|
|
145
203
|
* What the button shows. `action` is the one decision the client may not make itself:
|
|
146
204
|
* `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
|
|
@@ -159,14 +217,17 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
159
217
|
if (busy) action = 'busy'
|
|
160
218
|
else if (plan !== null) action = 'resume'
|
|
161
219
|
else if (current.readable === true && workCount > 0) action = 'pause'
|
|
162
|
-
// The dot means "something still needs you".
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
220
|
+
// The dot means "something still needs you". Stopped jobs are reported separately and must not
|
|
221
|
+
// turn a clean idle page into a permanent dot, but restore attention also covers "the message
|
|
222
|
+
// was admitted and no turn started" and "the turn cannot be proven finished": those are open
|
|
223
|
+
// obligations even when no plan is left, so they are always shown.
|
|
224
|
+
const attentionRows = dedupeRows([
|
|
225
|
+
...(Array.isArray(plan?.attention) ? plan.attention : []),
|
|
226
|
+
...(Array.isArray(result?.attention) ? result.attention : []),
|
|
227
|
+
])
|
|
228
|
+
const attention = attentionRows.length
|
|
168
229
|
const first = attention > 0 ? attentionRows[0] : null
|
|
169
|
-
const focus = first
|
|
230
|
+
const focus = first !== null ? attentionFocus(first) : failureFocus(result)
|
|
170
231
|
return {
|
|
171
232
|
phase,
|
|
172
233
|
action,
|
|
@@ -245,17 +306,20 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
245
306
|
return out
|
|
246
307
|
}
|
|
247
308
|
|
|
309
|
+
/** Which durable file currently holds this plan; a converted pause plan may still live in pending. */
|
|
310
|
+
function usesPending(plan) {
|
|
311
|
+
if (isCrashPlan(plan) === true) return true
|
|
312
|
+
return samePlan(crashPlan(), plan) && !samePlan(pausedPlan(), plan)
|
|
313
|
+
}
|
|
314
|
+
|
|
248
315
|
function persist(plan) {
|
|
249
316
|
if (isCurrent() === false) {
|
|
250
317
|
log('stale instance: refusing to write over a newer plan')
|
|
251
318
|
return null
|
|
252
319
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return stored
|
|
257
|
-
}
|
|
258
|
-
return store.write(plan)
|
|
320
|
+
const stored = usesPending(plan) ? store.writePending(plan) : store.write(plan)
|
|
321
|
+
if (stored !== null) planFallback = null
|
|
322
|
+
return stored
|
|
259
323
|
}
|
|
260
324
|
|
|
261
325
|
/**
|
|
@@ -292,8 +356,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
292
356
|
const before = new Map((basePlan?.sessions ?? []).filter((row) => typeof row?.sessionId === 'string').map((row) => [row.sessionId, row]))
|
|
293
357
|
const restoredBefore = Number.isSafeInteger(basePlan?.restore?.attempts) && basePlan.restore.attempts > 0
|
|
294
358
|
plan.sessions = unionSessions(plan.sessions ?? [], work.sessions.map(freshSession))
|
|
359
|
+
// Only rows this press actually captured may get a new cycle. A row left from an earlier
|
|
360
|
+
// restore attempt belongs to a different pause: incrementing it would change its request id
|
|
361
|
+
// and admit a second continuation while the first is still sitting in the queue.
|
|
362
|
+
const captured = new Set(work.sessions.map((session) => session.sessionId))
|
|
295
363
|
plan.sessions = plan.sessions.map((row) => {
|
|
296
|
-
if (row.intent !== 'continue') return row
|
|
364
|
+
if (row.intent !== 'continue' || captured.has(row.sessionId) === false) return row
|
|
297
365
|
const prior = before.get(row.sessionId)
|
|
298
366
|
const priorEpoch = Number.isSafeInteger(prior?.epoch) ? prior.epoch : 0
|
|
299
367
|
if (Number.isSafeInteger(row.epoch) && row.epoch > priorEpoch) return row
|
|
@@ -373,16 +441,21 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
373
441
|
// The crash record described work that was not paused. This press did pause it, so the plan
|
|
374
442
|
// becomes a normal pause plan and the crash evidence is no longer a separate obligation.
|
|
375
443
|
plan.source = 'pause' // ui-copy-ignore
|
|
376
|
-
if
|
|
377
|
-
|
|
444
|
+
// The two files are a migration, not a plain write: if snapshot.json cannot be written, the
|
|
445
|
+
// pending fallback is the only durable copy. Removing it after a failed snapshot write would
|
|
446
|
+
// discard the plan, and reading only snapshot.json would then call this plan a stranger.
|
|
447
|
+
if (store.write(plan) !== null) {
|
|
448
|
+
planFallback = null
|
|
449
|
+
if (typeof store.clearPending === 'function' && store.clearPending() === false) lastError = 'store'
|
|
450
|
+
} else {
|
|
451
|
+
const kept = typeof store.writePending === 'function' ? store.writePending(plan) : null
|
|
378
452
|
if (kept === null) {
|
|
379
|
-
|
|
453
|
+
planFallback = plan
|
|
380
454
|
lastError = 'store'
|
|
455
|
+
} else {
|
|
456
|
+
planFallback = null
|
|
381
457
|
}
|
|
382
|
-
} else if (typeof store.clearPending === 'function') {
|
|
383
|
-
if (store.clearPending() === false) lastError = 'store'
|
|
384
458
|
}
|
|
385
|
-
crashFallback = null
|
|
386
459
|
} else if (store.write(plan) === null) {
|
|
387
460
|
// The durable copy still says `pausing` or `paused`: the next boot reports it instead of
|
|
388
461
|
// claiming a stop that was never written.
|
|
@@ -408,7 +481,11 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
408
481
|
if (plan.status === 'pausing') {
|
|
409
482
|
plan.source = plan.source === 'crash' ? 'crash' : 'interrupted'
|
|
410
483
|
plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
|
|
411
|
-
|
|
484
|
+
// `intent` outranks `running` in the probe, so an interrupted pause must reset the whole
|
|
485
|
+
// policy: mount only, no admission, and no earlier cycle's request identity.
|
|
486
|
+
plan.sessions = (plan.sessions ?? []).map((session) => ({
|
|
487
|
+
...session, intent: 'mount', running: false, epoch: 0, lastTurn: null,
|
|
488
|
+
}))
|
|
412
489
|
plan.status = 'paused'
|
|
413
490
|
if (persist(plan) === null) {
|
|
414
491
|
phase = 'paused'
|
|
@@ -425,7 +502,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
425
502
|
log(`cannot journal ${key}`)
|
|
426
503
|
}
|
|
427
504
|
}
|
|
505
|
+
const stale = () => isCurrent() === false
|
|
428
506
|
const step = async (key, label, run) => {
|
|
507
|
+
if (stale()) return { status: 'failed', detail: 'reloaded' }
|
|
429
508
|
let outcome
|
|
430
509
|
try {
|
|
431
510
|
outcome = await run()
|
|
@@ -433,6 +512,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
433
512
|
outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
434
513
|
}
|
|
435
514
|
const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
|
|
515
|
+
if (status === 'reloaded') return outcome
|
|
436
516
|
if (TERMINAL.has(status) === false) {
|
|
437
517
|
summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
|
|
438
518
|
return outcome
|
|
@@ -459,21 +539,64 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
459
539
|
// flight turns a long serial restore into one batch without flooding the Harness.
|
|
460
540
|
await Promise.all(Array.from({ length: Math.min(4, sessionRows.length) }, sessionWorker))
|
|
461
541
|
for (const goal of plan.goals ?? []) {
|
|
542
|
+
if (stale()) break
|
|
462
543
|
const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
|
|
463
|
-
const
|
|
544
|
+
const label = `goal ${goal.goalId ?? goal.sessionId}`
|
|
545
|
+
const setGoal = (change) => {
|
|
546
|
+
plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
|
|
547
|
+
? { ...row, ...change }
|
|
548
|
+
: row))
|
|
549
|
+
}
|
|
550
|
+
// Persist the intent before the side effect. A crash or a checkpoint failure after
|
|
551
|
+
// `goals.resume()` leaves an active, disarmed goal on the next boot; without this record the
|
|
552
|
+
// plan cannot prove that goal was its own work and would silently skip it.
|
|
553
|
+
if (goal.pendingRestore === undefined || goal.pendingRestore === null) {
|
|
554
|
+
setGoal({ pendingRestore: { revision: Number.isSafeInteger(goal.revision) ? goal.revision : null, at: now() } }) // ui-copy-ignore
|
|
555
|
+
if (persist(plan) === null) {
|
|
556
|
+
summary.failed.push(`${label}: restore intent not saved`)
|
|
557
|
+
lastError = 'store'
|
|
558
|
+
phase = 'paused'
|
|
559
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const outcome = await step(key, label, () => probe.restoreGoal(goal, { plan }))
|
|
563
|
+
if (outcome?.status === 'reloaded') break
|
|
464
564
|
if (outcome?.status === 'restored' && Number.isSafeInteger(outcome.revision)) {
|
|
465
565
|
// Remember what this plan armed. A restarted process sees `active + disarmed`, and that
|
|
466
566
|
// exact revision is the only proof the goal is ours to arm again rather than someone else's.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
567
|
+
const row = (plan.goals ?? []).find((item) => item.sessionId === goal.sessionId && item.goalId === goal.goalId)
|
|
568
|
+
setGoal({ armedRevision: outcome.revision, pendingRestore: null })
|
|
569
|
+
if (persist(plan) === null) {
|
|
570
|
+
// Keep the intent on disk so the next boot can still re-arm the goal; returning partial
|
|
571
|
+
// without it was exactly how a successful resume became a silent skip after a restart.
|
|
572
|
+
setGoal({ armedRevision: row?.armedRevision ?? null, pendingRestore: goal.pendingRestore ?? { revision: Number.isSafeInteger(goal.revision) ? goal.revision : null, at: now() } })
|
|
573
|
+
summary.failed.push(`${label}: checkpoint not saved`)
|
|
574
|
+
lastError = 'store'
|
|
575
|
+
phase = 'paused'
|
|
576
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
577
|
+
}
|
|
578
|
+
} else if (outcome?.status === 'restored' || TERMINAL.has(outcome?.status)) {
|
|
579
|
+
// Restored without a revision, or terminal for another reason: the intent is spent.
|
|
580
|
+
setGoal({ pendingRestore: null })
|
|
581
|
+
if (persist(plan) === null) {
|
|
582
|
+
summary.failed.push(`${label}: checkpoint not saved`)
|
|
583
|
+
lastError = 'store'
|
|
584
|
+
phase = 'paused'
|
|
585
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
586
|
+
}
|
|
471
587
|
}
|
|
472
588
|
}
|
|
473
589
|
// A killed job cannot be replayed: the same command may have had an unknown effect, so it is
|
|
474
590
|
// reported once and left to the person. It never blocks the plan and is never restarted.
|
|
475
591
|
for (const job of plan.jobs ?? []) summary.jobsStopped.push(`job ${job.id}: stopped, not restarted`)
|
|
476
592
|
|
|
593
|
+
if (stale()) {
|
|
594
|
+
// A reload owns the plan now. Publishing a summary or consuming anything here would erase
|
|
595
|
+
// work the new instance captured while these mounts were in flight.
|
|
596
|
+
log('stale instance: restore result discarded')
|
|
597
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
598
|
+
}
|
|
599
|
+
|
|
477
600
|
result = summary
|
|
478
601
|
if (summary.failed.length > 0) {
|
|
479
602
|
plan.restore = { at: summary.at, attempts: (plan.restore?.attempts ?? 0) + 1, failed: summary.failed } // ui-copy-ignore
|
|
@@ -483,35 +606,62 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
483
606
|
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
484
607
|
}
|
|
485
608
|
|
|
486
|
-
|
|
487
|
-
|
|
609
|
+
// A pending copy of the same plan means the migration's cleanup failed after snapshot.json was
|
|
610
|
+
// written. Finish that cleanup before consuming, or the next boot would offer the same restore
|
|
611
|
+
// again from a plan that is already complete.
|
|
612
|
+
if (usesPending(plan) === false && samePlan(crashPlan(), plan) && typeof store.clearPending === 'function') {
|
|
613
|
+
if (store.clearPending() === false) {
|
|
488
614
|
lastError = 'store'
|
|
489
615
|
phase = 'paused'
|
|
616
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// A plan may live in pending.json even after it became a normal pause plan (the snapshot
|
|
620
|
+
// write failed during the crash-to-pause migration). Completion follows the file that holds it.
|
|
621
|
+
if (usesPending(plan)) {
|
|
622
|
+
if (stillMine(plan) === false) {
|
|
623
|
+
log('stale instance: crash record left alone')
|
|
624
|
+
phase = 'paused'
|
|
490
625
|
return { status: 'partial', summary, outstanding: 0 }
|
|
491
626
|
}
|
|
492
|
-
if (typeof store.
|
|
627
|
+
if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
|
|
493
628
|
lastError = 'store'
|
|
494
629
|
phase = 'paused'
|
|
495
630
|
return { status: 'partial', summary, outstanding: 0 }
|
|
496
631
|
}
|
|
497
|
-
|
|
498
|
-
if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
|
|
499
|
-
if (isCurrent() === false) {
|
|
632
|
+
if (stillMine(plan) === false) {
|
|
500
633
|
log('stale instance: crash record left alone')
|
|
634
|
+
phase = 'paused'
|
|
501
635
|
return { status: 'partial', summary, outstanding: 0 }
|
|
502
636
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
637
|
+
if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
|
|
638
|
+
lastError = 'store'
|
|
639
|
+
phase = 'paused'
|
|
640
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
507
641
|
}
|
|
642
|
+
planFallback = null
|
|
643
|
+
if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
|
|
508
644
|
} else {
|
|
645
|
+
if (stillMine(plan) === false) {
|
|
646
|
+
log('stale instance: pause plan left alone')
|
|
647
|
+
phase = 'paused'
|
|
648
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
649
|
+
}
|
|
509
650
|
const consumed = store.consume(plan, summary)
|
|
510
651
|
if (!consumed?.ok) {
|
|
511
652
|
lastError = 'store'
|
|
512
653
|
phase = 'paused'
|
|
513
654
|
return { status: 'partial', summary, outstanding: 0 }
|
|
514
655
|
}
|
|
656
|
+
planFallback = null
|
|
657
|
+
}
|
|
658
|
+
// The plan is complete, including every crash row that was merged into it. Only now may the
|
|
659
|
+
// heartbeat stop protecting the previous process's record: releasing earlier would overwrite
|
|
660
|
+
// the only evidence of a session the merge had not yet brought into this plan.
|
|
661
|
+
try {
|
|
662
|
+
heartbeat?.release?.()
|
|
663
|
+
} catch (error) {
|
|
664
|
+
log(`cannot release heartbeat: ${error?.message ?? error}`)
|
|
515
665
|
}
|
|
516
666
|
phase = 'idle'
|
|
517
667
|
return { status: 'resumed', summary }
|
|
@@ -568,7 +718,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
568
718
|
* overwrites the previous process's file; it is persisted here so a second restart still sees it.
|
|
569
719
|
*/
|
|
570
720
|
async function hydrate(options = {}) {
|
|
571
|
-
|
|
721
|
+
planFallback = null
|
|
572
722
|
lastError = null
|
|
573
723
|
const residue = options?.residue ?? heartbeat?.residue ?? null
|
|
574
724
|
const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
|
|
@@ -591,7 +741,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
591
741
|
// A failed write stays in memory for this process, but the caller must not let the
|
|
592
742
|
// heartbeat overwrite the file the next process would have read the same residue from.
|
|
593
743
|
if (stored === null) {
|
|
594
|
-
|
|
744
|
+
planFallback = crash
|
|
595
745
|
durable = false
|
|
596
746
|
lastError = 'store'
|
|
597
747
|
heartbeat?.hold?.()
|
|
@@ -624,10 +774,13 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
624
774
|
if (stored === null) {
|
|
625
775
|
durable = false
|
|
626
776
|
lastError = 'store'
|
|
627
|
-
|
|
777
|
+
// Whatever the source, the in-memory plan now holds the complete set. Resume must
|
|
778
|
+
// use it, or the sessions that only exist here would be skipped forever.
|
|
779
|
+
planFallback = plan
|
|
628
780
|
heartbeat?.hold?.()
|
|
629
781
|
log('cannot merge the crash plan; keeping the previous heartbeat')
|
|
630
782
|
} else {
|
|
783
|
+
planFallback = null
|
|
631
784
|
log(`crash sessions merged (${added.length})`)
|
|
632
785
|
}
|
|
633
786
|
}
|
|
@@ -635,7 +788,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
635
788
|
}
|
|
636
789
|
}
|
|
637
790
|
|
|
638
|
-
|
|
791
|
+
const hydrated = currentPlan()
|
|
792
|
+
// A crash record is not a pause: the button offers restore, but the page is not paused.
|
|
793
|
+
phase = hydrated !== null && isCrashPlan(hydrated) === false ? 'paused' : 'idle'
|
|
639
794
|
return { phase, durable }
|
|
640
795
|
}
|
|
641
796
|
|
package/lib/heartbeat.js
CHANGED
|
@@ -204,16 +204,21 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
function arm() {
|
|
208
|
+
if (timer !== null) return
|
|
209
|
+
timer = setInterval(() => {
|
|
210
|
+
void refresh()
|
|
211
|
+
}, intervalMs)
|
|
212
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
213
|
+
}
|
|
214
|
+
|
|
207
215
|
function start() {
|
|
208
216
|
if (started) return
|
|
209
217
|
started = true
|
|
210
218
|
stopped = false
|
|
211
219
|
capture()
|
|
212
220
|
void refresh()
|
|
213
|
-
|
|
214
|
-
void refresh()
|
|
215
|
-
}, intervalMs)
|
|
216
|
-
if (typeof timer.unref === 'function') timer.unref()
|
|
221
|
+
arm()
|
|
217
222
|
}
|
|
218
223
|
|
|
219
224
|
function stop() {
|
|
@@ -234,10 +239,23 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
|
|
|
234
239
|
stop()
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* The residue was restored: this process owns the file again. Clear `stopped`, re-arm the timer
|
|
244
|
+
* and allow `note()`; a permission flip alone would leave both write paths dead.
|
|
245
|
+
*/
|
|
238
246
|
function release() {
|
|
239
247
|
writable = true
|
|
240
248
|
residue = null
|
|
249
|
+
started = true
|
|
250
|
+
stopped = false
|
|
251
|
+
if (dirty() && noteTimer === null) {
|
|
252
|
+
noteTimer = setTimeout(() => {
|
|
253
|
+
noteTimer = null
|
|
254
|
+
void flush()
|
|
255
|
+
}, DEBOUNCE_MS)
|
|
256
|
+
if (typeof noteTimer.unref === 'function') noteTimer.unref()
|
|
257
|
+
}
|
|
258
|
+
arm()
|
|
241
259
|
void refresh(true)
|
|
242
260
|
}
|
|
243
261
|
|
package/lib/probe.js
CHANGED
|
@@ -76,9 +76,12 @@ function goalView(services, agent) {
|
|
|
76
76
|
if (goals === undefined || goals === null || typeof goals.get !== 'function') return undefined
|
|
77
77
|
try {
|
|
78
78
|
const view = goals.get(agent)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
// `null` is the service's answer for "no goal"; a throw is "cannot tell right now". Collapsing
|
|
80
|
+
// both into `undefined` made a transient read error look like the goal had been deleted, which
|
|
81
|
+
// the restore then recorded as a terminal skip and never retried.
|
|
82
|
+
return view === undefined || view === null ? null : view
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { unreadable: true, detail: String(error?.message ?? error).slice(0, 120) }
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -132,6 +135,10 @@ export function createProbe(services, options = {}) {
|
|
|
132
135
|
const continueText = typeof options.continueText === 'string' && options.continueText.trim() !== ''
|
|
133
136
|
? options.continueText
|
|
134
137
|
: '继续'
|
|
138
|
+
// A reload replaces the probe while an old restore is still awaiting a mount. The old probe must
|
|
139
|
+
// stop before its side effects, not after: a sent message cannot be taken back.
|
|
140
|
+
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true
|
|
141
|
+
const reloaded = () => isCurrent() === false
|
|
135
142
|
|
|
136
143
|
/** Everything this press can stop, read from the live registry only. */
|
|
137
144
|
function live() {
|
|
@@ -151,7 +158,7 @@ export function createProbe(services, options = {}) {
|
|
|
151
158
|
sessions.push({ sessionId: agent.id, running: agent.status === 'running', lastTurn: lastTurnOf(agent) })
|
|
152
159
|
// A disarmed goal is not work: it will not start a round, and pausing it would throw. Only an
|
|
153
160
|
// active, armed goal is something this press can stop and this restore can re-arm.
|
|
154
|
-
if (goal !== undefined && goal.phase === 'active' && goal.activation === 'armed') {
|
|
161
|
+
if (goal !== undefined && goal !== null && goal.unreadable !== true && goal.phase === 'active' && goal.activation === 'armed') {
|
|
155
162
|
goals.push({ sessionId: agent.id, goalId: goal.id, revision: goal.revision })
|
|
156
163
|
}
|
|
157
164
|
for (const job of owned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: agent.id, status: job.status })
|
|
@@ -236,10 +243,15 @@ export function createProbe(services, options = {}) {
|
|
|
236
243
|
// The epoch is the pause cycle. A retry of the same pause reuses the id (official dedupe keeps
|
|
237
244
|
// admission idempotent), while a new pause after a partial restore gets a fresh one, so the
|
|
238
245
|
// earlier admitted message cannot swallow this cycle's continuation.
|
|
239
|
-
const cycle = Number.isSafeInteger(epoch) && epoch
|
|
246
|
+
const cycle = Number.isSafeInteger(epoch) && epoch >= 0 ? epoch : 1
|
|
247
|
+
// Cycle 0 is a pre-epoch plan: keep the exact id that may already sit in the official queue,
|
|
248
|
+
// so upgrading the plugin cannot duplicate a continuation the old version admitted.
|
|
249
|
+
const requestId = cycle === 0
|
|
250
|
+
? `${plan.planId}:continue:${sessionId}`
|
|
251
|
+
: `${plan.planId}:continue:${sessionId}:${cycle}`
|
|
240
252
|
return {
|
|
241
253
|
sessionId,
|
|
242
|
-
requestId
|
|
254
|
+
requestId,
|
|
243
255
|
content: [{ type: 'text', text: continueText }],
|
|
244
256
|
// `queue` is the shipped public mode: deliver after the current turn, start one when idle.
|
|
245
257
|
mode: 'queue',
|
|
@@ -288,6 +300,7 @@ export function createProbe(services, options = {}) {
|
|
|
288
300
|
return { status: 'failed', detail }
|
|
289
301
|
}
|
|
290
302
|
}
|
|
303
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
291
304
|
|
|
292
305
|
// Version 2 plans carry only `running`; a normalized version 3 plan carries `intent`. Reading
|
|
293
306
|
// the old field here keeps a plan written by 0.3.0 working across the upgrade.
|
|
@@ -312,6 +325,7 @@ export function createProbe(services, options = {}) {
|
|
|
312
325
|
if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
|
|
313
326
|
return { status: 'failed', detail: 'prompt unavailable', continued: false }
|
|
314
327
|
}
|
|
328
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
315
329
|
const request = continuation(plan, sessionId, session.epoch)
|
|
316
330
|
try {
|
|
317
331
|
// Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
|
|
@@ -356,12 +370,16 @@ export function createProbe(services, options = {}) {
|
|
|
356
370
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
357
371
|
}
|
|
358
372
|
}
|
|
373
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
359
374
|
const services_goals = service(services, 'goals')
|
|
360
375
|
if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
|
|
361
376
|
return { status: 'failed', detail: 'goals unavailable' }
|
|
362
377
|
}
|
|
363
378
|
const view = goalView(services, agent)
|
|
364
|
-
if (view
|
|
379
|
+
if (view !== undefined && view !== null && view.unreadable === true) {
|
|
380
|
+
return { status: 'failed', detail: `goal unreadable: ${view.detail}` }
|
|
381
|
+
}
|
|
382
|
+
if (view === undefined || view === null || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
|
|
365
383
|
return { status: 'skipped', detail: 'goal is gone' }
|
|
366
384
|
}
|
|
367
385
|
// An active goal is normally not ours to touch. The one exception is a goal this same plan
|
|
@@ -369,13 +387,22 @@ export function createProbe(services, options = {}) {
|
|
|
369
387
|
// exactly the revision the plan recorded. Another person's goal has no such record.
|
|
370
388
|
if (view.phase === 'active') {
|
|
371
389
|
if (view.activation === 'armed') return { status: 'skipped', detail: 'already active' }
|
|
372
|
-
|
|
390
|
+
const pending = goal.pendingRestore !== null && typeof goal.pendingRestore === 'object' ? goal.pendingRestore : null
|
|
391
|
+
const pendingRevision = Number.isSafeInteger(pending?.revision) ? pending.revision : null
|
|
392
|
+
// `pendingRestore` was written before the side effect. If the process died after resume but
|
|
393
|
+
// before the checkpoint, the goal is active and disarmed at the revision our resume produced.
|
|
394
|
+
const ours = pending !== null && (pendingRevision === null || view.revision >= pendingRevision)
|
|
395
|
+
if (ours || (Number.isSafeInteger(goal.armedRevision) && goal.armedRevision === view.revision)) {
|
|
396
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
373
397
|
try {
|
|
374
398
|
services_goals.resume(agent, { id: view.id, revision: view.revision })
|
|
375
399
|
} catch (error) {
|
|
376
400
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
377
401
|
}
|
|
378
402
|
const after = goalView(services, agent)
|
|
403
|
+
if (after !== undefined && after !== null && after.unreadable === true) {
|
|
404
|
+
return { status: 'failed', detail: 'goal unreadable after resume' }
|
|
405
|
+
}
|
|
379
406
|
return { status: 'restored', detail: 're-armed after restart', revision: after?.revision ?? null }
|
|
380
407
|
}
|
|
381
408
|
return { status: 'skipped', detail: 'goal disarmed' }
|
|
@@ -390,12 +417,16 @@ export function createProbe(services, options = {}) {
|
|
|
390
417
|
const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
|
|
391
418
|
if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
|
|
392
419
|
if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
|
|
420
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
393
421
|
try {
|
|
394
422
|
services_goals.resume(agent, { id: view.id, revision: view.revision })
|
|
395
423
|
} catch (error) {
|
|
396
424
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
397
425
|
}
|
|
398
426
|
const after = goalView(services, agent)
|
|
427
|
+
if (after !== undefined && after !== null && after.unreadable === true) {
|
|
428
|
+
return { status: 'failed', detail: 'goal unreadable after resume' }
|
|
429
|
+
}
|
|
399
430
|
return { status: 'restored', detail: 'armed', revision: after?.revision ?? null }
|
|
400
431
|
}
|
|
401
432
|
|
package/lib/snapshot.js
CHANGED
|
@@ -152,9 +152,9 @@ export function createStore(dir, options = {}) {
|
|
|
152
152
|
const intent = row.intent === 'continue' || row.intent === 'mount'
|
|
153
153
|
? row.intent
|
|
154
154
|
: (row.running === true ? 'continue' : 'mount')
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
// A plan written before epochs existed has no cycle number. Zero is the legacy identity:
|
|
156
|
+
// the continuation keeps its original request id instead of looking like a new operation.
|
|
157
|
+
const epoch = Number.isSafeInteger(row.epoch) && row.epoch > 0 ? row.epoch : 0
|
|
158
158
|
return { ...row, intent, epoch, running: intent === 'continue' }
|
|
159
159
|
})
|
|
160
160
|
plan.goals = (Array.isArray(plan.goals) ? plan.goals : []).map((row) => (
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pause-resume",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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",
|