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 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
- const attentionRows = plan !== null ? plan.attention : result?.attention
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
- for (const session of plan.sessions ?? []) {
431
- 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
+ }
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.attention.push(`job ${job.id}: stopped, not restarted`)
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.1",
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",