dsh-pause-resume 0.4.0 → 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 CHANGED
@@ -1,5 +1,21 @@
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
+
9
+ ## 0.4.1
10
+
11
+ A stop that could not finish no longer traps the button, and the dot stops lying after a resume.
12
+
13
+ * An unfinished stop now always offers the resume that releases it. The button used to prefer
14
+ `pause` whenever live work remained, so a partial stop re-ran the same stop on the same
15
+ uncancellable turn: the icon never changed, the attention dot stayed, and pressing it did
16
+ nothing a person could see.
17
+ * Work that starts while a plan waits no longer flips the button back to pause. The plan owns the
18
+ action until it is consumed.
3
19
  ## 0.4.0
4
20
 
5
21
  The crash path is now a first-class restore path, and a dead button after a restart is fixed.
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.0'
10
+ export const VERSION = '0.4.1'
11
11
 
12
12
  const BASE_PATH = '/dsh-pause-resume'
13
13
  const BUTTON_HEADER = 'x-dsh-pause-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
@@ -139,11 +151,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
139
151
  const work = workOf(current)
140
152
  const busy = TRANSIENT.has(phase)
141
153
  const workCount = liveWorkCount(work)
154
+ // A plan is an unfinished stop: the only way forward is to resume it, even when the stop left
155
+ // work behind. Preferring `pause` there used to trap a partial stop forever: every press
156
+ // re-ran the same stop, the residue could never be cancelled, and the button never offered the
157
+ // resume that would have released the plan.
142
158
  let action = 'none'
143
159
  if (busy) action = 'busy'
144
- else if (current.readable === true && workCount > 0) action = 'pause'
145
160
  else if (plan !== null) action = 'resume'
146
- const attentionRows = plan !== null ? plan.attention : result?.attention
161
+ else if (current.readable === true && workCount > 0) action = 'pause'
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
147
167
  const attention = Array.isArray(attentionRows) ? attentionRows.length : 0
148
168
  const first = attention > 0 ? attentionRows[0] : null
149
169
  const focus = first === null ? null : (typeof first === 'string' ? first : (first.id || first.detail || null))
@@ -338,16 +358,17 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
338
358
  const match = new RegExp(`^${kind} ([^:]+):`).exec(String(detail))
339
359
  return match === null ? '' : match[1].trim()
340
360
  }
341
- plan.attention = [
361
+ plan.attention = dedupeRows([
342
362
  ...(Array.isArray(basePlan?.attention) ? basePlan.attention : []),
343
363
  ...stillRunning.map((id) => ({ kind: 'session', id, detail: 'still running' })),
364
+ ...(stopped.killedJobs ?? []).map((job) => ({ kind: 'job', id: job.id, detail: 'stopped, not restarted' })),
344
365
  ...(stopped.goalErrors ?? []).map((detail) => {
345
366
  const goalId = errorId('goal', detail)
346
367
  const owner = [...(basePlan?.goals ?? []), ...(current.goals ?? [])].find((row) => row.goalId === goalId)
347
368
  return { kind: 'goal', id: owner?.sessionId ?? goalId, detail }
348
369
  }),
349
370
  ...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: errorId('job', detail), detail })),
350
- ]
371
+ ])
351
372
  if (isCrashPlan(basePlan) === true) {
352
373
  // The crash record described work that was not paused. This press did pause it, so the plan
353
374
  // becomes a normal pause plan and the crash evidence is no longer a separate obligation.
@@ -372,7 +393,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
372
393
  }
373
394
 
374
395
  function summaryOf(plan) {
375
- 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() }
376
397
  }
377
398
 
378
399
  /**
@@ -423,9 +444,20 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
423
444
  return outcome
424
445
  }
425
446
 
426
- for (const session of plan.sessions ?? []) {
427
- await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
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
+ }
428
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))
429
461
  for (const goal of plan.goals ?? []) {
430
462
  const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
431
463
  const outcome = await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
@@ -440,7 +472,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
440
472
  }
441
473
  // A killed job cannot be replayed: the same command may have had an unknown effect, so it is
442
474
  // reported once and left to the person. It never blocks the plan and is never restarted.
443
- for (const job of plan.jobs ?? []) summary.attention.push(`job ${job.id}: stopped, not restarted`)
475
+ for (const job of plan.jobs ?? []) summary.jobsStopped.push(`job ${job.id}: stopped, not restarted`)
444
476
 
445
477
  result = summary
446
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.0",
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",