dsh-pause-resume 0.1.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/lib/probe.js CHANGED
@@ -1,8 +1,24 @@
1
1
  /**
2
2
  * What the Harness is doing right now, and how to stop one piece of it without losing the pieces
3
- * behind it. Only service contracts are used here, and every service is reached through the
4
- * registry this module is given: one that is missing, or that answers nothing, is a smaller set of
5
- * work rather than a failed press.
3
+ * behind it.
4
+ *
5
+ * Every fact below is one of the official service contracts, checked against the shipped Harness:
6
+ * - `agents.get(id)` returns the live Agent object itself, and every goal call takes that object
7
+ * (the goal service rejects `{ id }` stand-ins). Enumerating `agents.list()` is therefore both the
8
+ * cheapest and the only correct view of "who can run". A session with no live agent cannot be
9
+ * running and cannot have an armed goal.
10
+ * - `sessionController.cancel({ sessionId })` stops one turn and keeps the pending inbox.
11
+ * - `sessionController.resolveAgent(sessionId)` is the public resume path: it mounts the recorded
12
+ * preset and model selection, unlike a raw `agents.resume`.
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.
20
+ * - `goals.pause` / `goals.resume` require the exact current revision, and every transition bumps
21
+ * it. The revision captured after pausing is the one a later resume must present.
6
22
  */
7
23
 
8
24
  function service(services, key) {
@@ -13,13 +29,15 @@ function service(services, key) {
13
29
  }
14
30
  }
15
31
 
32
+ /** The live Agent for one session, or undefined. Never a `{ id }` stand-in. */
16
33
  function agentOf(services, sessionId) {
17
34
  const agents = service(services, 'agents')
18
35
  if (agents === undefined || agents === null || typeof agents.get !== 'function') return undefined
19
36
  try {
20
- const handle = agents.get(sessionId)
21
- if (handle === undefined || handle === null) return undefined
22
- return handle.agent ?? handle
37
+ const found = agents.get(sessionId)
38
+ if (found === undefined || found === null) return undefined
39
+ if (typeof found.id === 'string' && typeof found.status === 'string') return found
40
+ return typeof found.agent?.id === 'string' ? found.agent : undefined
23
41
  } catch {
24
42
  return undefined
25
43
  }
@@ -28,7 +46,7 @@ function agentOf(services, sessionId) {
28
46
  /**
29
47
  * Live agents are the cheap truth for "who is attached right now": a couple of dozen handles in
30
48
  * memory, against a listing that walks every session ever recorded on disk. It answers `undefined`
31
- * when the registry cannot be read, so the listing stays the fallback rather than a second opinion.
49
+ * when the registry cannot be read, so the caller can tell "nothing live" from "cannot tell".
32
50
  */
33
51
  function agentsOf(services) {
34
52
  const agents = service(services, 'agents')
@@ -36,62 +54,36 @@ function agentsOf(services) {
36
54
  try {
37
55
  const handles = agents.list()
38
56
  if (!Array.isArray(handles)) return undefined
39
- const live = handles.map((handle) => handle?.agent ?? handle).filter((agent) => agent !== null && typeof agent?.id === 'string')
40
- return live
57
+ return handles
58
+ // The row wrapper carries its own `status`; only the agent inside it knows whether a turn is open.
59
+ .map((handle) => (handle?.agent !== null && typeof handle?.agent === 'object' ? handle.agent : handle))
60
+ .filter((agent) => agent !== null && agent !== undefined && typeof agent?.id === 'string')
41
61
  } catch {
42
62
  return undefined
43
63
  }
44
64
  }
45
65
 
46
- /** One row per attached agent: running says whether its turn is open, and every one is attached. */
47
- async function attachedRows(services) {
48
- const agents = agentsOf(services)
49
- if (agents !== undefined) {
50
- return agents.map((agent) => ({ sessionId: agent.id, running: agent.status === 'running', agentAvailable: true }))
51
- }
52
- const rows = await listSessions(services)
53
- return rows.filter((row) => row.running === true || row.agentAvailable === true)
54
- }
55
-
56
66
  /** The session ids whose turn is open right now: the heartbeat's only question. */
57
- export async function listRunning(services) {
67
+ export function listRunning(services) {
58
68
  const agents = agentsOf(services)
59
- if (agents !== undefined) return agents.filter((agent) => agent.status === 'running').map((agent) => agent.id)
60
- const rows = await listSessions(services)
61
- return rows.filter((row) => row.running === true).map((row) => row.sessionId)
69
+ if (agents === undefined) return []
70
+ return agents.filter((agent) => agent.status === 'running').map((agent) => agent.id)
62
71
  }
63
72
 
64
- /**
65
- * The goal service addresses one live agent, and every agent it accepts names itself by session
66
- * id, so a session id is the whole handle. It throws for an agent that is not live: that is the
67
- * ordinary answer for a closed session, not an error worth surfacing.
68
- */
69
- function goalAgent(sessionId) {
70
- return { id: sessionId }
71
- }
72
-
73
- async function goalOf(services, sessionId) {
73
+ /** The goal view for one exact live agent, or undefined. The goal service throws for a dead agent. */
74
+ function goalView(services, agent) {
74
75
  const goals = service(services, 'goals')
75
76
  if (goals === undefined || goals === null || typeof goals.get !== 'function') return undefined
76
77
  try {
77
- const view = await goals.get(goalAgent(sessionId))
78
+ const view = goals.get(agent)
78
79
  return view === undefined || view === null ? undefined : view
79
80
  } catch {
80
81
  return undefined
81
82
  }
82
83
  }
83
84
 
84
- /** The listing service answers `{ items }` through its remote facade and a bare array in tests. */
85
- async function listSessions(services) {
86
- const controller = service(services, 'sessionController')
87
- if (controller === undefined || controller === null || typeof controller.list !== 'function') return []
88
- const value = await controller.list({})
89
- const rows = Array.isArray(value) ? value : value?.items ?? value?.sessions ?? []
90
- return rows.filter((row) => row !== null && typeof row?.sessionId === 'string')
91
- }
92
-
93
85
  /**
94
- * Owned jobs are invisible to a caller-less `list`, so every attached session is asked for its own
86
+ * Owned jobs are invisible to a caller-less `list`, so every live session is asked for its own
95
87
  * bucket as well. One job can only be owned by one session, and the id map keeps the union exact.
96
88
  */
97
89
  function listJobs(services, owners = []) {
@@ -117,122 +109,298 @@ function listJobs(services, owners = []) {
117
109
  return [...seen.values()]
118
110
  }
119
111
 
112
+ const RUNNING_JOB = new Set(['running', 'stopping'])
113
+
114
+ /** The last turn number this agent has opened, when the loop exposes one. */
115
+ function lastTurnOf(agent) {
116
+ const value = agent?.phase?.lastTurn
117
+ return Number.isSafeInteger(value) ? value : null
118
+ }
119
+
120
120
  /**
121
- * The Harness half of the plugin: what is running now, how to stop it, how to put it back. Every
122
- * method answers `restored` / `skipped` rather than throwing, because one item that cannot be put
123
- * back must not stop the items behind it.
121
+ * The Harness half of the plugin: what is running now, how to stop it, how to put it back.
122
+ *
123
+ * Restore outcomes are explicit:
124
+ * - `restored`: mounted, and for an interrupted turn a visible continuation message was admitted
125
+ * - `skipped`: nothing to do, or the person already moved on; not a failure and not retried
126
+ * - `attention`: restored as far as allowed, but something cannot be put back (a killed job)
127
+ * - `failed`: this item may work on the next press, so it stays unmarked for retry
128
+ * Nothing here throws for one broken item: the press reports it and the rest proceeds.
124
129
  */
125
- export function createProbe(services, heartbeat) {
126
- const graceMs = 1500
130
+ export function createProbe(services, options = {}) {
131
+ const graceMs = Number.isFinite(options.graceMs) ? options.graceMs : 1500
132
+ const continueText = typeof options.continueText === 'string' && options.continueText.trim() !== ''
133
+ ? options.continueText
134
+ : '继续'
127
135
 
128
- async function live() {
129
- const attached = await attachedRows(services)
130
- const views = await Promise.all(attached.map((row) => goalOf(services, row.sessionId)))
136
+ /** Everything this press can stop, read from the live registry only. */
137
+ function live() {
138
+ const agents = agentsOf(services)
131
139
  const sessions = []
132
140
  const goals = []
133
- attached.forEach((row, index) => {
134
- const running = row.running === true
135
- const goal = views[index]
136
- const active = goal !== undefined && goal.phase === 'active'
137
- // A session is part of the work in two cases only: a turn is running, or a goal is armed and
138
- // can start one. A session the person merely has open is a tab, not work.
139
- if (running || active) sessions.push({ sessionId: row.sessionId, running })
140
- if (active) goals.push({ sessionId: row.sessionId, goalId: goal.id, revision: goal.revision })
141
- })
142
- const jobs = listJobs(services, attached.map((row) => row.sessionId))
143
- .filter((job) => job.status === 'running' || job.status === 'stopping')
144
- .map((job) => ({ id: job.id, label: job.label ?? job.kind ?? '', owner: job.owner }))
145
- return { sessions, goals, schedules: [], jobs, terminals: [] }
141
+ const jobs = []
142
+ if (agents === undefined) {
143
+ // The registry is unreadable: report no work rather than claiming a state the button cannot see.
144
+ return { sessions, goals, jobs, readable: false, source: 'none' }
145
+ }
146
+ const unowned = listJobs(services)
147
+ for (const job of unowned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: null, status: job.status })
148
+ for (const agent of agents) {
149
+ const goal = goalView(services, agent)
150
+ const owned = listJobs(services, [agent.id])
151
+ sessions.push({ sessionId: agent.id, running: agent.status === 'running', lastTurn: lastTurnOf(agent) })
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
+ }
157
+ for (const job of owned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: agent.id, status: job.status })
158
+ }
159
+ const unique = new Map()
160
+ for (const job of jobs) if (!unique.has(job.id)) unique.set(job.id, job)
161
+ return { sessions, goals, jobs: [...unique.values()], readable: true, source: 'live' }
146
162
  }
147
163
 
164
+ /**
165
+ * Stop the captured work: disarm goals first so no round is queued behind the press, then end
166
+ * the open turns, then kill the jobs. The returned goal entries carry the revision produced by
167
+ * `pause`, which is what a later `resume` must prove.
168
+ */
148
169
  async function stop(current, { graceMs: grace } = {}) {
149
170
  const controller = service(services, 'sessionController')
150
171
  const goals = service(services, 'goals')
151
172
  const jobs = service(services, 'jobs')
152
173
  const canceled = []
153
174
  const unknown = []
175
+ const pausedGoals = []
176
+ const goalErrors = []
177
+ const killedJobs = []
178
+ const jobErrors = []
154
179
 
155
- for (const session of current.sessions ?? []) {
156
- if (session.running !== true) continue
180
+ for (const goal of current.goals ?? []) {
181
+ const agent = agentOf(services, goal.sessionId)
182
+ if (agent === undefined) continue
157
183
  try {
158
- controller?.cancel?.({ sessionId: session.sessionId })
159
- canceled.push(session.sessionId)
184
+ const view = goals.pause(agent, { id: goal.goalId, revision: goal.revision })
185
+ pausedGoals.push({ sessionId: goal.sessionId, goalId: view?.id ?? goal.goalId, pausedRevision: view?.revision ?? goal.revision + 1 })
160
186
  } catch (error) {
161
- unknown.push(`session ${session.sessionId}: ${String(error?.message ?? error).slice(0, 80)}`)
187
+ goalErrors.push(`goal ${goal.goalId}: ${String(error?.message ?? error).slice(0, 80)}`)
162
188
  }
163
189
  }
164
- for (const goal of current.goals ?? []) {
190
+
191
+ // Every open turn is asked to stop before the wait, so the grace period measures one round trip.
192
+ const stopping = (current.sessions ?? []).filter((session) => session.running === true)
193
+ for (const session of stopping) {
165
194
  try {
166
- await goals?.pause?.(goalAgent(goal.sessionId), { id: goal.goalId, revision: goal.revision })
195
+ await controller?.cancel?.({ sessionId: session.sessionId })
196
+ canceled.push(session.sessionId)
167
197
  } catch (error) {
168
- unknown.push(`goal ${goal.goalId}: ${String(error?.message ?? error).slice(0, 80)}`)
198
+ unknown.push(`session ${session.sessionId}: ${String(error?.message ?? error).slice(0, 80)}`)
169
199
  }
170
200
  }
201
+
171
202
  for (const job of current.jobs ?? []) {
172
203
  try {
173
- jobs?.kill?.(job.id, job.owner)
174
- } catch {
175
- try {
176
- jobs?.kill?.(job.id)
177
- } catch (error) {
178
- unknown.push(`job ${job.id}: ${String(error?.message ?? error).slice(0, 80)}`)
179
- }
204
+ if (job.owner === null || job.owner === undefined) jobs?.kill?.(job.id)
205
+ else jobs?.kill?.(job.id, job.owner)
206
+ killedJobs.push({ id: job.id, owner: job.owner ?? null })
207
+ } catch (error) {
208
+ jobErrors.push(`job ${job.id}: ${String(error?.message ?? error).slice(0, 80)}`)
180
209
  }
181
210
  }
182
211
 
183
212
  // A turn ends when its tools return: the press waits a bounded moment for that instead of
184
213
  // claiming a stop that has not happened yet.
185
214
  const deadline = Date.now() + (grace ?? graceMs)
186
- let pending = current.sessions?.filter((session) => session.running === true).map((session) => session.sessionId) ?? []
215
+ let pending = stopping.map((session) => session.sessionId)
187
216
  while (pending.length > 0 && Date.now() < deadline) {
188
- await new Promise((resolve) => setTimeout(resolve, 80))
189
- const stillRunning = new Set(await listRunning(services).catch(() => []))
217
+ await new Promise((resolve) => setTimeout(resolve, 60))
218
+ const stillRunning = new Set(listRunning(services))
190
219
  pending = pending.filter((id) => stillRunning.has(id))
191
220
  }
192
221
  for (const id of pending) unknown.push(`session ${id}: still running`)
193
- return { canceled, unknown }
222
+
223
+ // The turn counter is only readable once the turn is over: a running phase carries the turn it
224
+ // is in, not the last finished one. Recording it now is what lets a restore tell "the press
225
+ // stopped turn 4" from "the person ran turn 5 while paused".
226
+ const sessions = stopping.map((session) => {
227
+ const agent = agentOf(services, session.sessionId)
228
+ return { sessionId: session.sessionId, lastTurn: agent === undefined ? null : lastTurnOf(agent) }
229
+ })
230
+
231
+ return { canceled, unknown, pausedGoals, goalErrors, killedJobs, jobErrors, stillRunning: pending, sessions }
194
232
  }
195
233
 
196
- async function rearm(sessionId) {
197
- const goals = service(services, 'goals')
198
- const goal = await goalOf(services, sessionId)
199
- if (goal === undefined) return false
200
- if (goal.phase === 'active' && goal.activation !== 'armed') {
201
- goals?.resume?.(goalAgent(sessionId), { id: goal.id, revision: goal.revision })
202
- return true
234
+ /** The continuation message: a real user message, so the session history shows why it moved. */
235
+ function continuation(plan, sessionId) {
236
+ return {
237
+ sessionId,
238
+ requestId: `${plan.planId}:continue:${sessionId}`,
239
+ content: [{ type: 'text', text: continueText }],
240
+ // `queue` is the shipped public mode: deliver after the current turn, start one when idle.
241
+ mode: 'queue',
203
242
  }
204
- return false
205
243
  }
206
244
 
207
- async function restoreSession(session) {
245
+ /** Mount one session through the public resume path, which restores preset and model selection. */
246
+ async function mount(sessionId) {
247
+ const controller = service(services, 'sessionController')
248
+ if (controller !== undefined && controller !== null && typeof controller.resolveAgent === 'function') {
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
254
+ if (agent === undefined || agent === null || typeof agent.id !== 'string') throw new Error('resume returned no agent')
255
+ return agent
256
+ }
208
257
  const agents = service(services, 'agents')
209
258
  if (agents === undefined || agents === null || typeof agents.resume !== 'function') throw new Error('agents unavailable')
210
- if (agentOf(services, session.sessionId) !== undefined) return 'skipped'
211
- await agents.resume({ resumeSessionId: session.sessionId })
212
- await rearm(session.sessionId)
213
- return 'restored'
259
+ await agents.resume({ resumeSessionId: sessionId })
260
+ const mounted = agentOf(services, sessionId)
261
+ if (mounted === undefined) throw new Error('session did not attach')
262
+ return mounted
214
263
  }
215
264
 
216
- async function restoreGoal(goal) {
217
- const goals = service(services, 'goals')
218
- const view = await goalOf(services, goal.sessionId)
219
- if (view === undefined || view.id !== goal.goalId) return 'skipped'
220
- if (view.phase === 'active') return (await rearm(goal.sessionId)) ? 'restored' : 'skipped'
221
- // The revision is the person's veto: a goal they changed by hand is not resurrected.
222
- if (view.phase !== 'paused' || view.revision !== goal.revision) return 'skipped'
223
- goals?.resume?.(goalAgent(goal.sessionId), { id: view.id, revision: view.revision })
224
- return 'restored'
225
- }
226
-
227
- async function crashPlan() {
228
- const captured = heartbeat.residue
229
- if (captured === null) return null
230
- const rows = await listSessions(services)
231
- const existing = new Set(rows.map((row) => row.sessionId))
232
- const sessions = captured.sessions.filter((row) => existing.has(row.sessionId)).map((row) => ({ sessionId: row.sessionId, running: true }))
265
+ /**
266
+ * Put one session back. A session whose turn was stopped by the press gets its continuation
267
+ * admitted; a session that only carried a goal is mounted and left to the goal driver.
268
+ */
269
+ async function restoreSession(session, { plan } = {}) {
270
+ if (session === null || typeof session?.sessionId !== 'string') return { status: 'skipped', detail: 'no session' }
271
+ const { sessionId } = session
272
+ let agent = agentOf(services, sessionId)
273
+ const mounted = agent !== undefined
274
+ if (agent === undefined) {
275
+ try {
276
+ agent = await mount(sessionId)
277
+ } catch (error) {
278
+ return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
279
+ }
280
+ }
281
+
282
+ if (session.running !== true) return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
283
+
284
+ // Someone continued by hand after the pause: the press must not add a second turn.
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)) {
288
+ return { status: 'skipped', detail: 'already continued', continued: false }
289
+ }
290
+
291
+ const controller = service(services, 'sessionController')
292
+ if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
293
+ return { status: 'failed', detail: 'prompt unavailable', continued: false }
294
+ }
295
+ const request = continuation(plan, sessionId)
296
+ try {
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)
300
+ } catch (error) {
301
+ return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120), continued: false }
302
+ }
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
+ }
326
+ }
327
+
328
+ /** Re-arm one paused goal. The saved revision is the person's veto: a changed goal is not ours. */
329
+ async function restoreGoal(goal, { plan } = {}) {
330
+ if (goal === null || typeof goal?.sessionId !== 'string') return { status: 'skipped', detail: 'no goal' }
331
+ let agent = agentOf(services, goal.sessionId)
332
+ if (agent === undefined) {
333
+ try {
334
+ agent = await mount(goal.sessionId)
335
+ } catch (error) {
336
+ return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
337
+ }
338
+ }
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' }
353
+ }
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' }
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' }
364
+ try {
365
+ services_goals.resume(agent, { id: view.id, revision: view.revision })
366
+ } catch (error) {
367
+ return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
368
+ }
369
+ return { status: 'restored', detail: 'armed' }
370
+ }
371
+
372
+ /**
373
+ * What a previous process left behind: sessions whose turn was open when that process ended.
374
+ * This process cannot know whether those turns finished, so it mounts them and says so. Only a
375
+ * press's own plan is allowed to continue a turn.
376
+ */
377
+ function crashPlan(residue, { planId } = {}) {
378
+ if (residue === null || residue === undefined) return null
379
+ const rows = Array.isArray(residue.sessions) ? residue.sessions : []
380
+ const sessions = rows
381
+ .filter((row) => typeof row?.sessionId === 'string')
382
+ .map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null, crash: true }))
233
383
  if (sessions.length === 0) return null
234
- return { version: 1, status: 'paused', source: 'crash', sessions, goals: [], schedules: [], jobs: [], terminals: [] }
384
+ return {
385
+ version: 3, // ui-copy-ignore
386
+ planId: planId ?? `crash-${Date.now().toString(36)}`,
387
+ status: 'paused', // ui-copy-ignore
388
+ source: 'crash', // ui-copy-ignore
389
+ createdAt: new Date(Number.isFinite(residue.at) ? residue.at : Date.now()).toISOString(),
390
+ sessions,
391
+ goals: [], // ui-copy-ignore
392
+ jobs: [], // ui-copy-ignore
393
+ attention: [], // ui-copy-ignore
394
+ crashes: sessions.length, // ui-copy-ignore
395
+ }
235
396
  }
236
397
 
237
- return { live, stop, restoreSession, restoreGoal, restoreSchedule: async () => 'skipped', crashPlan }
398
+ return {
399
+ live,
400
+ stop,
401
+ restoreSession,
402
+ restoreGoal,
403
+ restoreSchedule: async () => ({ status: 'skipped', detail: 'no schedules' }),
404
+ crashPlan,
405
+ }
238
406
  }