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/README.md +72 -35
- package/client.js +114 -29
- package/index.js +105 -32
- package/lib/engine.js +380 -94
- package/lib/heartbeat.js +200 -50
- package/lib/probe.js +284 -116
- package/lib/snapshot.js +270 -83
- package/package.json +2 -2
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.
|
|
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(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
|
|
21
|
-
if (
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
67
|
+
export function listRunning(services) {
|
|
58
68
|
const agents = agentsOf(services)
|
|
59
|
-
if (agents
|
|
60
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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,
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
156
|
-
|
|
180
|
+
for (const goal of current.goals ?? []) {
|
|
181
|
+
const agent = agentOf(services, goal.sessionId)
|
|
182
|
+
if (agent === undefined) continue
|
|
157
183
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
187
|
+
goalErrors.push(`goal ${goal.goalId}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
162
188
|
}
|
|
163
189
|
}
|
|
164
|
-
|
|
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
|
|
195
|
+
await controller?.cancel?.({ sessionId: session.sessionId })
|
|
196
|
+
canceled.push(session.sessionId)
|
|
167
197
|
} catch (error) {
|
|
168
|
-
unknown.push(`
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
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 =
|
|
215
|
+
let pending = stopping.map((session) => session.sessionId)
|
|
187
216
|
while (pending.length > 0 && Date.now() < deadline) {
|
|
188
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
189
|
-
const stillRunning = new Set(
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 {
|
|
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 {
|
|
398
|
+
return {
|
|
399
|
+
live,
|
|
400
|
+
stop,
|
|
401
|
+
restoreSession,
|
|
402
|
+
restoreGoal,
|
|
403
|
+
restoreSchedule: async () => ({ status: 'skipped', detail: 'no schedules' }),
|
|
404
|
+
crashPlan,
|
|
405
|
+
}
|
|
238
406
|
}
|