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/README.md +67 -35
- package/client.js +101 -28
- package/index.js +102 -31
- package/lib/engine.js +235 -72
- package/lib/heartbeat.js +180 -49
- package/lib/probe.js +234 -113
- package/lib/snapshot.js +143 -78
- package/package.json +2 -2
package/lib/probe.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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(...)` 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.
|
|
16
|
+
* - `goals.pause` / `goals.resume` require the exact current revision, and every transition bumps
|
|
17
|
+
* it. The revision captured after pausing is the one a later resume must present.
|
|
6
18
|
*/
|
|
7
19
|
|
|
8
20
|
function service(services, key) {
|
|
@@ -13,13 +25,15 @@ function service(services, key) {
|
|
|
13
25
|
}
|
|
14
26
|
}
|
|
15
27
|
|
|
28
|
+
/** The live Agent for one session, or undefined. Never a `{ id }` stand-in. */
|
|
16
29
|
function agentOf(services, sessionId) {
|
|
17
30
|
const agents = service(services, 'agents')
|
|
18
31
|
if (agents === undefined || agents === null || typeof agents.get !== 'function') return undefined
|
|
19
32
|
try {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
|
|
33
|
+
const found = agents.get(sessionId)
|
|
34
|
+
if (found === undefined || found === null) return undefined
|
|
35
|
+
if (typeof found.id === 'string' && typeof found.status === 'string') return found
|
|
36
|
+
return typeof found.agent?.id === 'string' ? found.agent : undefined
|
|
23
37
|
} catch {
|
|
24
38
|
return undefined
|
|
25
39
|
}
|
|
@@ -28,7 +42,7 @@ function agentOf(services, sessionId) {
|
|
|
28
42
|
/**
|
|
29
43
|
* Live agents are the cheap truth for "who is attached right now": a couple of dozen handles in
|
|
30
44
|
* memory, against a listing that walks every session ever recorded on disk. It answers `undefined`
|
|
31
|
-
* when the registry cannot be read, so the
|
|
45
|
+
* when the registry cannot be read, so the caller can tell "nothing live" from "cannot tell".
|
|
32
46
|
*/
|
|
33
47
|
function agentsOf(services) {
|
|
34
48
|
const agents = service(services, 'agents')
|
|
@@ -36,62 +50,36 @@ function agentsOf(services) {
|
|
|
36
50
|
try {
|
|
37
51
|
const handles = agents.list()
|
|
38
52
|
if (!Array.isArray(handles)) return undefined
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
return handles
|
|
54
|
+
// The row wrapper carries its own `status`; only the agent inside it knows whether a turn is open.
|
|
55
|
+
.map((handle) => (handle?.agent !== null && typeof handle?.agent === 'object' ? handle.agent : handle))
|
|
56
|
+
.filter((agent) => agent !== null && agent !== undefined && typeof agent?.id === 'string')
|
|
41
57
|
} catch {
|
|
42
58
|
return undefined
|
|
43
59
|
}
|
|
44
60
|
}
|
|
45
61
|
|
|
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
62
|
/** The session ids whose turn is open right now: the heartbeat's only question. */
|
|
57
|
-
export
|
|
63
|
+
export function listRunning(services) {
|
|
58
64
|
const agents = agentsOf(services)
|
|
59
|
-
if (agents
|
|
60
|
-
|
|
61
|
-
return rows.filter((row) => row.running === true).map((row) => row.sessionId)
|
|
62
|
-
}
|
|
63
|
-
|
|
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 }
|
|
65
|
+
if (agents === undefined) return []
|
|
66
|
+
return agents.filter((agent) => agent.status === 'running').map((agent) => agent.id)
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
|
|
69
|
+
/** The goal view for one exact live agent, or undefined. The goal service throws for a dead agent. */
|
|
70
|
+
function goalView(services, agent) {
|
|
74
71
|
const goals = service(services, 'goals')
|
|
75
72
|
if (goals === undefined || goals === null || typeof goals.get !== 'function') return undefined
|
|
76
73
|
try {
|
|
77
|
-
const view =
|
|
74
|
+
const view = goals.get(agent)
|
|
78
75
|
return view === undefined || view === null ? undefined : view
|
|
79
76
|
} catch {
|
|
80
77
|
return undefined
|
|
81
78
|
}
|
|
82
79
|
}
|
|
83
80
|
|
|
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
81
|
/**
|
|
94
|
-
* Owned jobs are invisible to a caller-less `list`, so every
|
|
82
|
+
* Owned jobs are invisible to a caller-less `list`, so every live session is asked for its own
|
|
95
83
|
* bucket as well. One job can only be owned by one session, and the id map keeps the union exact.
|
|
96
84
|
*/
|
|
97
85
|
function listJobs(services, owners = []) {
|
|
@@ -117,122 +105,255 @@ function listJobs(services, owners = []) {
|
|
|
117
105
|
return [...seen.values()]
|
|
118
106
|
}
|
|
119
107
|
|
|
108
|
+
const RUNNING_JOB = new Set(['running', 'stopping'])
|
|
109
|
+
|
|
110
|
+
/** The last turn number this agent has opened, when the loop exposes one. */
|
|
111
|
+
function lastTurnOf(agent) {
|
|
112
|
+
const value = agent?.phase?.lastTurn
|
|
113
|
+
return Number.isSafeInteger(value) ? value : null
|
|
114
|
+
}
|
|
115
|
+
|
|
120
116
|
/**
|
|
121
|
-
* The Harness half of the plugin: what is running now, how to stop it, how to put it back.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
117
|
+
* The Harness half of the plugin: what is running now, how to stop it, how to put it back.
|
|
118
|
+
*
|
|
119
|
+
* Restore outcomes are explicit:
|
|
120
|
+
* - `restored`: mounted, and for an interrupted turn a visible continuation message was admitted
|
|
121
|
+
* - `skipped`: nothing to do, or the person already moved on; not a failure and not retried
|
|
122
|
+
* - `attention`: restored as far as allowed, but something cannot be put back (a killed job)
|
|
123
|
+
* - `failed`: this item may work on the next press, so it stays unmarked for retry
|
|
124
|
+
* Nothing here throws for one broken item: the press reports it and the rest proceeds.
|
|
124
125
|
*/
|
|
125
|
-
export function createProbe(services,
|
|
126
|
-
const graceMs = 1500
|
|
126
|
+
export function createProbe(services, options = {}) {
|
|
127
|
+
const graceMs = Number.isFinite(options.graceMs) ? options.graceMs : 1500
|
|
128
|
+
const continueText = typeof options.continueText === 'string' && options.continueText.trim() !== ''
|
|
129
|
+
? options.continueText
|
|
130
|
+
: '继续'
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
132
|
+
/** Everything this press can stop, read from the live registry only. */
|
|
133
|
+
function live() {
|
|
134
|
+
const agents = agentsOf(services)
|
|
131
135
|
const sessions = []
|
|
132
136
|
const goals = []
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.
|
|
144
|
-
.
|
|
145
|
-
|
|
137
|
+
const jobs = []
|
|
138
|
+
if (agents === undefined) {
|
|
139
|
+
// The registry is unreadable: report no work rather than claiming a state the button cannot see.
|
|
140
|
+
return { sessions, goals, jobs, readable: false, source: 'none' }
|
|
141
|
+
}
|
|
142
|
+
const unowned = listJobs(services)
|
|
143
|
+
for (const job of unowned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: null, status: job.status })
|
|
144
|
+
for (const agent of agents) {
|
|
145
|
+
const goal = goalView(services, agent)
|
|
146
|
+
const owned = listJobs(services, [agent.id])
|
|
147
|
+
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 })
|
|
149
|
+
for (const job of owned) if (RUNNING_JOB.has(job.status)) jobs.push({ id: job.id, owner: agent.id, status: job.status })
|
|
150
|
+
}
|
|
151
|
+
const unique = new Map()
|
|
152
|
+
for (const job of jobs) if (!unique.has(job.id)) unique.set(job.id, job)
|
|
153
|
+
return { sessions, goals, jobs: [...unique.values()], readable: true, source: 'live' }
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Stop the captured work: disarm goals first so no round is queued behind the press, then end
|
|
158
|
+
* the open turns, then kill the jobs. The returned goal entries carry the revision produced by
|
|
159
|
+
* `pause`, which is what a later `resume` must prove.
|
|
160
|
+
*/
|
|
148
161
|
async function stop(current, { graceMs: grace } = {}) {
|
|
149
162
|
const controller = service(services, 'sessionController')
|
|
150
163
|
const goals = service(services, 'goals')
|
|
151
164
|
const jobs = service(services, 'jobs')
|
|
152
165
|
const canceled = []
|
|
153
166
|
const unknown = []
|
|
167
|
+
const pausedGoals = []
|
|
168
|
+
const goalErrors = []
|
|
169
|
+
const killedJobs = []
|
|
170
|
+
const jobErrors = []
|
|
154
171
|
|
|
155
|
-
for (const
|
|
156
|
-
|
|
172
|
+
for (const goal of current.goals ?? []) {
|
|
173
|
+
const agent = agentOf(services, goal.sessionId)
|
|
174
|
+
if (agent === undefined) continue
|
|
157
175
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
176
|
+
const view = goals.pause(agent, { id: goal.goalId, revision: goal.revision })
|
|
177
|
+
pausedGoals.push({ sessionId: goal.sessionId, goalId: view?.id ?? goal.goalId, pausedRevision: view?.revision ?? goal.revision + 1 })
|
|
160
178
|
} catch (error) {
|
|
161
|
-
|
|
179
|
+
goalErrors.push(`goal ${goal.goalId}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
162
180
|
}
|
|
163
181
|
}
|
|
164
|
-
|
|
182
|
+
|
|
183
|
+
// Every open turn is asked to stop before the wait, so the grace period measures one round trip.
|
|
184
|
+
const stopping = (current.sessions ?? []).filter((session) => session.running === true)
|
|
185
|
+
for (const session of stopping) {
|
|
165
186
|
try {
|
|
166
|
-
await
|
|
187
|
+
await controller?.cancel?.({ sessionId: session.sessionId })
|
|
188
|
+
canceled.push(session.sessionId)
|
|
167
189
|
} catch (error) {
|
|
168
|
-
unknown.push(`
|
|
190
|
+
unknown.push(`session ${session.sessionId}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
169
191
|
}
|
|
170
192
|
}
|
|
193
|
+
|
|
171
194
|
for (const job of current.jobs ?? []) {
|
|
172
195
|
try {
|
|
173
|
-
jobs?.kill?.(job.id
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
unknown.push(`job ${job.id}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
179
|
-
}
|
|
196
|
+
if (job.owner === null || job.owner === undefined) jobs?.kill?.(job.id)
|
|
197
|
+
else jobs?.kill?.(job.id, job.owner)
|
|
198
|
+
killedJobs.push({ id: job.id, owner: job.owner ?? null })
|
|
199
|
+
} catch (error) {
|
|
200
|
+
jobErrors.push(`job ${job.id}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
180
201
|
}
|
|
181
202
|
}
|
|
182
203
|
|
|
183
204
|
// A turn ends when its tools return: the press waits a bounded moment for that instead of
|
|
184
205
|
// claiming a stop that has not happened yet.
|
|
185
206
|
const deadline = Date.now() + (grace ?? graceMs)
|
|
186
|
-
let pending =
|
|
207
|
+
let pending = stopping.map((session) => session.sessionId)
|
|
187
208
|
while (pending.length > 0 && Date.now() < deadline) {
|
|
188
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
189
|
-
const stillRunning = new Set(
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
210
|
+
const stillRunning = new Set(listRunning(services))
|
|
190
211
|
pending = pending.filter((id) => stillRunning.has(id))
|
|
191
212
|
}
|
|
192
213
|
for (const id of pending) unknown.push(`session ${id}: still running`)
|
|
193
|
-
|
|
214
|
+
|
|
215
|
+
// The turn counter is only readable once the turn is over: a running phase carries the turn it
|
|
216
|
+
// is in, not the last finished one. Recording it now is what lets a restore tell "the press
|
|
217
|
+
// stopped turn 4" from "the person ran turn 5 while paused".
|
|
218
|
+
const sessions = stopping.map((session) => {
|
|
219
|
+
const agent = agentOf(services, session.sessionId)
|
|
220
|
+
return { sessionId: session.sessionId, lastTurn: agent === undefined ? null : lastTurnOf(agent) }
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return { canceled, unknown, pausedGoals, goalErrors, killedJobs, jobErrors, stillRunning: pending, sessions }
|
|
194
224
|
}
|
|
195
225
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
226
|
+
/** The continuation message: a real user message, so the session history shows why it moved. */
|
|
227
|
+
function continuation(plan, sessionId) {
|
|
228
|
+
return {
|
|
229
|
+
sessionId,
|
|
230
|
+
requestId: `${plan.planId}:continue:${sessionId}`,
|
|
231
|
+
content: [{ type: 'text', text: continueText }],
|
|
232
|
+
mode: 'followup',
|
|
203
233
|
}
|
|
204
|
-
return false
|
|
205
234
|
}
|
|
206
235
|
|
|
207
|
-
|
|
236
|
+
/** Mount one session through the public resume path, which restores preset and model selection. */
|
|
237
|
+
async function mount(sessionId) {
|
|
238
|
+
const controller = service(services, 'sessionController')
|
|
239
|
+
if (controller !== undefined && controller !== null && typeof controller.resolveAgent === 'function') {
|
|
240
|
+
const agent = await controller.resolveAgent(sessionId)
|
|
241
|
+
if (agent === undefined || agent === null || typeof agent.id !== 'string') throw new Error('resume returned no agent')
|
|
242
|
+
return agent
|
|
243
|
+
}
|
|
208
244
|
const agents = service(services, 'agents')
|
|
209
245
|
if (agents === undefined || agents === null || typeof agents.resume !== 'function') throw new Error('agents unavailable')
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return
|
|
246
|
+
await agents.resume({ resumeSessionId: sessionId })
|
|
247
|
+
const mounted = agentOf(services, sessionId)
|
|
248
|
+
if (mounted === undefined) throw new Error('session did not attach')
|
|
249
|
+
return mounted
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Put one session back. A session whose turn was stopped by the press gets its continuation
|
|
254
|
+
* admitted; a session that only carried a goal is mounted and left to the goal driver.
|
|
255
|
+
*/
|
|
256
|
+
async function restoreSession(session, { plan } = {}) {
|
|
257
|
+
if (session === null || typeof session?.sessionId !== 'string') return { status: 'skipped', detail: 'no session' }
|
|
258
|
+
const { sessionId } = session
|
|
259
|
+
let agent = agentOf(services, sessionId)
|
|
260
|
+
const mounted = agent !== undefined
|
|
261
|
+
if (agent === undefined) {
|
|
262
|
+
try {
|
|
263
|
+
agent = await mount(sessionId)
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (session.running !== true) return { status: 'restored', detail: mounted ? 'mounted' : 'attached', continued: false }
|
|
270
|
+
|
|
271
|
+
// 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)) {
|
|
274
|
+
return { status: 'skipped', detail: 'already continued', continued: false }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const controller = service(services, 'sessionController')
|
|
278
|
+
if (controller === undefined || controller === null || typeof controller.prompt !== 'function') {
|
|
279
|
+
return { status: 'failed', detail: 'prompt unavailable', continued: false }
|
|
280
|
+
}
|
|
281
|
+
const request = continuation(plan, sessionId)
|
|
282
|
+
try {
|
|
283
|
+
await controller.prompt(request, { kind: 'user', rpcId: request.requestId }, new AbortController().signal)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120), continued: false }
|
|
286
|
+
}
|
|
287
|
+
return { status: 'restored', detail: mounted ? 'continued' : 'resumed and continued', continued: true }
|
|
214
288
|
}
|
|
215
289
|
|
|
216
|
-
|
|
290
|
+
/** Re-arm one paused goal. The saved revision is the person's veto: a changed goal is not ours. */
|
|
291
|
+
async function restoreGoal(goal, { plan } = {}) {
|
|
292
|
+
if (goal === null || typeof goal?.sessionId !== 'string') return { status: 'skipped', detail: 'no goal' }
|
|
293
|
+
let agent = agentOf(services, goal.sessionId)
|
|
294
|
+
if (agent === undefined) {
|
|
295
|
+
try {
|
|
296
|
+
agent = await mount(goal.sessionId)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
217
301
|
const goals = service(services, 'goals')
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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) }
|
|
308
|
+
}
|
|
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' }
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
goals.resume(agent, { id: view.id, revision: view.revision })
|
|
320
|
+
} catch (error) {
|
|
321
|
+
return { status: 'failed', detail: String(error?.message ?? error).slice(0, 120) }
|
|
322
|
+
}
|
|
323
|
+
return { status: 'restored', detail: 'armed' }
|
|
225
324
|
}
|
|
226
325
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
326
|
+
/**
|
|
327
|
+
* What a previous process left behind: sessions whose turn was open when that process ended.
|
|
328
|
+
* This process cannot know whether those turns finished, so it mounts them and says so. Only a
|
|
329
|
+
* press's own plan is allowed to continue a turn.
|
|
330
|
+
*/
|
|
331
|
+
function crashPlan(residue, { planId } = {}) {
|
|
332
|
+
if (residue === null || residue === undefined) return null
|
|
333
|
+
const rows = Array.isArray(residue.sessions) ? residue.sessions : []
|
|
334
|
+
const sessions = rows
|
|
335
|
+
.filter((row) => typeof row?.sessionId === 'string')
|
|
336
|
+
.map((row) => ({ sessionId: row.sessionId, running: false, lastTurn: null, crash: true }))
|
|
233
337
|
if (sessions.length === 0) return null
|
|
234
|
-
return {
|
|
338
|
+
return {
|
|
339
|
+
version: 2, // ui-copy-ignore
|
|
340
|
+
planId: planId ?? `crash-${Date.now().toString(36)}`,
|
|
341
|
+
status: 'paused', // ui-copy-ignore
|
|
342
|
+
source: 'crash', // ui-copy-ignore
|
|
343
|
+
createdAt: new Date(Number.isFinite(residue.at) ? residue.at : Date.now()).toISOString(),
|
|
344
|
+
sessions,
|
|
345
|
+
goals: [], // ui-copy-ignore
|
|
346
|
+
jobs: [], // ui-copy-ignore
|
|
347
|
+
crashes: sessions.length, // ui-copy-ignore
|
|
348
|
+
}
|
|
235
349
|
}
|
|
236
350
|
|
|
237
|
-
return {
|
|
351
|
+
return {
|
|
352
|
+
live,
|
|
353
|
+
stop,
|
|
354
|
+
restoreSession,
|
|
355
|
+
restoreGoal,
|
|
356
|
+
restoreSchedule: async () => ({ status: 'skipped', detail: 'no schedules' }),
|
|
357
|
+
crashPlan,
|
|
358
|
+
}
|
|
238
359
|
}
|