dsh-pause-resume 0.4.2 → 0.4.4
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 -4
- package/README.md +3 -1
- package/client.js +3 -2
- package/index.js +8 -8
- package/lib/engine.js +64 -7
- package/lib/heartbeat.js +23 -5
- package/lib/probe.js +7 -2
- package/lib/snapshot.js +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.4.
|
|
3
|
+
## 0.4.4
|
|
4
|
+
|
|
5
|
+
Restores are now safe to hand over between instances and across an upgrade.
|
|
6
|
+
|
|
7
|
+
* An interrupted pause resets the whole restore policy (`intent: "mount"`, `epoch: 0`), so it is
|
|
8
|
+
mounted and never sends a continuation.
|
|
9
|
+
* When a merged crash plan could not be written to disk, resume restores that in-memory plan, which
|
|
10
|
+
holds both the old and the newly found sessions. The older `pending.json` no longer wins and hides
|
|
11
|
+
half the work.
|
|
12
|
+
* `heartbeat.release()` restores a full lifecycle: the periodic sample is re-armed, `stopped` is
|
|
13
|
+
cleared, and state events can persist again. Previously the file stayed frozen after a repaired
|
|
14
|
+
write failure.
|
|
15
|
+
* A pre-epoch plan keeps its original continuation request id (`…:continue:<session>`), so upgrading
|
|
16
|
+
cannot queue a second copy of a message the old version already admitted.
|
|
17
|
+
* A restore that loses ownership (plugin reload) stops before publishing, consuming or clearing. It
|
|
18
|
+
also compares the plan's actual work, not only its id, so additions from a newer press survive.
|
|
19
|
+
* A goal whose checkpoint cannot be written keeps the plan on disk with the failure named, instead of
|
|
20
|
+
silently skipping the goal on the next boot.
|
|
21
|
+
* The button hint combines the error with the focused item for partial pause/restore.
|
|
22
|
+
|
|
23
|
+
## 0.4.3
|
|
4
24
|
|
|
5
25
|
- 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.
|
|
6
26
|
- Restore mounts sessions in bounded parallel batches, so wall time is the slowest batch instead of the sum of every session.
|
|
@@ -20,9 +40,9 @@ A stop that could not finish no longer traps the button, and the dot stops lying
|
|
|
20
40
|
|
|
21
41
|
The crash path is now a first-class restore path, and a dead button after a restart is fixed.
|
|
22
42
|
|
|
23
|
-
* Routes are registered synchronously while `apply` runs.
|
|
24
|
-
|
|
25
|
-
|
|
43
|
+
* Routes are registered synchronously while `apply` runs. Registration itself is not the problem —
|
|
44
|
+
the server keeps a live route table — but registering after hydration meant the button could be
|
|
45
|
+
pressed before the table had an entry. Handlers now wait for hydration instead.
|
|
26
46
|
* A crash plan is written to `pending.json` before the heartbeat is replaced. If that write fails,
|
|
27
47
|
the heartbeat is held and the previous `running.json` is left untouched, so the next boot still
|
|
28
48
|
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
|
@@ -7,7 +7,7 @@ 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.4'
|
|
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
|
]
|
|
@@ -275,8 +275,8 @@ export function apply(ctx, config = {}) {
|
|
|
275
275
|
log(`cannot follow agent events: ${error?.message ?? error}`)
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
//
|
|
279
|
-
//
|
|
278
|
+
// Routes must be registered while `apply` is still synchronous, so a press can never arrive
|
|
279
|
+
// before the table has an entry. Hydration is asynchronous; the handlers wait on this promise.
|
|
280
280
|
let releaseReady = () => {}
|
|
281
281
|
const ready = new Promise((resolve) => {
|
|
282
282
|
releaseReady = resolve
|
package/lib/engine.js
CHANGED
|
@@ -66,7 +66,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
66
66
|
|
|
67
67
|
function crashPlan() {
|
|
68
68
|
const stored = typeof store.readPending === 'function' ? store.readPending() : null
|
|
69
|
-
|
|
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
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/** Exactly one plan is pending, and a finished pause outranks a leftover crash record. */
|
|
@@ -74,6 +76,32 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
74
76
|
return pausedPlan() ?? crashPlan()
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/** The work a plan asks for, without timestamps or reporting fields. */
|
|
80
|
+
function planKey(plan) {
|
|
81
|
+
const row = (item) => [item?.sessionId ?? '', item?.intent ?? (item?.running === true ? 'continue' : 'mount'), item?.goalId ?? '']
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
sessions: (plan?.sessions ?? []).map(row),
|
|
84
|
+
goals: (plan?.goals ?? []).map((goal) => [goal?.sessionId ?? '', goal?.goalId ?? '']),
|
|
85
|
+
jobs: (plan?.jobs ?? []).map((job) => job?.id ?? ''),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A plan is only ours to spend if the durable copy still asks for exactly the same work. A
|
|
91
|
+
* merged plan keeps the planId, so an id comparison alone would delete another press's additions.
|
|
92
|
+
*/
|
|
93
|
+
function stillMine(plan) {
|
|
94
|
+
if (isCurrent() === false) return false
|
|
95
|
+
// When the merge could not be written, this in-memory plan is the complete set and the file
|
|
96
|
+
// is only its older subset. The epoch check above already proved no newer instance wrote.
|
|
97
|
+
if (isCrashPlan(plan) === true && crashFallback === plan) return true
|
|
98
|
+
const current = isCrashPlan(plan)
|
|
99
|
+
? (typeof store.readPending === 'function' ? store.readPending() : null)
|
|
100
|
+
: store.read()
|
|
101
|
+
if (current === null) return crashFallback === plan && isCrashPlan(plan) === true
|
|
102
|
+
return current.planId === plan.planId && planKey(current) === planKey(plan)
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
function isCrashPlan(plan) {
|
|
78
106
|
return plan !== null && plan !== undefined && plan.source === 'crash'
|
|
79
107
|
}
|
|
@@ -408,7 +436,11 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
408
436
|
if (plan.status === 'pausing') {
|
|
409
437
|
plan.source = plan.source === 'crash' ? 'crash' : 'interrupted'
|
|
410
438
|
plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
|
|
411
|
-
|
|
439
|
+
// `intent` outranks `running` in the probe, so an interrupted pause must reset the whole
|
|
440
|
+
// policy: mount only, no admission, and no earlier cycle's request identity.
|
|
441
|
+
plan.sessions = (plan.sessions ?? []).map((session) => ({
|
|
442
|
+
...session, intent: 'mount', running: false, epoch: 0, lastTurn: null,
|
|
443
|
+
}))
|
|
412
444
|
plan.status = 'paused'
|
|
413
445
|
if (persist(plan) === null) {
|
|
414
446
|
phase = 'paused'
|
|
@@ -425,7 +457,9 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
425
457
|
log(`cannot journal ${key}`)
|
|
426
458
|
}
|
|
427
459
|
}
|
|
460
|
+
const stale = () => isCurrent() === false
|
|
428
461
|
const step = async (key, label, run) => {
|
|
462
|
+
if (stale()) return { status: 'failed', detail: 'reloaded' }
|
|
429
463
|
let outcome
|
|
430
464
|
try {
|
|
431
465
|
outcome = await run()
|
|
@@ -433,6 +467,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
433
467
|
outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
434
468
|
}
|
|
435
469
|
const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
|
|
470
|
+
if (status === 'reloaded') return outcome
|
|
436
471
|
if (TERMINAL.has(status) === false) {
|
|
437
472
|
summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
|
|
438
473
|
return outcome
|
|
@@ -467,13 +502,27 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
467
502
|
plan.goals = (plan.goals ?? []).map((row) => (row.sessionId === goal.sessionId && row.goalId === goal.goalId
|
|
468
503
|
? { ...row, armedRevision: outcome.revision }
|
|
469
504
|
: row))
|
|
470
|
-
if (persist(plan) === null)
|
|
505
|
+
if (persist(plan) === null) {
|
|
506
|
+
// Without the checkpoint a later boot cannot prove this goal is ours to arm again.
|
|
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`)
|
|
509
|
+
lastError = 'store'
|
|
510
|
+
phase = 'paused'
|
|
511
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
512
|
+
}
|
|
471
513
|
}
|
|
472
514
|
}
|
|
473
515
|
// A killed job cannot be replayed: the same command may have had an unknown effect, so it is
|
|
474
516
|
// reported once and left to the person. It never blocks the plan and is never restarted.
|
|
475
517
|
for (const job of plan.jobs ?? []) summary.jobsStopped.push(`job ${job.id}: stopped, not restarted`)
|
|
476
518
|
|
|
519
|
+
if (stale()) {
|
|
520
|
+
// A reload owns the plan now. Publishing a summary or consuming anything here would erase
|
|
521
|
+
// work the new instance captured while these mounts were in flight.
|
|
522
|
+
log('stale instance: restore result discarded')
|
|
523
|
+
return { status: 'partial', summary, outstanding: outstandingOf(plan) }
|
|
524
|
+
}
|
|
525
|
+
|
|
477
526
|
result = summary
|
|
478
527
|
if (summary.failed.length > 0) {
|
|
479
528
|
plan.restore = { at: summary.at, attempts: (plan.restore?.attempts ?? 0) + 1, failed: summary.failed } // ui-copy-ignore
|
|
@@ -484,11 +533,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
484
533
|
}
|
|
485
534
|
|
|
486
535
|
if (isCrashPlan(plan) === true) {
|
|
536
|
+
if (stillMine(plan) === false) {
|
|
537
|
+
log('stale instance: crash record left alone')
|
|
538
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
539
|
+
}
|
|
487
540
|
if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
|
|
488
541
|
lastError = 'store'
|
|
489
542
|
phase = 'paused'
|
|
490
543
|
return { status: 'partial', summary, outstanding: 0 }
|
|
491
544
|
}
|
|
545
|
+
if (stillMine(plan) === false) {
|
|
546
|
+
log('stale instance: crash record left alone')
|
|
547
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
548
|
+
}
|
|
492
549
|
if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
|
|
493
550
|
lastError = 'store'
|
|
494
551
|
phase = 'paused'
|
|
@@ -496,16 +553,16 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
496
553
|
}
|
|
497
554
|
crashFallback = null
|
|
498
555
|
if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
|
|
499
|
-
if (isCurrent() === false) {
|
|
500
|
-
log('stale instance: crash record left alone')
|
|
501
|
-
return { status: 'partial', summary, outstanding: 0 }
|
|
502
|
-
}
|
|
503
556
|
try {
|
|
504
557
|
heartbeat?.release?.()
|
|
505
558
|
} catch (error) {
|
|
506
559
|
log(`cannot release crash record: ${error?.message ?? error}`)
|
|
507
560
|
}
|
|
508
561
|
} else {
|
|
562
|
+
if (stillMine(plan) === false) {
|
|
563
|
+
log('stale instance: pause plan left alone')
|
|
564
|
+
return { status: 'partial', summary, outstanding: 0 }
|
|
565
|
+
}
|
|
509
566
|
const consumed = store.consume(plan, summary)
|
|
510
567
|
if (!consumed?.ok) {
|
|
511
568
|
lastError = 'store'
|
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
|
@@ -236,10 +236,15 @@ export function createProbe(services, options = {}) {
|
|
|
236
236
|
// The epoch is the pause cycle. A retry of the same pause reuses the id (official dedupe keeps
|
|
237
237
|
// admission idempotent), while a new pause after a partial restore gets a fresh one, so the
|
|
238
238
|
// earlier admitted message cannot swallow this cycle's continuation.
|
|
239
|
-
const cycle = Number.isSafeInteger(epoch) && epoch
|
|
239
|
+
const cycle = Number.isSafeInteger(epoch) && epoch >= 0 ? epoch : 1
|
|
240
|
+
// Cycle 0 is a pre-epoch plan: keep the exact id that may already sit in the official queue,
|
|
241
|
+
// so upgrading the plugin cannot duplicate a continuation the old version admitted.
|
|
242
|
+
const requestId = cycle === 0
|
|
243
|
+
? `${plan.planId}:continue:${sessionId}`
|
|
244
|
+
: `${plan.planId}:continue:${sessionId}:${cycle}`
|
|
240
245
|
return {
|
|
241
246
|
sessionId,
|
|
242
|
-
requestId
|
|
247
|
+
requestId,
|
|
243
248
|
content: [{ type: 'text', text: continueText }],
|
|
244
249
|
// `queue` is the shipped public mode: deliver after the current turn, start one when idle.
|
|
245
250
|
mode: 'queue',
|
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.4",
|
|
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",
|