dsh-pause-resume 0.4.1 → 0.4.2
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 +6 -0
- package/lib/engine.js +35 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
- 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
|
+
- Restore mounts sessions in bounded parallel batches, so wall time is the slowest batch instead of the sum of every session.
|
|
7
|
+
- Duplicate attention rows for the same item are collapsed.
|
|
8
|
+
|
|
3
9
|
## 0.4.1
|
|
4
10
|
|
|
5
11
|
A stop that could not finish no longer traps the button, and the dot stops lying after a resume.
|
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 row per item: a retried stop must not multiply the same unfinished job in the dot. */
|
|
32
|
+
function dedupeRows(rows) {
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
return rows.filter((row) => {
|
|
35
|
+
if (row === null || row === undefined) return false
|
|
36
|
+
const key = `${row.kind ?? ''}|${row.id ?? ''}|${row.detail ?? ''}`
|
|
37
|
+
if (seen.has(key)) return false
|
|
38
|
+
seen.add(key)
|
|
39
|
+
return true
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
export function createEngine({ store, probe, heartbeat = null, bootId = 'boot', epoch = null, now = () => Date.now(), graceMs = 2000, log = () => {} }) {
|
|
32
44
|
/**
|
|
33
45
|
* A reload creates a new instance and bumps the shared epoch. An old instance's press may still
|
|
@@ -147,7 +159,11 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
147
159
|
if (busy) action = 'busy'
|
|
148
160
|
else if (plan !== null) action = 'resume'
|
|
149
161
|
else if (current.readable === true && workCount > 0) action = 'pause'
|
|
150
|
-
|
|
162
|
+
// The dot means "something still needs you". A clean idle page has nothing to point at: the
|
|
163
|
+
// jobs a pause killed stay in the last-restore record, but they are not an open obligation.
|
|
164
|
+
const attentionRows = plan !== null
|
|
165
|
+
? dedupeRows(Array.isArray(plan.attention) ? plan.attention : [])
|
|
166
|
+
: phase === 'idle' && lastError === null ? null : result?.attention
|
|
151
167
|
const attention = Array.isArray(attentionRows) ? attentionRows.length : 0
|
|
152
168
|
const first = attention > 0 ? attentionRows[0] : null
|
|
153
169
|
const focus = first === null ? null : (typeof first === 'string' ? first : (first.id || first.detail || null))
|
|
@@ -342,16 +358,17 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
342
358
|
const match = new RegExp(`^${kind} ([^:]+):`).exec(String(detail))
|
|
343
359
|
return match === null ? '' : match[1].trim()
|
|
344
360
|
}
|
|
345
|
-
plan.attention = [
|
|
361
|
+
plan.attention = dedupeRows([
|
|
346
362
|
...(Array.isArray(basePlan?.attention) ? basePlan.attention : []),
|
|
347
363
|
...stillRunning.map((id) => ({ kind: 'session', id, detail: 'still running' })),
|
|
364
|
+
...(stopped.killedJobs ?? []).map((job) => ({ kind: 'job', id: job.id, detail: 'stopped, not restarted' })),
|
|
348
365
|
...(stopped.goalErrors ?? []).map((detail) => {
|
|
349
366
|
const goalId = errorId('goal', detail)
|
|
350
367
|
const owner = [...(basePlan?.goals ?? []), ...(current.goals ?? [])].find((row) => row.goalId === goalId)
|
|
351
368
|
return { kind: 'goal', id: owner?.sessionId ?? goalId, detail }
|
|
352
369
|
}),
|
|
353
370
|
...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: errorId('job', detail), detail })),
|
|
354
|
-
]
|
|
371
|
+
])
|
|
355
372
|
if (isCrashPlan(basePlan) === true) {
|
|
356
373
|
// The crash record described work that was not paused. This press did pause it, so the plan
|
|
357
374
|
// becomes a normal pause plan and the crash evidence is no longer a separate obligation.
|
|
@@ -376,7 +393,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
376
393
|
}
|
|
377
394
|
|
|
378
395
|
function summaryOf(plan) {
|
|
379
|
-
return { planId: plan.planId, source: plan.source ?? 'pause', restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
|
|
396
|
+
return { planId: plan.planId, source: plan.source ?? 'pause', restored: [], skipped: [], failed: [], attention: [], jobs: 0, jobsStopped: [], at: new Date(now()).toISOString() }
|
|
380
397
|
}
|
|
381
398
|
|
|
382
399
|
/**
|
|
@@ -427,9 +444,20 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
427
444
|
return outcome
|
|
428
445
|
}
|
|
429
446
|
|
|
430
|
-
|
|
431
|
-
|
|
447
|
+
const sessionRows = plan.sessions ?? []
|
|
448
|
+
const sessionDone = new Array(sessionRows.length)
|
|
449
|
+
let nextSession = 0
|
|
450
|
+
const sessionWorker = async () => {
|
|
451
|
+
while (nextSession < sessionRows.length) {
|
|
452
|
+
const index = nextSession
|
|
453
|
+
nextSession += 1
|
|
454
|
+
const session = sessionRows[index]
|
|
455
|
+
sessionDone[index] = await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
|
|
456
|
+
}
|
|
432
457
|
}
|
|
458
|
+
// Bounded fan-out: every mount asks the same registry for its own session, so a handful in
|
|
459
|
+
// flight turns a long serial restore into one batch without flooding the Harness.
|
|
460
|
+
await Promise.all(Array.from({ length: Math.min(4, sessionRows.length) }, sessionWorker))
|
|
433
461
|
for (const goal of plan.goals ?? []) {
|
|
434
462
|
const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
|
|
435
463
|
const outcome = await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
|
|
@@ -444,7 +472,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
444
472
|
}
|
|
445
473
|
// A killed job cannot be replayed: the same command may have had an unknown effect, so it is
|
|
446
474
|
// reported once and left to the person. It never blocks the plan and is never restarted.
|
|
447
|
-
for (const job of plan.jobs ?? []) summary.
|
|
475
|
+
for (const job of plan.jobs ?? []) summary.jobsStopped.push(`job ${job.id}: stopped, not restarted`)
|
|
448
476
|
|
|
449
477
|
result = summary
|
|
450
478
|
if (summary.failed.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pause-resume",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|