dsh-pause-resume 0.2.0 → 0.3.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/README.md CHANGED
@@ -40,15 +40,19 @@ A restore is not a claim that work continued. It is three honest steps per item:
40
40
  3. **Re-arm a goal** with the revision it had when paused. The revision is the person's veto: if the
41
41
  goal changed while paused, it is not ours to resume and the item is `skipped`.
42
42
 
43
- Every finished item is journaled before the plan is archived, so a crash in the middle of a restore
44
- continues with the remainder instead of restoring the same session twice. A failure leaves the plan
45
- and the journal on disk: the next press retries only the failed item.
43
+ Every attempt is journaled with the id of the process that made it, but a journal is reporting, not a
44
+ promise: the next boot asks the live Harness again instead of trusting an old row. The continuation
45
+ request id is derived from the plan and the session, so a retry before a turn starts is deduplicated
46
+ by the Host. A failure leaves the plan on disk and the next press retries every item that still needs
47
+ work.
46
48
 
47
49
  ## Crash, not just a press
48
50
 
49
51
  A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
50
52
  `running.json` holds the sessions whose turn is open right now and the id of the process that wrote
51
- it. A new process finds the previous process's record and offers ▶ with no press needed.
53
+ it. The previous process's record is turned into a crash plan in `pending.json` before the heartbeat
54
+ file is replaced, so a second or third restart still sees the same work and offers ▶ with no press
55
+ needed.
52
56
 
53
57
  A crash record is deliberately weaker than a press: it proves a turn was open, not whether the person
54
58
  wanted it continued. So restore mounts those sessions and lists them in the outcome; it does not
@@ -74,8 +78,8 @@ machine; local-only is the default.
74
78
  a double press and a tool call cannot overlap. The second caller is told `busy`.
75
79
  * **Durable before destructive.** No turn is cancelled until the plan is on disk. A failed write means
76
80
  nothing was stopped.
77
- * **Idempotent.** A restore journals every finished item; the continuation request id is derived from
78
- the plan, so a retry cannot post the same message twice.
81
+ * **Idempotent.** The continuation request id is derived from the plan and the session, so a retry
82
+ cannot post the same message twice; a turn that already moved is not continued again.
79
83
  * **Honest.** Unreadable state never reports `paused`. A session deleted by hand, a goal changed by
80
84
  hand, a session already running: each is skipped and named in the outcome.
81
85
  * **No hidden takeover.** There is no public API to detach an agent, so the pause guarantee is
@@ -92,7 +96,8 @@ All state lives under `$DSH_HOME/pause-resume/`:
92
96
  | File | Purpose |
93
97
  | --- | --- |
94
98
  | `snapshot.json` | the work a press stopped, `pausing` or `paused` |
95
- | `journal/<planId>.jsonl` | restore items already finished |
99
+ | `pending.json` | a crash plan, kept until a restore finishes |
100
+ | `journal/<planId>.jsonl` | restore attempts, tagged with the boot that made them |
96
101
  | `restores/<planId>.json` | the archived plan, kept as a record of the last restore |
97
102
  | `last-restore.json` | summary of the last restore |
98
103
  | `running.json` | sessions with an open turn, for crash residue |
package/client.js CHANGED
@@ -172,24 +172,36 @@ window.__ModuleLoader__.load({
172
172
  ensureStyle()
173
173
  mounted.current = true
174
174
  let timer = null
175
+ let stopped = false
175
176
  let current = null
176
- const beat = () => {
177
- if (document.visibilityState !== 'visible' || inflight.current !== null) return
178
- try {
179
- current?.abort()
180
- } catch {}
177
+ // One request at a time, and the next one starts after this one settles. A fixed interval
178
+ // that aborts its own predecessor made any answer slower than the interval invisible.
179
+ const beat = async () => {
180
+ timer = null
181
+ if (stopped || document.visibilityState !== 'visible' || inflight.current !== null) { schedule() ; return }
181
182
  current = typeof AbortController === 'function' ? new AbortController() : null
182
- void pull(current?.signal)
183
+ try {
184
+ await pull(current?.signal)
185
+ } finally {
186
+ current = null
187
+ schedule()
188
+ }
189
+ }
190
+ const schedule = () => {
191
+ if (stopped || timer !== null) return
192
+ timer = setTimeout(() => { void beat() }, POLL_MS)
183
193
  }
184
- beat()
185
- timer = setInterval(beat, POLL_MS)
186
194
  const onVisible = () => {
187
- if (document.visibilityState === 'visible') beat()
195
+ if (document.visibilityState !== 'visible') return
196
+ if (timer !== null) { clearTimeout(timer); timer = null }
197
+ void beat()
188
198
  }
199
+ void beat()
189
200
  document.addEventListener('visibilitychange', onVisible)
190
201
  return () => {
202
+ stopped = true
191
203
  mounted.current = false
192
- if (timer !== null) clearInterval(timer)
204
+ if (timer !== null) clearTimeout(timer)
193
205
  try {
194
206
  current?.abort()
195
207
  } catch {}
package/index.js CHANGED
@@ -6,7 +6,7 @@ import { createHeartbeat } from './lib/heartbeat.js'
6
6
  import { createProbe, listRunning } from './lib/probe.js'
7
7
 
8
8
  export const name = 'dsh-pause-resume'
9
- export const VERSION = '0.2.0'
9
+ export const VERSION = '0.3.0'
10
10
 
11
11
  const BASE_PATH = '/dsh-pause-resume'
12
12
  const BUTTON_HEADER = 'x-dsh-pause-resume'
@@ -147,7 +147,7 @@ function serve(ctx, engine, heartbeat, options) {
147
147
  route(async (req, res) => {
148
148
  const outcome = await engine.pause()
149
149
  log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
150
- if (outcome.status === 'paused') heartbeat.release()
150
+ if (outcome.status === 'paused' || outcome.status === 'partial') heartbeat.release()
151
151
  sendJson(res, 200, { ...engine.describe(), outcome })
152
152
  }, false),
153
153
  ],
@@ -216,6 +216,7 @@ export function apply(ctx, config = {}) {
216
216
  store,
217
217
  probe,
218
218
  heartbeat,
219
+ bootId: bootId(),
219
220
  graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
220
221
  log,
221
222
  })
@@ -265,11 +266,12 @@ export function apply(ctx, config = {}) {
265
266
 
266
267
  void (async () => {
267
268
  try {
268
- // The heartbeat claims the previous process's record in `start`, and the engine's crash plan
269
- // is derived from that claim: start first, or the residue is read as absent and lost.
270
- heartbeat.start()
269
+ // Read the previous process's record before `start()` overwrites it, and persist the crash
270
+ // plan before the heartbeat goes away: a second restart then still sees the same work.
271
+ const residue = heartbeat.peek()
272
+ await engine.hydrate({ residue })
271
273
  if (disposed) return
272
- await engine.hydrate()
274
+ heartbeat.start()
273
275
  if (disposed) return
274
276
  const dispose = serve(ctx, engine, heartbeat, { allowRemote })
275
277
  if (disposed) dispose()
package/lib/engine.js CHANGED
@@ -6,26 +6,34 @@
6
6
  * set, so two windows, a double press and a tool call can never overlap. The client is not allowed
7
7
  * to infer a phase: it asks, and renders the answer.
8
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.
9
+ * `action` is state-derived, never phase-derived. Live work asks for a pause; a plan with no live
10
+ * work asks for a resume; neither is `none`. That single rule is what makes a second press useful
11
+ * after a failed pause, a partial stop, or a restart: the button does what the Harness still needs,
12
+ * not what the last operation intended.
12
13
  *
13
- * The engine performs no I/O of its own. `store` persists, `probe` reads and acts on the running
14
- * Harness, so every transition here is testable without a Harness.
14
+ * A plan is kept until every item in it has a terminal outcome or a retryable failure. Attention
15
+ * outcomes (a continuation that was admitted but never opened a turn, a job that cannot be
16
+ * replayed) are terminal: they are carried into the last-restore record and shown as a dot, but
17
+ * they never cause the same message to be delivered twice. The crash plan is written to disk
18
+ * before the heartbeat overwrites the previous process's record, so a second or third restart
19
+ * still sees the same pending work.
15
20
  */
16
21
 
17
22
  const TRANSIENT = new Set(['pausing', 'resuming'])
18
23
  const TERMINAL = new Set(['restored', 'skipped', 'attention'])
19
24
 
20
- export function createEngine({ store, probe, heartbeat = null, now = () => Date.now(), graceMs = 2000, log = () => {} }) {
25
+ function emptyLive() {
26
+ return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
27
+ }
28
+
29
+ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot', now = () => Date.now(), graceMs = 2000, log = () => {} }) {
21
30
  let phase = 'idle'
22
31
  let lastError = null
23
32
  let result = null
24
33
  /** Set while a press runs, so a second caller joins the answer instead of starting work again. */
25
34
  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
35
+ /** Crash plan kept in memory only when its durable write failed; a reload loses it, the file does not. */
36
+ let crashFallback = null
29
37
  let counter = 0
30
38
 
31
39
  function newPlanId(kind) {
@@ -33,29 +41,22 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
33
41
  return `${kind}-${now().toString(36)}-${counter.toString(36)}`
34
42
  }
35
43
 
36
- function pendingPlan() {
44
+ function pausedPlan() {
37
45
  return store.read()
38
46
  }
39
47
 
40
- function readCrash() {
41
- if (crashRead) return crash
42
- crashRead = true
43
- try {
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}` })
50
- } catch (error) {
51
- log(`cannot read crash residue: ${error?.message ?? error}`)
52
- crash = null
53
- }
54
- return crash
48
+ function crashPlan() {
49
+ const stored = typeof store.readPending === 'function' ? store.readPending() : null
50
+ return stored ?? crashFallback
55
51
  }
56
52
 
53
+ /** Exactly one plan is pending, and a finished pause outranks a leftover crash record. */
57
54
  function currentPlan() {
58
- return pendingPlan() ?? readCrash()
55
+ return pausedPlan() ?? crashPlan()
56
+ }
57
+
58
+ function isCrashPlan(plan) {
59
+ return plan !== null && plan !== undefined && plan.source === 'crash'
59
60
  }
60
61
 
61
62
  function itemKeys(plan) {
@@ -66,10 +67,17 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
66
67
  return keys
67
68
  }
68
69
 
70
+ /**
71
+ * Rows written by an earlier process are evidence, not permission to skip: a new process must
72
+ * check the live Harness again. Only this boot's rows reduce what is still outstanding.
73
+ */
69
74
  function outstandingOf(plan) {
70
75
  if (plan === null || plan === undefined) return 0
71
- const done = store.readJournal(plan.planId)
72
- return itemKeys(plan).filter((key) => !done.has(key)).length
76
+ const rows = store.readJournal(plan.planId)
77
+ return itemKeys(plan).filter((key) => {
78
+ const row = rows.get(key)
79
+ return row === undefined || (row.boot !== null && row.boot !== bootId)
80
+ }).length
73
81
  }
74
82
 
75
83
  function countsOf(plan) {
@@ -83,14 +91,37 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
83
91
 
84
92
  function liveNow() {
85
93
  try {
86
- const value = probe.live()
87
- return value ?? { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
94
+ return probe.live() ?? emptyLive()
88
95
  } catch (error) {
89
96
  log(`cannot read live work: ${error?.message ?? error}`)
90
- return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
97
+ return emptyLive()
91
98
  }
92
99
  }
93
100
 
101
+ /**
102
+ * Only work that was actually stopped goes into the plan: a session whose turn was open, or a
103
+ * session that carries an armed goal. A session that ended normally is left alone, however it
104
+ * happens to look in the session list.
105
+ */
106
+ function workOf(current) {
107
+ const sessions = new Map()
108
+ for (const session of current.sessions ?? []) {
109
+ if (session.running === true) sessions.set(session.sessionId, session)
110
+ }
111
+ for (const goal of current.goals ?? []) {
112
+ if (!sessions.has(goal.sessionId)) sessions.set(goal.sessionId, { sessionId: goal.sessionId, running: false, lastTurn: null })
113
+ }
114
+ return {
115
+ sessions: [...sessions.values()],
116
+ goals: current.goals ?? [],
117
+ jobs: current.jobs ?? [], // ui-copy-ignore
118
+ }
119
+ }
120
+
121
+ function liveWorkCount(work) {
122
+ return work.sessions.length + work.goals.length + work.jobs.length
123
+ }
124
+
94
125
  /**
95
126
  * What the button shows. `action` is the one decision the client may not make itself:
96
127
  * `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
@@ -98,167 +129,229 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
98
129
  function describe() {
99
130
  const plan = currentPlan()
100
131
  const current = liveNow()
101
- const busy = TRANSIENT.has(phase)
102
132
  const work = workOf(current)
103
- const canPause = current.readable === true && work.sessions.length + work.goals.length + work.jobs.length > 0
104
- const action = busy ? 'busy' : plan !== null ? 'resume' : canPause ? 'pause' : 'none'
105
- const outstanding = outstandingOf(plan)
133
+ const busy = TRANSIENT.has(phase)
134
+ const workCount = liveWorkCount(work)
135
+ let action = 'none'
136
+ if (busy) action = 'busy'
137
+ else if (current.readable === true && workCount > 0) action = 'pause'
138
+ else if (plan !== null) action = 'resume'
139
+ const attention = plan !== null
140
+ ? (Array.isArray(plan.attention) ? plan.attention.length : 0)
141
+ : (Array.isArray(result?.attention) ? result.attention.length : 0)
106
142
  return {
107
143
  phase,
108
144
  action,
109
145
  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
146
+ // `complete` means the stop finished and nothing is still turning. A partial stop is not.
147
+ complete: plan === null ? null : plan.status !== 'partial' && plan.status !== 'pausing' && lastError !== 'store',
148
+ outstanding: outstandingOf(plan), // ui-copy-ignore
113
149
  counts: countsOf(plan), // ui-copy-ignore
114
150
  live: { // ui-copy-ignore
115
151
  sessions: work.sessions.filter((session) => session.running === true).length,
116
152
  goals: current.goals.length,
117
153
  jobs: current.jobs.length,
118
154
  },
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
155
+ attention,
156
+ error: lastError ?? (plan?.status === 'pausing' ? 'pause-interrupted' : plan?.status === 'partial' ? 'pause-partial' : null), // ui-copy-ignore
121
157
  result,
122
158
  }
123
159
  }
124
160
 
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)
161
+ function freshSession(session) {
162
+ return {
163
+ sessionId: session.sessionId,
164
+ running: session.running === true,
165
+ lastTurn: Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null,
134
166
  }
135
- for (const goal of current.goals ?? []) {
136
- if (!sessions.has(goal.sessionId)) sessions.set(goal.sessionId, { sessionId: goal.sessionId, running: false, lastTurn: null })
167
+ }
168
+
169
+ function freshGoal(goal) {
170
+ return { sessionId: goal.sessionId, goalId: goal.goalId, revision: goal.revision }
171
+ }
172
+
173
+ function freshJob(job) {
174
+ return { id: job.id, owner: job.owner ?? null, status: job.status ?? null }
175
+ }
176
+
177
+ function unionSessions(base, additions) {
178
+ const out = []
179
+ const index = new Map()
180
+ for (const row of [...base, ...additions]) {
181
+ if (typeof row?.sessionId !== 'string') continue
182
+ const existing = index.get(row.sessionId)
183
+ if (existing !== undefined) {
184
+ // Stopping again refreshes the baseline; a plain union would keep a stale turn number.
185
+ if (Number.isSafeInteger(row.lastTurn)) existing.lastTurn = row.lastTurn
186
+ if (row.running === false) existing.running = false
187
+ continue
188
+ }
189
+ const copy = { ...row }
190
+ index.set(copy.sessionId, copy)
191
+ out.push(copy)
137
192
  }
138
- return {
139
- sessions: [...sessions.values()],
140
- goals: current.goals ?? [],
141
- jobs: current.jobs ?? [], // ui-copy-ignore
193
+ return out
194
+ }
195
+
196
+ function unionById(base, additions, idOf) {
197
+ const out = []
198
+ const seen = new Set()
199
+ for (const row of [...base, ...additions]) {
200
+ const id = idOf(row)
201
+ if (typeof id !== 'string' || id === '' || seen.has(id)) continue
202
+ seen.add(id)
203
+ out.push(row)
142
204
  }
205
+ return out
143
206
  }
144
207
 
145
- function planOf(current, { planId, status }) {
146
- return {
147
- version: 2, // ui-copy-ignore
148
- planId,
149
- status,
150
- source: 'pause', // ui-copy-ignore
151
- createdAt: new Date(now()).toISOString(),
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 })),
208
+ function persist(plan) {
209
+ if (isCrashPlan(plan) === true) {
210
+ const stored = store.writePending(plan)
211
+ if (stored !== null) crashFallback = null
212
+ return stored
163
213
  }
214
+ return store.write(plan)
164
215
  }
165
216
 
166
- async function runPause() {
217
+ /**
218
+ * Pause, including the second attempt after a partial stop. Capture happens before anything is
219
+ * stopped, and the plan is durable before the first cancellation, so a crash in between is
220
+ * visible as an interrupted pause instead of a clean one.
221
+ */
222
+ async function runPause(basePlan) {
167
223
  const current = liveNow()
224
+ const work = workOf(current)
168
225
  if (current.readable !== true) {
169
- phase = 'idle'
226
+ phase = basePlan === null ? 'idle' : 'paused'
170
227
  lastError = 'registry'
171
228
  return { status: 'failed', reason: 'registry' }
172
229
  }
173
- const work = workOf(current)
174
- if (work.sessions.length + work.goals.length + work.jobs.length === 0) {
175
- phase = 'idle'
176
- return { status: 'idle', action: 'none' }
230
+ if (liveWorkCount(work) === 0) {
231
+ phase = basePlan === null ? 'idle' : 'paused'
232
+ return basePlan === null ? { status: 'idle', action: 'none' } : { status: 'already', phase }
177
233
  }
178
234
  lastError = null
179
- // The plan exists before anything is stopped: a crash between the two is then visible as
180
- // `pausing` on the next boot instead of being reported as a clean pause.
181
- const draft = planOf(current, { planId: newPlanId('pause'), status: 'pausing' })
182
- if (store.write(draft) === null) {
183
- phase = 'idle'
235
+ const plan = basePlan === null
236
+ ? {
237
+ version: 3, // ui-copy-ignore
238
+ planId: newPlanId('pause'),
239
+ status: 'pausing', // ui-copy-ignore
240
+ source: 'pause', // ui-copy-ignore
241
+ createdAt: new Date(now()).toISOString(),
242
+ sessions: [], // ui-copy-ignore
243
+ goals: [], // ui-copy-ignore
244
+ jobs: [], // ui-copy-ignore
245
+ attention: [], // ui-copy-ignore
246
+ }
247
+ : { ...basePlan, status: 'pausing', attention: Array.isArray(basePlan.attention) ? basePlan.attention : [] }
248
+ plan.sessions = unionSessions(plan.sessions ?? [], work.sessions.map(freshSession))
249
+ plan.goals = unionById(plan.goals ?? [], work.goals.map(freshGoal), (goal) => `${goal?.sessionId}:${goal?.goalId}`)
250
+ plan.jobs = unionById(plan.jobs ?? [], work.jobs.map(freshJob), (job) => job?.id)
251
+ if (persist(plan) === null) {
252
+ // Nothing was stopped: the state is exactly what it was before the press.
253
+ phase = basePlan === null ? 'idle' : 'paused'
184
254
  lastError = 'store'
185
255
  return { status: 'failed', reason: 'store' }
186
256
  }
257
+
187
258
  let stopped = null
188
259
  try {
189
260
  stopped = await probe.stop(current, { graceMs })
190
261
  } catch (error) {
191
262
  log(`stopping work failed: ${error?.message ?? error}`)
192
- stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [] }
263
+ stopped = { canceled: [], unknown: [], pausedGoals: [], goalErrors: [], killedJobs: [], jobErrors: [], stillRunning: [], sessions: [] }
193
264
  }
194
265
 
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.
266
+ // A cancelled turn bumps the agent's turn counter, so the baseline is read after the stop.
267
+ // Without this a restore would mistake its own cancelled turn for work the person continued.
201
268
  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 }
269
+ plan.sessions = plan.sessions.map((session) => baseline.has(session.sessionId)
270
+ ? { ...session, lastTurn: Number.isSafeInteger(baseline.get(session.sessionId)) ? baseline.get(session.sessionId) : session.lastTurn }
204
271
  : session)
205
- draft.goals = (stopped.pausedGoals ?? []).map((goal) => ({
272
+ // A goal that could not be paused is not in the plan: resuming it would bump a revision for no
273
+ // reason. It is reported instead, and the person can pause again after fixing whatever blocked it.
274
+ const pausedGoals = (stopped.pausedGoals ?? []).map((goal) => ({
206
275
  sessionId: goal.sessionId,
207
276
  goalId: goal.goalId,
208
277
  revision: goal.pausedRevision,
209
278
  }))
210
- draft.status = 'paused'
211
- draft.stoppedAt = new Date(now()).toISOString()
212
- draft.stopped = {
279
+ const keptGoals = (plan.goals ?? []).filter((goal) => !(stopped.pausedGoals ?? []).some((row) => row.sessionId === goal.sessionId && row.goalId === goal.goalId))
280
+ plan.goals = [...keptGoals, ...pausedGoals]
281
+ const stillRunning = stopped.stillRunning ?? []
282
+ plan.status = stillRunning.length > 0 ? 'partial' : 'paused'
283
+ plan.stoppedAt = new Date(now()).toISOString()
284
+ plan.stopped = {
213
285
  canceled: stopped.canceled ?? [],
214
286
  unknown: stopped.unknown ?? [],
215
- stillRunning: stopped.stillRunning ?? [],
287
+ stillRunning,
216
288
  goalErrors: stopped.goalErrors ?? [],
217
289
  killedJobs: stopped.killedJobs ?? [],
218
290
  jobErrors: stopped.jobErrors ?? [],
219
291
  }
220
- draft.attention = [
221
- ...(stopped.stillRunning ?? []).map((id) => ({ kind: 'session', id, detail: 'still running' })),
292
+ plan.attention = [
293
+ ...(Array.isArray(basePlan?.attention) ? basePlan.attention : []),
294
+ ...stillRunning.map((id) => ({ kind: 'session', id, detail: 'still running' })),
222
295
  ...(stopped.goalErrors ?? []).map((detail) => ({ kind: 'goal', id: '', detail })),
223
296
  ...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: '', detail })),
224
297
  ]
225
- if (store.write(draft) === null) {
226
- // The durable copy still says `pausing`, which is the truth: the next boot reports it.
298
+ if (isCrashPlan(basePlan) === true) {
299
+ // The crash record described work that was not paused. This press did pause it, so the plan
300
+ // becomes a normal pause plan and the crash evidence is no longer a separate obligation.
301
+ plan.source = 'pause' // ui-copy-ignore
302
+ if (store.write(plan) === null) {
303
+ const kept = store.writePending(plan)
304
+ if (kept === null) {
305
+ crashFallback = plan
306
+ lastError = 'store'
307
+ }
308
+ } else if (typeof store.clearPending === 'function') {
309
+ if (store.clearPending() === false) lastError = 'store'
310
+ }
311
+ crashFallback = null
312
+ } else if (store.write(plan) === null) {
313
+ // The durable copy still says `pausing` or `paused`: the next boot reports it instead of
314
+ // claiming a stop that was never written.
227
315
  lastError = 'store'
228
316
  }
229
317
  phase = 'paused'
230
- return { status: 'paused', counts: countsOf(draft), outstanding: outstandingOf(draft) }
318
+ return { status: plan.status === 'partial' ? 'partial' : 'paused', counts: countsOf(plan), outstanding: outstandingOf(plan) }
231
319
  }
232
320
 
233
- function summaryOf() {
234
- return { restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
321
+ function summaryOf(plan) {
322
+ return { planId: plan.planId, source: plan.source ?? 'pause', restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
235
323
  }
236
324
 
237
325
  /**
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.
326
+ * One restore pass. Items are not skipped from the journal: the live Harness decides whether a
327
+ * session is already back, which is the only answer that survives a second process, a manual
328
+ * resume, or a reload. A retryable failure keeps the plan; attention outcomes are terminal.
241
329
  */
242
330
  async function runResume(plan) {
243
331
  lastError = null
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.
332
+ // A crash in the middle of a pause left a plan that never finished stopping. Mounting is safe;
333
+ // continuing is not, because the person asked for silence and the turn may have died mid-way.
247
334
  if (plan.status === 'pausing') {
248
- plan.source = 'interrupted'
335
+ plan.source = plan.source === 'crash' ? 'crash' : 'interrupted'
249
336
  plan.attention = [...(plan.attention ?? []), { kind: 'pause', id: '', detail: 'pause interrupted' }]
250
337
  plan.sessions = (plan.sessions ?? []).map((session) => ({ ...session, running: false, lastTurn: null }))
251
338
  plan.status = 'paused'
339
+ if (persist(plan) === null) {
340
+ phase = 'paused'
341
+ lastError = 'store'
342
+ return { status: 'partial', summary: null, outstanding: outstandingOf(plan) }
343
+ }
252
344
  }
253
- const summary = summaryOf()
345
+ const summary = summaryOf(plan)
254
346
  summary.jobs = countsOf(plan).jobs
255
- const done = store.readJournal(plan.planId)
256
- const record = (key) => {
257
- done.add(key)
258
- store.appendJournal(plan.planId, key)
347
+ const record = (key, status) => {
348
+ if (store.appendJournal(plan.planId, key, status, bootId) === false) {
349
+ // Reporting only: the restore already happened, so this never blocks the plan. It does mean
350
+ // a later boot counts the item as outstanding and checks it again, which is safe.
351
+ log(`cannot journal ${key}`)
352
+ }
259
353
  }
260
354
  const step = async (key, label, run) => {
261
- if (done.has(key)) return
262
355
  let outcome
263
356
  try {
264
357
  outcome = await run()
@@ -266,55 +359,60 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
266
359
  outcome = { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
267
360
  }
268
361
  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 {
362
+ if (TERMINAL.has(status) === false) {
278
363
  summary.failed.push(`${label}: ${outcome?.detail ?? 'unknown'}`)
364
+ return
279
365
  }
366
+ record(key, status)
367
+ if (status === 'restored') summary.restored.push(label)
368
+ else if (status === 'skipped') summary.skipped.push(label)
369
+ else summary.attention.push(`${label}: ${outcome?.detail ?? 'needs a look'}`)
280
370
  }
281
371
 
282
372
  for (const session of plan.sessions ?? []) {
283
373
  await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
284
374
  }
285
375
  for (const goal of plan.goals ?? []) {
286
- const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
287
- await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
376
+ await step(`g:${goal.sessionId}:${goal.goalId ?? ''}`, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
288
377
  }
289
378
  // 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.
379
+ // reported once and left to the person. It never blocks the plan and is never restarted.
291
380
  for (const job of plan.jobs ?? []) summary.attention.push(`job ${job.id}: stopped, not restarted`)
292
381
 
293
382
  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) {
383
+ if (summary.failed.length > 0) {
297
384
  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'
385
+ if (persist(plan) === null) lastError = 'store'
299
386
  else lastError = 'restore-partial'
300
387
  phase = 'paused'
301
- return { status: 'partial', summary, outstanding: pending.length }
388
+ return { status: 'partial', summary, outstanding: outstandingOf(plan) }
302
389
  }
303
390
 
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
391
+ if (isCrashPlan(plan) === true) {
392
+ if (typeof store.writeResult === 'function' && store.writeResult({ ...plan, status: 'restored', restoredAt: summary.at, summary }) === false) {
393
+ lastError = 'store'
394
+ phase = 'paused'
395
+ return { status: 'partial', summary, outstanding: 0 }
396
+ }
397
+ if (typeof store.clearPending !== 'function' || store.clearPending() === false) {
398
+ lastError = 'store'
399
+ phase = 'paused'
400
+ return { status: 'partial', summary, outstanding: 0 }
401
+ }
402
+ crashFallback = null
403
+ if (typeof store.clearJournal === 'function') store.clearJournal(plan.planId)
313
404
  try {
314
405
  heartbeat?.release?.()
315
406
  } catch (error) {
316
407
  log(`cannot release crash record: ${error?.message ?? error}`)
317
408
  }
409
+ } else {
410
+ const consumed = store.consume(plan, summary)
411
+ if (!consumed?.ok) {
412
+ lastError = 'store'
413
+ phase = 'paused'
414
+ return { status: 'partial', summary, outstanding: 0 }
415
+ }
318
416
  }
319
417
  phase = 'idle'
320
418
  return { status: 'resumed', summary }
@@ -329,7 +427,7 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
329
427
  } catch (error) {
330
428
  log(`${kind} failed: ${error?.message ?? error}`)
331
429
  lastError = 'internal'
332
- phase = pendingPlan() === null ? 'idle' : 'paused'
430
+ phase = currentPlan() === null ? 'idle' : 'paused'
333
431
  return { status: 'failed', reason: 'internal' }
334
432
  } finally {
335
433
  inflight = null
@@ -341,11 +439,15 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
341
439
 
342
440
  async function pause() {
343
441
  if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
344
- if (pendingPlan() !== null) return { status: 'already', phase }
442
+ const plan = currentPlan()
443
+ if (plan !== null) {
444
+ const work = workOf(liveNow())
445
+ if (liveWorkCount(work) === 0) return { status: 'already', phase }
446
+ }
345
447
  // Set synchronously, before the first await: two windows pressing at once are then exclusive
346
448
  // in the same tick they were dispatched, not one microtask later.
347
449
  phase = 'pausing'
348
- return once('pause', runPause)
450
+ return once('pause', () => runPause(plan))
349
451
  }
350
452
 
351
453
  async function resume() {
@@ -361,12 +463,33 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
361
463
  })
362
464
  }
363
465
 
364
- /** A reload of this plugin must not invent a phase: the disk and the running Harness decide it. */
365
- async function hydrate() {
366
- crashRead = false
367
- crash = null
368
- readCrash()
369
- phase = pendingPlan() !== null ? 'paused' : 'idle'
466
+ /**
467
+ * A reload or restart must not invent a phase: the disk and the running Harness decide it. The
468
+ * crash residue is handed in by the caller, because it has to be read before the heartbeat
469
+ * overwrites the previous process's file; it is persisted here so a second restart still sees it.
470
+ */
471
+ async function hydrate(options = {}) {
472
+ crashFallback = null
473
+ const residue = options?.residue ?? heartbeat?.residue ?? null
474
+ if (residue !== null && residue !== undefined && pausedPlan() === null && crashPlan() === null) {
475
+ let plan = null
476
+ try {
477
+ plan = probe.crashPlan(residue, { planId: `crash-${Number.isFinite(residue.at) ? residue.at : 0}-${Array.isArray(residue.sessions) ? residue.sessions.length : 0}` })
478
+ } catch (error) {
479
+ log(`cannot derive crash plan: ${error?.message ?? error}`)
480
+ }
481
+ if (plan !== null) {
482
+ const stored = typeof store.writePending === 'function' ? store.writePending(plan) : null
483
+ // A failed write keeps the plan in memory so this process can still restore it; only the
484
+ // next restart loses it.
485
+ crashFallback = stored ?? plan
486
+ log('crash plan recorded')
487
+ }
488
+ }
489
+ const restored = typeof store.lastRestore === 'function' ? store.lastRestore() : null
490
+ result = restored !== null && typeof restored === 'object' ? (restored.summary ?? null) : null
491
+ phase = pausedPlan() !== null ? 'paused' : 'idle'
492
+ lastError = null
370
493
  return phase
371
494
  }
372
495
 
@@ -376,7 +499,7 @@ export function createEngine({ store, probe, heartbeat = null, now = () => Date.
376
499
  resume,
377
500
  hydrate,
378
501
  get phase() { return phase },
379
- get pendingPlan() { return pendingPlan },
380
- get crashPlan() { return readCrash },
502
+ get pendingPlan() { return currentPlan },
503
+ get crashPlan() { return crashPlan },
381
504
  }
382
505
  }
package/lib/heartbeat.js CHANGED
@@ -66,10 +66,18 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
66
66
 
67
67
  /** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
68
68
  function syncDir(path) {
69
+ if (process.platform === 'win32') return // ui-copy-ignore
69
70
  let fd = null
70
71
  try {
71
72
  fd = fsImpl.openSync(path, 'r')
72
73
  fsImpl.fsyncSync(fd)
74
+ } catch (error) {
75
+ // Some filesystems refuse a directory fsync after a rename that already landed. The record is
76
+ // published either way; only the power-loss guarantee is weaker, so this is not a failure.
77
+ const code = error?.code
78
+ if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOTSUP' && code !== 'EINVAL' && code !== 'ENOSYS' && code !== 'EBADF' && code !== 'EISDIR') {
79
+ noteError(`cannot flush ${path}: ${error?.message ?? error}`)
80
+ }
73
81
  } finally {
74
82
  if (fd !== null) closeQuietly(fsImpl, fd)
75
83
  }
@@ -112,10 +120,13 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
112
120
  }
113
121
  }
114
122
 
115
- /** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
116
- function capture() {
117
- if (captured) return residue
118
- captured = true
123
+ /**
124
+ * Read the previous process's record without claiming it. The caller has to read this before
125
+ * `start()` overwrites the file, and turning it into a durable plan may take a moment: a read that
126
+ * also claimed would leave the very next read empty if that write failed.
127
+ */
128
+ function peek() {
129
+ if (residue !== null) return residue
119
130
  let parsed = null
120
131
  try {
121
132
  parsed = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
@@ -130,6 +141,13 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
130
141
  return residue
131
142
  }
132
143
 
144
+ /** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
145
+ function capture() {
146
+ if (captured) return residue
147
+ captured = true
148
+ return peek()
149
+ }
150
+
133
151
  function writePending() {
134
152
  return dirty() ? persist(pending) : true
135
153
  }
@@ -215,6 +233,7 @@ export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, n
215
233
 
216
234
  return {
217
235
  note,
236
+ peek,
218
237
  start,
219
238
  stop,
220
239
  release,
package/lib/probe.js CHANGED
@@ -10,9 +10,13 @@
10
10
  * - `sessionController.cancel({ sessionId })` stops one turn and keeps the pending inbox.
11
11
  * - `sessionController.resolveAgent(sessionId)` is the public resume path: it mounts the recorded
12
12
  * preset and model selection, unlike a raw `agents.resume`.
13
- * - `sessionController.prompt(...)` admits one real user message and wakes the agent. That is the
14
- * only supported way to make a resumed session continue work, so a restore that must continue
15
- * says so with a visible message instead of pretending the turn came back by itself.
13
+ * - `sessionController.prompt(request, signal)` admits one real user message after resuming the
14
+ * session itself. The shipped service validates the AbortSignal before it does anything, so the
15
+ * second argument is that signal, never the source metadata (the service builds the source).
16
+ * That is the only supported way to make a resumed session continue work, so a restore that must
17
+ * continue says so with a visible message instead of pretending the turn came back by itself.
18
+ * - `sessionController.resolveAgent(sessionId)` answers an already live Agent directly and wraps a
19
+ * cold one as `{ agent }`, or `{ error }`; both shapes are handled here.
16
20
  * - `goals.pause` / `goals.resume` require the exact current revision, and every transition bumps
17
21
  * it. The revision captured after pausing is the one a later resume must present.
18
22
  */
@@ -145,7 +149,11 @@ export function createProbe(services, options = {}) {
145
149
  const goal = goalView(services, agent)
146
150
  const owned = listJobs(services, [agent.id])
147
151
  sessions.push({ sessionId: agent.id, running: agent.status === 'running', lastTurn: lastTurnOf(agent) })
148
- if (goal !== undefined && goal.phase === 'active') goals.push({ sessionId: agent.id, goalId: goal.id, revision: goal.revision })
152
+ // A disarmed goal is not work: it will not start a round, and pausing it would throw. Only an
153
+ // active, armed goal is something this press can stop and this restore can re-arm.
154
+ if (goal !== undefined && goal.phase === 'active' && goal.activation === 'armed') {
155
+ goals.push({ sessionId: agent.id, goalId: goal.id, revision: goal.revision })
156
+ }
149
157
  for (const job of owned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: agent.id, status: job.status })
150
158
  }
151
159
  const unique = new Map()
@@ -229,7 +237,8 @@ export function createProbe(services, options = {}) {
229
237
  sessionId,
230
238
  requestId: `${plan.planId}:continue:${sessionId}`,
231
239
  content: [{ type: 'text', text: continueText }],
232
- mode: 'followup',
240
+ // `queue` is the shipped public mode: deliver after the current turn, start one when idle.
241
+ mode: 'queue',
233
242
  }
234
243
  }
235
244
 
@@ -237,7 +246,11 @@ export function createProbe(services, options = {}) {
237
246
  async function mount(sessionId) {
238
247
  const controller = service(services, 'sessionController')
239
248
  if (controller !== undefined && controller !== null && typeof controller.resolveAgent === 'function') {
240
- const agent = await controller.resolveAgent(sessionId)
249
+ const resolved = await controller.resolveAgent(sessionId)
250
+ // The shipped service returns the Agent itself when it is already live, `{ agent }` after a
251
+ // cold resume, and `{ error }` when the resume failed. All three are official shapes.
252
+ if (resolved !== null && typeof resolved === 'object' && 'error' in resolved) throw resolved.error
253
+ const agent = resolved !== null && typeof resolved === 'object' && 'agent' in resolved ? resolved.agent : resolved
241
254
  if (agent === undefined || agent === null || typeof agent.id !== 'string') throw new Error('resume returned no agent')
242
255
  return agent
243
256
  }
@@ -269,8 +282,9 @@ export function createProbe(services, options = {}) {
269
282
  if (session.running !== true) return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
270
283
 
271
284
  // Someone continued by hand after the pause: the press must not add a second turn.
272
- const lastTurn = lastTurnOf(agent)
273
- if (agent.status === 'running' || (Number.isSafeInteger(session.lastTurn) && lastTurn !== null && lastTurn > session.lastTurn)) {
285
+ const baseline = Number.isSafeInteger(session.lastTurn) ? session.lastTurn : null
286
+ const turnBefore = lastTurnOf(agent)
287
+ if (agent.status === 'running' || (baseline !== null && turnBefore !== null && turnBefore > baseline)) {
274
288
  return { status: 'skipped', detail: 'already continued', continued: false }
275
289
  }
276
290
 
@@ -280,11 +294,35 @@ export function createProbe(services, options = {}) {
280
294
  }
281
295
  const request = continuation(plan, sessionId)
282
296
  try {
283
- await controller.prompt(request, { kind: 'user', rpcId: request.requestId }, new AbortController().signal)
297
+ // Two arguments: the shipped service takes the caller's AbortSignal. Passing source metadata
298
+ // here made every restore fail on a live Harness while the fake in the tests accepted it.
299
+ await controller.prompt(request, new AbortController().signal)
284
300
  } catch (error) {
285
301
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120), continued: false }
286
302
  }
287
- return { status: 'restored', detail: mounted ? 'continued' : 'resumed and continued', continued: true }
303
+
304
+ // Admission is not execution. The shipped prompt answers `{ accepted: true }` without waking a
305
+ // queue when the same request id was already admitted, so the press waits for the visible sign
306
+ // of a turn (running, or a newer lastTurn) before it calls this item restored.
307
+ const started = await waitForTurn(sessionId, baseline, options.settleMs)
308
+ if (started === true) return { status: 'restored', detail: mounted ? 'continued' : 'resumed and continued', continued: true }
309
+ return { status: 'attention', detail: 'continuation admitted, no turn started', continued: false }
310
+ }
311
+
312
+ /** Bounded poll for the visible sign that an admitted continuation actually opened a turn. */
313
+ async function waitForTurn(sessionId, baseline, settle) {
314
+ if (settle === 0) return false
315
+ const deadline = Date.now() + (Number.isFinite(settle) ? settle : 1200)
316
+ for (;;) {
317
+ const agent = agentOf(services, sessionId)
318
+ if (agent !== undefined) {
319
+ if (agent.status === 'running') return true
320
+ const turn = lastTurnOf(agent)
321
+ if (baseline !== null && turn !== null && turn > baseline) return true
322
+ }
323
+ if (Date.now() >= deadline) return false
324
+ await new Promise((resolve) => setTimeout(resolve, 60))
325
+ }
288
326
  }
289
327
 
290
328
  /** Re-arm one paused goal. The saved revision is the person's veto: a changed goal is not ours. */
@@ -298,25 +336,33 @@ export function createProbe(services, options = {}) {
298
336
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
299
337
  }
300
338
  }
301
- const goals = service(services, 'goals')
302
- if (goals === undefined || goals === null || typeof goals.get !== 'function') return { status: 'skipped', detail: 'goals unavailable' }
303
- let view
304
- try {
305
- view = goals.get(agent)
306
- } catch (error) {
307
- return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
339
+ const services_goals = service(services, 'goals')
340
+ if (services_goals === undefined || services_goals === null || typeof services_goals.resume !== 'function') {
341
+ return { status: 'failed', detail: 'goals unavailable' }
342
+ }
343
+ const view = goalView(services, agent)
344
+ if (view === undefined || (typeof goal.goalId === 'string' && view.id !== goal.goalId)) {
345
+ return { status: 'skipped', detail: 'goal is gone' }
346
+ }
347
+ // A goal that is already active and armed was restored by an earlier press, or re-armed by the
348
+ // person: it must not be touched again. A disarmed active goal is not ours to arm either.
349
+ if (view.phase === 'active') {
350
+ return view.activation === 'armed'
351
+ ? { status: 'skipped', detail: 'already active' }
352
+ : { status: 'skipped', detail: 'goal disarmed' }
308
353
  }
309
- if (view === undefined || view === null) return { status: 'skipped', detail: 'goal cleared' }
310
- if (view.id !== goal.goalId) return { status: 'skipped', detail: 'another goal' }
311
- if (view.phase === 'active' && view.activation === 'armed') return { status: 'restored', detail: 'already active' }
312
- if (view.phase === 'complete' || view.phase === 'blocked') return { status: 'attention', detail: `goal is ${view.phase}` }
313
- // The plan stores the revision observed after `pause`; older plans called it `pausedRevision`.
314
- const expected = Number.isSafeInteger(goal.pausedRevision) ? goal.pausedRevision : goal.revision
315
- if (view.phase === 'paused' && Number.isSafeInteger(expected) && view.revision !== expected) {
316
- return { status: 'skipped', detail: 'goal changed by hand' }
354
+ if (view.phase !== 'paused') return { status: 'skipped', detail: `goal is ${view.phase}` }
355
+ // The saved revision is the person's veto: a goal edited after the pause is not ours to resume.
356
+ if (Number.isSafeInteger(goal.revision) && view.revision !== goal.revision) {
357
+ return { status: 'skipped', detail: 'goal changed' }
317
358
  }
359
+ // The plan stores the revision observed after `pause`; 0.3 plans keep it in `revision`, 0.2
360
+ // plans called it `pausedRevision`.
361
+ const expected = Number.isSafeInteger(goal.revision) ? goal.revision : goal.pausedRevision
362
+ if (Number.isSafeInteger(expected) === false) return { status: 'attention', detail: 'goal has no saved revision' }
363
+ if (view.revision !== expected) return { status: 'skipped', detail: 'goal changed by hand' }
318
364
  try {
319
- goals.resume(agent, { id: view.id, revision: view.revision })
365
+ services_goals.resume(agent, { id: view.id, revision: view.revision })
320
366
  } catch (error) {
321
367
  return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
322
368
  }
@@ -336,7 +382,7 @@ export function createProbe(services, options = {}) {
336
382
  .map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null, crash: true }))
337
383
  if (sessions.length === 0) return null
338
384
  return {
339
- version: 2, // ui-copy-ignore
385
+ version: 3, // ui-copy-ignore
340
386
  planId: planId ?? `crash-${Date.now().toString(36)}`,
341
387
  status: 'paused', // ui-copy-ignore
342
388
  source: 'crash', // ui-copy-ignore
@@ -344,6 +390,7 @@ export function createProbe(services, options = {}) {
344
390
  sessions,
345
391
  goals: [], // ui-copy-ignore
346
392
  jobs: [], // ui-copy-ignore
393
+ attention: [], // ui-copy-ignore
347
394
  crashes: sessions.length, // ui-copy-ignore
348
395
  }
349
396
  }
package/lib/snapshot.js CHANGED
@@ -2,16 +2,24 @@ import * as fs from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
4
  /**
5
- * The durable half of the pause button: one plan file, a per-plan restore journal, one archive per
6
- * consumed plan, and the last restore record.
5
+ * The durable half of the pause button: the active plan, the crash plan a restart inherited, one
6
+ * restore journal, one archive per consumed plan, and the last restore record.
7
7
  *
8
- * Every write is temp file + fsync + rename + directory fsync, so a reader never sees half a plan
9
- * and a crash leaves the previous complete file in place. Nothing here throws: a disk that cannot
10
- * be written answers `null`, and the caller decides whether to stay in its current phase instead
11
- * of claiming a state it could not record.
8
+ * Every write is temp file + fsync + rename + best-effort directory fsync, so a reader never sees
9
+ * half a plan and a crash leaves the previous complete file in place. A directory fsync that the
10
+ * platform refuses (EPERM/ENOTSUP on some network and sandboxed filesystems) does not undo a
11
+ * rename that already happened: the record is published, only its power-loss guarantee is weaker.
12
+ * Nothing here throws: a disk that cannot be written answers `false`/`null`, and the caller
13
+ * decides whether to stay in its current phase instead of claiming a state it could not record.
14
+ *
15
+ * Formats read: version 3 (this release), version 2 (0.2.x), version 1 (0.1.x). A version 1 plan is
16
+ * migrated conservatively: it has no turn baseline, so its sessions are mounted and never sent a
17
+ * continuation message, and its goal rows carry no revision, so they are reported, not resumed.
12
18
  */
13
19
 
14
20
  const UNSAFE = /[^A-Za-z0-9._-]/gu
21
+ const CURRENT_VERSION = 3
22
+ const DIR_SYNC_CODES = new Set(['EPERM', 'EACCES', 'ENOTSUP', 'EINVAL', 'ENOSYS', 'EBADF', 'EISDIR'])
15
23
 
16
24
  let counter = 0
17
25
 
@@ -32,6 +40,7 @@ export function createStore(dir, options = {}) {
32
40
  const log = typeof options.log === 'function' ? options.log : () => {}
33
41
 
34
42
  const snapshotPath = join(dir, 'snapshot.json')
43
+ const pendingPath = join(dir, 'pending.json')
35
44
  const journalDir = join(dir, 'journal')
36
45
  const restoresDir = join(dir, 'restores')
37
46
  const lastRestorePath = join(dir, 'last-restore.json')
@@ -40,15 +49,20 @@ export function createStore(dir, options = {}) {
40
49
 
41
50
  /** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
42
51
  function syncDir(path) {
52
+ if (process.platform === 'win32') return // ui-copy-ignore
43
53
  let fd = null
44
54
  try {
45
55
  fd = fsv.openSync(path, 'r')
46
56
  fsv.fsyncSync(fd)
57
+ } catch (error) {
58
+ const code = error?.code
59
+ if (DIR_SYNC_CODES.has(code) === false) log(`cannot flush ${path}: ${error?.message ?? error}`)
47
60
  } finally {
48
61
  if (fd !== null) closeQuietly(fsv, fd)
49
62
  }
50
63
  }
51
64
 
65
+ /** `published` is the truth: the rename landed, so readers see the new record. */
52
66
  function atomicWrite(target, value) {
53
67
  let body = null
54
68
  try {
@@ -85,29 +99,106 @@ export function createStore(dir, options = {}) {
85
99
  }
86
100
  }
87
101
 
88
- function read() {
102
+ function parse(path) {
89
103
  let parsed = null
90
104
  try {
91
- parsed = JSON.parse(fsv.readFileSync(snapshotPath, 'utf8'))
105
+ parsed = JSON.parse(fsv.readFileSync(path, 'utf8'))
92
106
  } catch {
93
107
  return null
94
108
  }
95
109
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
96
- if (parsed.version !== 2) return null
97
- if (parsed.status !== 'pausing' && parsed.status !== 'paused') return null
98
110
  return parsed
99
111
  }
100
112
 
113
+ /**
114
+ * A version 1 plan predates the turn baseline and the goal revision. Mounting is safe; claiming
115
+ * that a turn was interrupted is not, and resuming a goal without its revision is not either.
116
+ */
117
+ function migrateLegacy(legacy, stamp) {
118
+ const sessions = (Array.isArray(legacy.sessions) ? legacy.sessions : [])
119
+ .filter((row) => typeof row?.sessionId === 'string')
120
+ .map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null }))
121
+ const goals = (Array.isArray(legacy.goals) ? legacy.goals : [])
122
+ .filter((row) => typeof row?.sessionId === 'string')
123
+ .map((row) => ({ sessionId: row.sessionId, goalId: typeof row.goalId === 'string' ? row.goalId : null, revision: null }))
124
+ const jobs = (Array.isArray(legacy.jobs) ? legacy.jobs : []).map((row) => ({ id: String(row?.id ?? ''), owner: row?.owner ?? null }))
125
+ return {
126
+ planId: `legacy-${stamp}`, // ui-copy-ignore
127
+ status: 'paused', // ui-copy-ignore
128
+ source: 'legacy', // ui-copy-ignore
129
+ legacy: true, // ui-copy-ignore
130
+ createdAt: legacy.createdAt ?? stamp,
131
+ sessions,
132
+ goals,
133
+ jobs,
134
+ attention: ['legacy plan: sessions are mounted only; goals need a new pause'],
135
+ }
136
+ }
137
+
138
+ function normalize(parsed, stamp = now()) {
139
+ if (parsed === null) return null
140
+ if (parsed.version === 1) return migrateLegacy(parsed, typeof parsed.createdAt === 'number' ? parsed.createdAt : stamp)
141
+ if (parsed.version !== 2 && parsed.version !== CURRENT_VERSION) return null
142
+ if (parsed.status !== 'pausing' && parsed.status !== 'paused' && parsed.status !== 'partial') return null
143
+ const plan = { ...parsed, version: CURRENT_VERSION }
144
+ if (typeof plan.planId !== 'string' || plan.planId === '') plan.planId = `plan-${plan.createdAt ?? stamp}`
145
+ if (Array.isArray(plan.attention) === false) plan.attention = []
146
+ if (plan.status === 'pausing') plan.interrupted = true
147
+ return plan
148
+ }
149
+
150
+ function readFrom(path) {
151
+ const parsed = parse(path)
152
+ if (parsed === null) return null
153
+ // A version 1 plan has no plan id; the file stamp keeps one journal per legacy press.
154
+ let stamp = now()
155
+ try {
156
+ stamp = Math.trunc(fsv.statSync(path).mtimeMs)
157
+ } catch {}
158
+ return normalize(parsed, stamp)
159
+ }
160
+
161
+ function read() {
162
+ return readFrom(snapshotPath)
163
+ }
164
+
101
165
  function write(plan) {
102
166
  if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
103
- const record = { ...plan, version: 2 }
167
+ const record = { ...plan, version: CURRENT_VERSION }
104
168
  return atomicWrite(snapshotPath, record) ? record : null
105
169
  }
106
170
 
107
- function appendJournal(planId, key) {
171
+ /** The crash plan survives this process: it is written before the heartbeat overwrites its file. */
172
+ function readPending() {
173
+ return readFrom(pendingPath)
174
+ }
175
+
176
+ function writePending(plan) {
177
+ if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
178
+ const record = { ...plan, version: CURRENT_VERSION }
179
+ return atomicWrite(pendingPath, record) ? record : null
180
+ }
181
+
182
+ function clearPending() {
183
+ try {
184
+ fsv.rmSync(pendingPath, { force: true })
185
+ syncDir(dir)
186
+ return true
187
+ } catch (error) {
188
+ log(`cannot clear pending plan: ${error?.message ?? error}`)
189
+ return false
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Journals carry the boot that wrote each row. A row from an earlier process is evidence for the
195
+ * summary, never permission to skip work: the only things a new process can trust are the live
196
+ * Harness and the plan itself.
197
+ */
198
+ function appendJournal(planId, key, status, bootId) {
108
199
  let line = null
109
200
  try {
110
- line = JSON.stringify({ k: key })
201
+ line = JSON.stringify({ k: key, s: status ?? 'ok', b: bootId ?? null, at: now() })
111
202
  } catch {
112
203
  return false
113
204
  }
@@ -127,23 +218,23 @@ export function createStore(dir, options = {}) {
127
218
  }
128
219
 
129
220
  function readJournal(planId) {
130
- const done = new Set()
221
+ const rows = new Map()
131
222
  let raw = ''
132
223
  try {
133
224
  raw = fsv.readFileSync(journalPath(planId), 'utf8')
134
225
  } catch {
135
- return done
226
+ return rows
136
227
  }
137
228
  for (const line of raw.split('\n')) {
138
229
  if (line === '') continue
139
230
  try {
140
231
  const row = JSON.parse(line)
141
- if (typeof row?.k === 'string') done.add(row.k)
232
+ if (typeof row?.k === 'string') rows.set(row.k, { status: typeof row.s === 'string' ? row.s : 'ok', boot: typeof row.b === 'string' ? row.b : null })
142
233
  } catch {
143
234
  // A torn last line is a write that never finished, not a key that was restored.
144
235
  }
145
236
  }
146
- return done
237
+ return rows
147
238
  }
148
239
 
149
240
  function clearJournal(planId) {
@@ -152,47 +243,78 @@ export function createStore(dir, options = {}) {
152
243
  } catch {}
153
244
  }
154
245
 
246
+ function writeResult(record) {
247
+ return atomicWrite(lastRestorePath, record)
248
+ }
249
+
155
250
  /**
156
251
  * The plan is spent: archive it, publish the record the UI reads, and only then remove it. A plan
157
- * that could not be archived stays on disk, so the same work is never claimed twice.
252
+ * that could not be archived stays on disk, so the same work is never claimed twice. A snapshot
253
+ * that could not be removed is reported as a failure even though the archive landed: the next
254
+ * reader would otherwise see a plan that is already restored and try to restore it again.
158
255
  */
159
256
  function consume(plan, summary) {
160
257
  const source = plan !== null && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
161
258
  const archivePath = join(restoresDir, `${sanitize(source.planId)}.json`)
162
259
  const record = { ...source, status: 'restored', restoredAt: new Date(now()).toISOString(), summary }
163
260
  const archived = atomicWrite(archivePath, record)
164
- const published = archived && atomicWrite(lastRestorePath, record)
261
+ const published = archived && writeResult(record)
165
262
  if (published === false) {
166
263
  log('cannot archive the restored plan: the snapshot stays')
167
- return { ok: false, archivePath: null }
264
+ return { ok: false, archivePath: null, published: false }
168
265
  }
169
266
  const current = read()
170
267
  // Another press may have written a newer plan while this one restored; that one is not ours.
171
- if (current !== null && current.planId === source.planId) {
268
+ if (current === null || current.planId === source.planId) {
172
269
  try {
173
270
  fsv.rmSync(snapshotPath, { force: true })
174
- } catch {}
271
+ syncDir(dir)
272
+ } catch (error) {
273
+ log(`cannot clear the restored plan: ${error?.message ?? error}`)
274
+ return { ok: false, archivePath, published: true }
275
+ }
175
276
  }
176
277
  clearJournal(source.planId)
177
- return { ok: true, archivePath }
278
+ return { ok: true, archivePath, published: true }
178
279
  }
179
280
 
180
281
  function lastRestore() {
282
+ const parsed = parse(lastRestorePath)
283
+ return parsed
284
+ }
285
+
286
+ function hasSnapshot() {
181
287
  try {
182
- const parsed = JSON.parse(fsv.readFileSync(lastRestorePath, 'utf8'))
183
- return parsed !== null && typeof parsed === 'object' ? parsed : null
288
+ return fsv.existsSync(snapshotPath) === true
184
289
  } catch {
185
- return null
290
+ return false
186
291
  }
187
292
  }
188
293
 
189
- function hasSnapshot() {
294
+ function hasPending() {
190
295
  try {
191
- return fsv.existsSync(snapshotPath) === true
296
+ return fsv.existsSync(pendingPath) === true
192
297
  } catch {
193
298
  return false
194
299
  }
195
300
  }
196
301
 
197
- return { dir, snapshotPath, read, write, appendJournal, readJournal, clearJournal, consume, lastRestore, hasSnapshot }
302
+ return {
303
+ dir,
304
+ snapshotPath,
305
+ pendingPath,
306
+ read,
307
+ write,
308
+ readPending,
309
+ writePending,
310
+ clearPending,
311
+ appendJournal,
312
+ readJournal,
313
+ clearJournal,
314
+ writeResult,
315
+ consume,
316
+ lastRestore,
317
+ hasSnapshot,
318
+ hasPending,
319
+ }
198
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pause-resume",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",