dsh-pause-resume 0.4.4 → 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 +24 -0
- package/index.js +3 -2
- package/lib/engine.js +150 -52
- package/lib/probe.js +32 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
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
|
+
|
|
3
27
|
## 0.4.4
|
|
4
28
|
|
|
5
29
|
Restores are now safe to hand over between instances and across an upgrade.
|
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'
|
|
@@ -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
|
})
|
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,15 +81,21 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
function crashPlan() {
|
|
68
|
-
|
|
69
|
-
// When the merge could not be written, the in-memory plan holds the old crash sessions *and*
|
|
70
|
-
// the ones this process found; the older file would hide the half that was never saved.
|
|
71
|
-
return crashFallback ?? stored
|
|
84
|
+
return typeof store.readPending === 'function' ? store.readPending() : null
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
/**
|
|
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
|
+
*/
|
|
75
92
|
function currentPlan() {
|
|
76
|
-
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)
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
/** The work a plan asks for, without timestamps or reporting fields. */
|
|
@@ -94,12 +116,10 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
94
116
|
if (isCurrent() === false) return false
|
|
95
117
|
// When the merge could not be written, this in-memory plan is the complete set and the file
|
|
96
118
|
// is only its older subset. The epoch check above already proved no newer instance wrote.
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (current === null) return crashFallback === plan && isCrashPlan(plan) === true
|
|
102
|
-
return current.planId === plan.planId && planKey(current) === planKey(plan)
|
|
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)
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
function isCrashPlan(plan) {
|
|
@@ -169,6 +189,16 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
169
189
|
return work.sessions.length + work.goals.length + work.jobs.length
|
|
170
190
|
}
|
|
171
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
|
+
|
|
172
202
|
/**
|
|
173
203
|
* What the button shows. `action` is the one decision the client may not make itself:
|
|
174
204
|
* `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
|
|
@@ -187,14 +217,17 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
187
217
|
if (busy) action = 'busy'
|
|
188
218
|
else if (plan !== null) action = 'resume'
|
|
189
219
|
else if (current.readable === true && workCount > 0) action = 'pause'
|
|
190
|
-
// The dot means "something still needs you".
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
196
229
|
const first = attention > 0 ? attentionRows[0] : null
|
|
197
|
-
const focus = first
|
|
230
|
+
const focus = first !== null ? attentionFocus(first) : failureFocus(result)
|
|
198
231
|
return {
|
|
199
232
|
phase,
|
|
200
233
|
action,
|
|
@@ -273,17 +306,20 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
273
306
|
return out
|
|
274
307
|
}
|
|
275
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
|
+
|
|
276
315
|
function persist(plan) {
|
|
277
316
|
if (isCurrent() === false) {
|
|
278
317
|
log('stale instance: refusing to write over a newer plan')
|
|
279
318
|
return null
|
|
280
319
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return stored
|
|
285
|
-
}
|
|
286
|
-
return store.write(plan)
|
|
320
|
+
const stored = usesPending(plan) ? store.writePending(plan) : store.write(plan)
|
|
321
|
+
if (stored !== null) planFallback = null
|
|
322
|
+
return stored
|
|
287
323
|
}
|
|
288
324
|
|
|
289
325
|
/**
|
|
@@ -320,8 +356,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
320
356
|
const before = new Map((basePlan?.sessions ?? []).filter((row) => typeof row?.sessionId === 'string').map((row) => [row.sessionId, row]))
|
|
321
357
|
const restoredBefore = Number.isSafeInteger(basePlan?.restore?.attempts) && basePlan.restore.attempts > 0
|
|
322
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))
|
|
323
363
|
plan.sessions = plan.sessions.map((row) => {
|
|
324
|
-
if (row.intent !== 'continue') return row
|
|
364
|
+
if (row.intent !== 'continue' || captured.has(row.sessionId) === false) return row
|
|
325
365
|
const prior = before.get(row.sessionId)
|
|
326
366
|
const priorEpoch = Number.isSafeInteger(prior?.epoch) ? prior.epoch : 0
|
|
327
367
|
if (Number.isSafeInteger(row.epoch) && row.epoch > priorEpoch) return row
|
|
@@ -401,16 +441,21 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
401
441
|
// The crash record described work that was not paused. This press did pause it, so the plan
|
|
402
442
|
// becomes a normal pause plan and the crash evidence is no longer a separate obligation.
|
|
403
443
|
plan.source = 'pause' // ui-copy-ignore
|
|
404
|
-
if
|
|
405
|
-
|
|
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
|
|
406
452
|
if (kept === null) {
|
|
407
|
-
|
|
453
|
+
planFallback = plan
|
|
408
454
|
lastError = 'store'
|
|
455
|
+
} else {
|
|
456
|
+
planFallback = null
|
|
409
457
|
}
|
|
410
|
-
} else if (typeof store.clearPending === 'function') {
|
|
411
|
-
if (store.clearPending() === false) lastError = 'store'
|
|
412
458
|
}
|
|
413
|
-
crashFallback = null
|
|
414
459
|
} else if (store.write(plan) === null) {
|
|
415
460
|
// The durable copy still says `pausing` or `paused`: the next boot reports it instead of
|
|
416
461
|
// claiming a stop that was never written.
|
|
@@ -494,18 +539,47 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
494
539
|
// flight turns a long serial restore into one batch without flooding the Harness.
|
|
495
540
|
await Promise.all(Array.from({ length: Math.min(4, sessionRows.length) }, sessionWorker))
|
|
496
541
|
for (const goal of plan.goals ?? []) {
|
|
542
|
+
if (stale()) break
|
|
497
543
|
const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
|
|
498
|
-
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
|
|
499
564
|
if (outcome?.status === 'restored' && Number.isSafeInteger(outcome.revision)) {
|
|
500
565
|
// Remember what this plan armed. A restarted process sees `active + disarmed`, and that
|
|
501
566
|
// exact revision is the only proof the goal is ours to arm again rather than someone else's.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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 })
|
|
505
581
|
if (persist(plan) === null) {
|
|
506
|
-
|
|
507
|
-
// Keep the plan (sessions mount idempotently) instead of silently skipping the goal.
|
|
508
|
-
summary.failed.push(`goal ${goal.goalId ?? goal.sessionId}: checkpoint not saved`)
|
|
582
|
+
summary.failed.push(`${label}: checkpoint not saved`)
|
|
509
583
|
lastError = 'store'
|
|
510
584
|
phase = 'paused'
|
|
511
585
|
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
@@ -532,9 +606,22 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
532
606
|
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
533
607
|
}
|
|
534
608
|
|
|
535
|
-
|
|
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) {
|
|
614
|
+
lastError = 'store'
|
|
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)) {
|
|
536
622
|
if (stillMine(plan) === false) {
|
|
537
623
|
log('stale instance: crash record left alone')
|
|
624
|
+
phase = 'paused'
|
|
538
625
|
return { status: 'partial', summary, outstanding: 0 }
|
|
539
626
|
}
|
|
540
627
|
if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
|
|
@@ -544,6 +631,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
544
631
|
}
|
|
545
632
|
if (stillMine(plan) === false) {
|
|
546
633
|
log('stale instance: crash record left alone')
|
|
634
|
+
phase = 'paused'
|
|
547
635
|
return { status: 'partial', summary, outstanding: 0 }
|
|
548
636
|
}
|
|
549
637
|
if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
|
|
@@ -551,16 +639,12 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
551
639
|
phase = 'paused'
|
|
552
640
|
return { status: 'partial', summary, outstanding: 0 }
|
|
553
641
|
}
|
|
554
|
-
|
|
642
|
+
planFallback = null
|
|
555
643
|
if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
|
|
556
|
-
try {
|
|
557
|
-
heartbeat?.release?.()
|
|
558
|
-
} catch (error) {
|
|
559
|
-
log(`cannot release crash record: ${error?.message ?? error}`)
|
|
560
|
-
}
|
|
561
644
|
} else {
|
|
562
645
|
if (stillMine(plan) === false) {
|
|
563
646
|
log('stale instance: pause plan left alone')
|
|
647
|
+
phase = 'paused'
|
|
564
648
|
return { status: 'partial', summary, outstanding: 0 }
|
|
565
649
|
}
|
|
566
650
|
const consumed = store.consume(plan, summary)
|
|
@@ -569,6 +653,15 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
569
653
|
phase = 'paused'
|
|
570
654
|
return { status: 'partial', summary, outstanding: 0 }
|
|
571
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}`)
|
|
572
665
|
}
|
|
573
666
|
phase = 'idle'
|
|
574
667
|
return { status: 'resumed', summary }
|
|
@@ -625,7 +718,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
625
718
|
* overwrites the previous process's file; it is persisted here so a second restart still sees it.
|
|
626
719
|
*/
|
|
627
720
|
async function hydrate(options = {}) {
|
|
628
|
-
|
|
721
|
+
planFallback = null
|
|
629
722
|
lastError = null
|
|
630
723
|
const residue = options?.residue ?? heartbeat?.residue ?? null
|
|
631
724
|
const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
|
|
@@ -648,7 +741,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
648
741
|
// A failed write stays in memory for this process, but the caller must not let the
|
|
649
742
|
// heartbeat overwrite the file the next process would have read the same residue from.
|
|
650
743
|
if (stored === null) {
|
|
651
|
-
|
|
744
|
+
planFallback = crash
|
|
652
745
|
durable = false
|
|
653
746
|
lastError = 'store'
|
|
654
747
|
heartbeat?.hold?.()
|
|
@@ -681,10 +774,13 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
681
774
|
if (stored === null) {
|
|
682
775
|
durable = false
|
|
683
776
|
lastError = 'store'
|
|
684
|
-
|
|
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
|
|
685
780
|
heartbeat?.hold?.()
|
|
686
781
|
log('cannot merge the crash plan; keeping the previous heartbeat')
|
|
687
782
|
} else {
|
|
783
|
+
planFallback = null
|
|
688
784
|
log(`crash sessions merged (${added.length})`)
|
|
689
785
|
}
|
|
690
786
|
}
|
|
@@ -692,7 +788,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
692
788
|
}
|
|
693
789
|
}
|
|
694
790
|
|
|
695
|
-
|
|
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'
|
|
696
794
|
return { phase, durable }
|
|
697
795
|
}
|
|
698
796
|
|
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 })
|
|
@@ -293,6 +300,7 @@ export function createProbe(services, options = {}) {
|
|
|
293
300
|
return { status: 'failed', detail }
|
|
294
301
|
}
|
|
295
302
|
}
|
|
303
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
296
304
|
|
|
297
305
|
// Version 2 plans carry only `running`; a normalized version 3 plan carries `intent`. Reading
|
|
298
306
|
// the old field here keeps a plan written by 0.3.0 working across the upgrade.
|
|
@@ -317,6 +325,7 @@ export function createProbe(services, options = {}) {
|
|
|
317
325
|
if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
|
|
318
326
|
return { status: 'failed', detail: 'prompt unavailable', continued: false }
|
|
319
327
|
}
|
|
328
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
320
329
|
const request = continuation(plan, sessionId, session.epoch)
|
|
321
330
|
try {
|
|
322
331
|
// Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
|
|
@@ -361,12 +370,16 @@ export function createProbe(services, options = {}) {
|
|
|
361
370
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
362
371
|
}
|
|
363
372
|
}
|
|
373
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
364
374
|
const services_goals = service(services, 'goals')
|
|
365
375
|
if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
|
|
366
376
|
return { status: 'failed', detail: 'goals unavailable' }
|
|
367
377
|
}
|
|
368
378
|
const view = goalView(services, agent)
|
|
369
|
-
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)) {
|
|
370
383
|
return { status: 'skipped', detail: 'goal is gone' }
|
|
371
384
|
}
|
|
372
385
|
// An active goal is normally not ours to touch. The one exception is a goal this same plan
|
|
@@ -374,13 +387,22 @@ export function createProbe(services, options = {}) {
|
|
|
374
387
|
// exactly the revision the plan recorded. Another person's goal has no such record.
|
|
375
388
|
if (view.phase === 'active') {
|
|
376
389
|
if (view.activation === 'armed') return { status: 'skipped', detail: 'already active' }
|
|
377
|
-
|
|
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' }
|
|
378
397
|
try {
|
|
379
398
|
services_goals.resume(agent, { id: view.id, revision: view.revision })
|
|
380
399
|
} catch (error) {
|
|
381
400
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
382
401
|
}
|
|
383
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
|
+
}
|
|
384
406
|
return { status: 'restored', detail: 're-armed after restart', revision: after?.revision ?? null }
|
|
385
407
|
}
|
|
386
408
|
return { status: 'skipped', detail: 'goal disarmed' }
|
|
@@ -395,12 +417,16 @@ export function createProbe(services, options = {}) {
|
|
|
395
417
|
const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
|
|
396
418
|
if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
|
|
397
419
|
if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
|
|
420
|
+
if (reloaded()) return { status: 'reloaded' }
|
|
398
421
|
try {
|
|
399
422
|
services_goals.resume(agent, { id: view.id, revision: view.revision })
|
|
400
423
|
} catch (error) {
|
|
401
424
|
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
402
425
|
}
|
|
403
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
|
+
}
|
|
404
430
|
return { status: 'restored', detail: 'armed', revision: after?.revision ?? null }
|
|
405
431
|
}
|
|
406
432
|
|
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",
|