dsh-pause-resume 0.1.0 → 0.2.0

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/lib/engine.js CHANGED
@@ -1,113 +1,184 @@
1
1
  /**
2
2
  * The only place the button's shape comes from.
3
3
  *
4
- * One phase value, written before any work starts and after it ends, plus a durable snapshot. The
5
- * two transient phases are exclusive by construction: `pause()` and `resume()` refuse while either
6
- * is set, so two windows, a double press and a tool call can never overlap. The client is not
7
- * allowed to infer a phase: it asks, and renders the answer.
4
+ * One phase value, written before any work starts and after it ends, plus a durable plan. The two
5
+ * transient phases are exclusive by construction: `pause()` and `resume()` refuse while either is
6
+ * set, so two windows, a double press and a tool call can never overlap. The client is not allowed
7
+ * to infer a phase: it asks, and renders the answer.
8
+ *
9
+ * A plan is kept until every item in it has a terminal outcome. A restore that could not put one
10
+ * session back keeps the plan and the journal, so the next press retries exactly the missing item
11
+ * instead of replaying the whole pause.
8
12
  *
9
13
  * The engine performs no I/O of its own. `store` persists, `probe` reads and acts on the running
10
14
  * Harness, so every transition here is testable without a Harness.
11
15
  */
12
16
 
13
17
  const TRANSIENT = new Set(['pausing', 'resuming'])
18
+ const TERMINAL = new Set(['restored', 'skipped', 'attention'])
14
19
 
15
- export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2000, gapMs = 150, log = () => {} }) {
20
+ export function createEngine({ store, probe, heartbeat = null, now = () => Date.now(), graceMs = 2000, log = () => {} }) {
16
21
  let phase = 'idle'
17
22
  let lastError = null
18
23
  let result = null
19
24
  /** Set while a press runs, so a second caller joins the answer instead of starting work again. */
20
25
  let inflight = null
26
+ /** The crash plan is derived once per boot: its id must not move between two polls. */
27
+ let crash = null
28
+ let crashRead = false
29
+ let counter = 0
21
30
 
22
- const emptyLive = { sessions: [], goals: [], schedules: [], jobs: [], terminals: [] }
23
-
24
- function pendingSnapshot() {
25
- const snapshot = store.read()
26
- // `pausing` is included on purpose: it means the last press stopped halfway, so the work in it
27
- // still has to be restored. Reporting it as nothing to restore would hide a half-done pause.
28
- return snapshot
31
+ function newPlanId(kind) {
32
+ counter += 1
33
+ return `${kind}-${now().toString(36)}-${counter.toString(36)}`
29
34
  }
30
35
 
31
- async function live() {
32
- try {
33
- const value = await probe.live()
34
- return { ...emptyLive, ...(value ?? {}) }
35
- } catch (error) {
36
- log(`cannot read live work: ${error?.message ?? error}`)
37
- return emptyLive
38
- }
36
+ function pendingPlan() {
37
+ return store.read()
39
38
  }
40
39
 
41
- async function crashPlan() {
40
+ function readCrash() {
41
+ if (crashRead) return crash
42
+ crashRead = true
42
43
  try {
43
- const plan = await probe.crashPlan()
44
- return plan ?? null
44
+ const residue = heartbeat?.residue ?? null
45
+ // The id is derived from the residue itself: two polls of the same crash share one journal,
46
+ // and a different crash gets its own.
47
+ const at = Number.isFinite(residue?.at) ? residue.at : 0
48
+ const count = Array.isArray(residue?.sessions) ? residue.sessions.length : 0
49
+ crash = probe.crashPlan(residue, { planId: `crash-${at}-${count}` })
45
50
  } catch (error) {
46
51
  log(`cannot read crash residue: ${error?.message ?? error}`)
47
- return null
52
+ crash = null
48
53
  }
54
+ return crash
55
+ }
56
+
57
+ function currentPlan() {
58
+ return pendingPlan() ?? readCrash()
59
+ }
60
+
61
+ function itemKeys(plan) {
62
+ if (plan === null || plan === undefined) return []
63
+ const keys = []
64
+ for (const session of plan.sessions ?? []) if (typeof session?.sessionId === 'string') keys.push(`s:${session.sessionId}`)
65
+ for (const goal of plan.goals ?? []) keys.push(`g:${goal.sessionId}:${goal.goalId ?? ''}`)
66
+ return keys
67
+ }
68
+
69
+ function outstandingOf(plan) {
70
+ if (plan === null || plan === undefined) return 0
71
+ const done = store.readJournal(plan.planId)
72
+ return itemKeys(plan).filter((key) => !done.has(key)).length
49
73
  }
50
74
 
51
75
  function countsOf(plan) {
52
- if (plan === null || plan === undefined) return { sessions: 0, goals: 0, schedules: 0, jobs: 0 }
76
+ if (plan === null || plan === undefined) return { sessions: 0, goals: 0, jobs: 0 }
53
77
  return {
54
78
  sessions: Array.isArray(plan.sessions) ? plan.sessions.length : 0,
55
79
  goals: Array.isArray(plan.goals) ? plan.goals.length : 0,
56
- schedules: Array.isArray(plan.schedules) ? plan.schedules.length : 0,
57
80
  jobs: Array.isArray(plan.jobs) ? plan.jobs.length : 0,
58
81
  }
59
82
  }
60
83
 
84
+ function liveNow() {
85
+ try {
86
+ const value = probe.live()
87
+ return value ?? { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
88
+ } catch (error) {
89
+ log(`cannot read live work: ${error?.message ?? error}`)
90
+ return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
91
+ }
92
+ }
93
+
61
94
  /**
62
95
  * What the button shows. `action` is the one decision the client may not make itself:
63
96
  * `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
64
97
  */
65
- async function describe() {
66
- const snapshot = pendingSnapshot()
67
- const current = await live()
68
- const plan = snapshot ?? (await crashPlan())
98
+ function describe() {
99
+ const plan = currentPlan()
100
+ const current = liveNow()
69
101
  const busy = TRANSIENT.has(phase)
70
- const canPause = !busy && current.sessions.length + current.goals.length > 0
102
+ const work = workOf(current)
103
+ const canPause = current.readable === true && work.sessions.length + work.goals.length + work.jobs.length > 0
71
104
  const action = busy ? 'busy' : plan !== null ? 'resume' : canPause ? 'pause' : 'none'
105
+ const outstanding = outstandingOf(plan)
72
106
  return {
73
107
  phase,
74
108
  action,
75
- source: plan === null ? null : plan.status === 'pausing' ? 'crash' : snapshot !== null ? 'pause' : plan.source ?? 'crash',
76
- complete: plan === null ? null : plan.status === 'paused',
109
+ source: plan === null ? null : plan.source ?? 'pause',
110
+ // A finished pause is complete even though every item still has to be restored later.
111
+ complete: plan === null ? null : plan.status === 'paused' && lastError === null,
112
+ outstanding: outstanding, // ui-copy-ignore
77
113
  counts: countsOf(plan), // ui-copy-ignore
78
114
  live: { // ui-copy-ignore
79
- sessions: current.sessions.length,
115
+ sessions: work.sessions.filter((session) => session.running === true).length,
80
116
  goals: current.goals.length,
81
117
  jobs: current.jobs.length,
82
118
  },
83
- error: lastError, // ui-copy-ignore
119
+ attention: Array.isArray(plan?.attention) ? plan.attention.length : 0, // ui-copy-ignore
120
+ error: lastError ?? (plan?.status === 'pausing' ? 'pause-interrupted' : null), // ui-copy-ignore
84
121
  result,
85
122
  }
86
123
  }
87
124
 
88
- function snapshotOf(current) {
125
+ /**
126
+ * Only work that was actually stopped goes into the plan: a session whose turn was open, or a
127
+ * session that carries an armed goal. A session that ended normally is left alone, however it
128
+ * happens to look in the session list.
129
+ */
130
+ function workOf(current) {
131
+ const sessions = new Map()
132
+ for (const session of current.sessions ?? []) {
133
+ if (session.running === true) sessions.set(session.sessionId, session)
134
+ }
135
+ for (const goal of current.goals ?? []) {
136
+ if (!sessions.has(goal.sessionId)) sessions.set(goal.sessionId, { sessionId: goal.sessionId, running: false, lastTurn: null })
137
+ }
138
+ return {
139
+ sessions: [...sessions.values()],
140
+ goals: current.goals ?? [],
141
+ jobs: current.jobs ?? [], // ui-copy-ignore
142
+ }
143
+ }
144
+
145
+ function planOf(current, { planId, status }) {
89
146
  return {
90
- version: 1, // ui-copy-ignore
91
- status: 'pausing', // ui-copy-ignore
147
+ version: 2, // ui-copy-ignore
148
+ planId,
149
+ status,
150
+ source: 'pause', // ui-copy-ignore
92
151
  createdAt: new Date(now()).toISOString(),
93
- sessions: current.sessions, // ui-copy-ignore
94
- goals: current.goals, // ui-copy-ignore
95
- schedules: current.schedules,
96
- jobs: current.jobs, // ui-copy-ignore
97
- terminals: current.terminals,
152
+ sessions: workOf(current).sessions.map((session) => ({
153
+ sessionId: session.sessionId,
154
+ running: session.running === true,
155
+ lastTurn: Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null,
156
+ })),
157
+ goals: workOf(current).goals.map((goal) => ({
158
+ sessionId: goal.sessionId,
159
+ goalId: goal.goalId,
160
+ revision: goal.revision,
161
+ })),
162
+ jobs: workOf(current).jobs.map((job) => ({ id: job.id, owner: job.owner ?? null, status: job.status ?? null })),
98
163
  }
99
164
  }
100
165
 
101
166
  async function runPause() {
102
- const current = await live()
103
- if (current.sessions.length === 0 && current.goals.length === 0) {
167
+ const current = liveNow()
168
+ if (current.readable !== true) {
169
+ phase = 'idle'
170
+ lastError = 'registry'
171
+ return { status: 'failed', reason: 'registry' }
172
+ }
173
+ const work = workOf(current)
174
+ if (work.sessions.length + work.goals.length + work.jobs.length === 0) {
104
175
  phase = 'idle'
105
176
  return { status: 'idle', action: 'none' }
106
177
  }
107
178
  lastError = null
108
- // The snapshot exists before anything is stopped: a crash between the two is then visible as
179
+ // The plan exists before anything is stopped: a crash between the two is then visible as
109
180
  // `pausing` on the next boot instead of being reported as a clean pause.
110
- const draft = snapshotOf(current)
181
+ const draft = planOf(current, { planId: newPlanId('pause'), status: 'pausing' })
111
182
  if (store.write(draft) === null) {
112
183
  phase = 'idle'
113
184
  lastError = 'store'
@@ -118,52 +189,133 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
118
189
  stopped = await probe.stop(current, { graceMs })
119
190
  } catch (error) {
120
191
  log(`stopping work failed: ${error?.message ?? error}`)
121
- stopped = { canceled: [], unknown: [] }
192
+ stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [] }
122
193
  }
194
+
195
+ // Only goals that really stopped are part of the plan. Resuming a goal nobody paused would bump
196
+ // its revision for no reason and could override the person's own pause; a goal that failed to
197
+ // pause is reported instead and left alone.
198
+ // A cancelled turn bumps the agent's turn counter, so the baseline has to be read after the
199
+ // stop, not before it. Without this a restore would mistake its own cancelled turn for work
200
+ // the person continued by hand and skip the continuation it was asked to send.
201
+ const baseline = new Map((stopped.sessions ?? []).map((session) => [session.sessionId, session.lastTurn]))
202
+ draft.sessions = draft.sessions.map((session) => baseline.has(session.sessionId)
203
+ ? { ...session, lastTurn: Number.isSafeInteger(baseline.get(session.sessionId)) ? baseline.get(session.sessionId) : null }
204
+ : session)
205
+ draft.goals = (stopped.pausedGoals ?? []).map((goal) => ({
206
+ sessionId: goal.sessionId,
207
+ goalId: goal.goalId,
208
+ revision: goal.pausedRevision,
209
+ }))
123
210
  draft.status = 'paused'
124
211
  draft.stoppedAt = new Date(now()).toISOString()
125
- draft.stopped = stopped
212
+ draft.stopped = {
213
+ canceled: stopped.canceled ?? [],
214
+ unknown: stopped.unknown ?? [],
215
+ stillRunning: stopped.stillRunning ?? [],
216
+ goalErrors: stopped.goalErrors ?? [],
217
+ killedJobs: stopped.killedJobs ?? [],
218
+ jobErrors: stopped.jobErrors ?? [],
219
+ }
220
+ draft.attention = [
221
+ ...(stopped.stillRunning ?? []).map((id) => ({ kind: 'session', id, detail: 'still running' })),
222
+ ...(stopped.goalErrors ?? []).map((detail) => ({ kind: 'goal', id: '', detail })),
223
+ ...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: '', detail })),
224
+ ]
126
225
  if (store.write(draft) === null) {
127
226
  // The durable copy still says `pausing`, which is the truth: the next boot reports it.
128
227
  lastError = 'store'
129
228
  }
130
229
  phase = 'paused'
131
- const counts = countsOf(draft)
132
- return { status: 'paused', counts }
230
+ return { status: 'paused', counts: countsOf(draft), outstanding: outstandingOf(draft) }
133
231
  }
134
232
 
233
+ function summaryOf() {
234
+ return { restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
235
+ }
236
+
237
+ /**
238
+ * One restore pass. Every item that ends in a terminal outcome is journaled, so a later press
239
+ * only walks what is left. A retryable failure keeps the plan; a durable write failure keeps it
240
+ * too, because the archive is the record that the work came back.
241
+ */
135
242
  async function runResume(plan) {
136
243
  lastError = null
137
- const done = store.readJournal()
138
- const summary = { restored: [], skipped: [], failed: [], jobs: countsOf(plan).jobs }
139
- const mark = (key) => {
140
- store.appendJournal(key)
244
+ // A crash in the middle of a pause left a plan that never finished stopping. Its sessions were
245
+ // still turning when the process died, so mounting them is safe but continuing them is not: the
246
+ // person asked for silence, not another round.
247
+ if (plan.status === 'pausing') {
248
+ plan.source = 'interrupted'
249
+ plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
250
+ plan.sessions = (plan.sessions ?? []).map((session) => ({ ...session, running: false, lastTurn: null }))
251
+ plan.status = 'paused'
252
+ }
253
+ const summary = summaryOf()
254
+ summary.jobs = countsOf(plan).jobs
255
+ const done = store.readJournal(plan.planId)
256
+ const record = (key) => {
141
257
  done.add(key)
258
+ store.appendJournal(plan.planId, key)
142
259
  }
143
260
  const step = async (key, label, run) => {
144
261
  if (done.has(key)) return
262
+ let outcome
145
263
  try {
146
- const outcome = await run()
147
- if (outcome === 'restored') summary.restored.push(label)
148
- else summary.skipped.push(label)
264
+ outcome = await run()
149
265
  } catch (error) {
150
- summary.failed.push(`${label}: ${String(error?.message ?? error).slice(0, 120)}`)
266
+ outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
267
+ }
268
+ const status = typeof outcome?.status === 'string' ? outcome.status : 'failed'
269
+ if (TERMINAL.has(status)) {
270
+ record(key)
271
+ if (status === 'restored') summary.restored.push(label)
272
+ else if (status === 'skipped') summary.skipped.push(label)
273
+ else {
274
+ summary.attention.push(`${label}: ${outcome?.detail ?? 'needs a look'}`)
275
+ plan.attention = [...(plan.attention ?? []), { kind: 'session', id: label, detail: String(outcome?.detail ?? '') }]
276
+ }
277
+ } else {
278
+ summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
151
279
  }
152
- mark(key)
153
280
  }
154
281
 
155
282
  for (const session of plan.sessions ?? []) {
156
283
  await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
157
284
  }
158
285
  for (const goal of plan.goals ?? []) {
159
- await step(`g:${goal.goalId ?? goal.sessionId}`, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
286
+ const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
287
+ await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
160
288
  }
161
- for (const schedule of plan.schedules ?? []) {
162
- await step(`c:${schedule.id}`, `schedule ${schedule.id}`, () => probe.restoreSchedule(schedule, { plan }))
163
- }
164
- store.consume(summary)
165
- store.clearJournal()
289
+ // A killed job cannot be replayed: the same command may have had an unknown effect, so it is
290
+ // reported once and left to the person. It is not a retryable item and never blocks the plan.
291
+ for (const job of plan.jobs ?? []) summary.attention.push(`job ${job.id}: stopped, not restarted`)
292
+
166
293
  result = summary
294
+ const failed = summary.failed.length > 0
295
+ const pending = itemKeys(plan).filter((key) => !done.has(key))
296
+ if (failed || pending.length > 0) {
297
+ plan.restore = { at: summary.at, attempts: (plan.restore?.attempts ?? 0) + 1, failed: summary.failed } // ui-copy-ignore
298
+ if (store.write(plan) === null) lastError = 'store'
299
+ else lastError = 'restore-partial'
300
+ phase = 'paused'
301
+ return { status: 'partial', summary, outstanding: pending.length }
302
+ }
303
+
304
+ const consumed = store.consume(plan, summary)
305
+ if (!consumed?.ok) {
306
+ lastError = 'store'
307
+ phase = 'paused'
308
+ return { status: 'partial', summary, outstanding: 0 }
309
+ }
310
+ store.clearJournal(plan.planId)
311
+ if (plan.source === 'crash') {
312
+ crash = null
313
+ try {
314
+ heartbeat?.release?.()
315
+ } catch (error) {
316
+ log(`cannot release crash record: ${error?.message ?? error}`)
317
+ }
318
+ }
167
319
  phase = 'idle'
168
320
  return { status: 'resumed', summary }
169
321
  }
@@ -177,7 +329,7 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
177
329
  } catch (error) {
178
330
  log(`${kind} failed: ${error?.message ?? error}`)
179
331
  lastError = 'internal'
180
- phase = 'idle'
332
+ phase = pendingPlan() === null ? 'idle' : 'paused'
181
333
  return { status: 'failed', reason: 'internal' }
182
334
  } finally {
183
335
  inflight = null
@@ -189,7 +341,7 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
189
341
 
190
342
  async function pause() {
191
343
  if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
192
- if (phase === 'paused') return { status: 'already' }
344
+ if (pendingPlan() !== null) return { status: 'already', phase }
193
345
  // Set synchronously, before the first await: two windows pressing at once are then exclusive
194
346
  // in the same tick they were dispatched, not one microtask later.
195
347
  phase = 'pausing'
@@ -200,7 +352,7 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
200
352
  if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
201
353
  phase = 'resuming'
202
354
  return once('resume', async () => {
203
- const plan = pendingSnapshot() ?? (await crashPlan())
355
+ const plan = currentPlan()
204
356
  if (plan === null) {
205
357
  phase = 'idle'
206
358
  return { status: 'empty' }
@@ -211,9 +363,20 @@ export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2
211
363
 
212
364
  /** A reload of this plugin must not invent a phase: the disk and the running Harness decide it. */
213
365
  async function hydrate() {
214
- // A reloaded plugin must not invent a phase: the disk decides whether work is still stopped.
215
- phase = pendingSnapshot() !== null ? 'paused' : 'idle'
366
+ crashRead = false
367
+ crash = null
368
+ readCrash()
369
+ phase = pendingPlan() !== null ? 'paused' : 'idle'
370
+ return phase
216
371
  }
217
372
 
218
- return { describe, pause, resume, hydrate, get phase() { return phase }, pendingSnapshot, gapMs }
373
+ return {
374
+ describe,
375
+ pause,
376
+ resume,
377
+ hydrate,
378
+ get phase() { return phase },
379
+ get pendingPlan() { return pendingPlan },
380
+ get crashPlan() { return readCrash },
381
+ }
219
382
  }