@linxin666/dsh-client-ui-task-board 0.2.8 → 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/LICENSE +202 -29
- package/README.i18n.yaml +2 -2
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +18 -159
- package/lib/client.js.map +1 -1
- package/lib/index.js +123 -46
- package/lib/types/core/controller.d.ts +10 -31
- package/lib/types/core/tasks.d.ts +17 -1
- package/lib/types/host-ledger.d.ts +28 -0
- package/lib/types/host-service.d.ts +0 -1
- package/lib/types/http.d.ts +42 -0
- package/package.json +1 -1
- package/src/client/board/TaskBoard.tsx +2 -0
- package/src/client/board.module.css +185 -13
- package/src/client/sidebar-entry.ts +2 -2
- package/src/core/controller.ts +16 -152
- package/src/core/tasks.ts +31 -2
- package/src/host-ledger.ts +69 -3
- package/src/host-routes.ts +10 -14
- package/src/host-service.ts +32 -28
- package/src/http.ts +105 -0
- package/lib/types/core/execution.d.ts +0 -197
- package/src/core/execution.ts +0 -425
package/src/core/execution.ts
DELETED
|
@@ -1,425 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Execution service: runs a task through dsh's real session machinery.
|
|
3
|
-
*
|
|
4
|
-
* The board's "run" button must make dsh actually work, not fake a status:
|
|
5
|
-
* the service connects a real session (workspace blank-session reuse or
|
|
6
|
-
* `session.create` on the host via the workspaces service), renames it to
|
|
7
|
-
* the task title, sends the task prompt with `session.prompt`, and then
|
|
8
|
-
* watches the session's conversation snapshot until its turn settles. The
|
|
9
|
-
* task board controller consumes {@link ExecutionEvent}s to move the card
|
|
10
|
-
* running → done/failed and to keep the execution record.
|
|
11
|
-
*
|
|
12
|
-
* Deliberately framework-free: the runtime faces are declared structurally
|
|
13
|
-
* (a narrow slice of the real `ctx.sessions` / `ctx.workspaces` contracts)
|
|
14
|
-
* so tests drive it with plain fakes.
|
|
15
|
-
*/
|
|
16
|
-
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
17
|
-
|
|
18
|
-
/** One list-row summary the execution service reads (the narrow slice of a SessionSummary). */
|
|
19
|
-
export interface ExecutionSessionSummary {
|
|
20
|
-
running: boolean
|
|
21
|
-
completed?: boolean
|
|
22
|
-
/** Empty-log bit: the preset can only be recomposed while the session is blank. */
|
|
23
|
-
blank?: boolean
|
|
24
|
-
/** The preset the session currently runs (absent on deployments without presets). */
|
|
25
|
-
agentPreset?: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** The narrow sessions face the service needs. */
|
|
29
|
-
export interface SessionsExecutionFace {
|
|
30
|
-
list: {
|
|
31
|
-
getSnapshot(): {
|
|
32
|
-
/** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
|
|
33
|
-
phase: 'pending' | 'ready'
|
|
34
|
-
byId: Record<string, ExecutionSessionSummary>
|
|
35
|
-
}
|
|
36
|
-
subscribe(fn: () => void): () => void
|
|
37
|
-
}
|
|
38
|
-
binding(id: string): { session: SessionDriver } | undefined
|
|
39
|
-
/** Record a host-confirmed preset switch so the list label moves immediately. */
|
|
40
|
-
noteAgentPreset?(sessionId: string, agentPreset: string): void
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The narrow agent-preset wire face the service needs (`agentPreset.select`). */
|
|
44
|
-
export interface PresetsExecutionFace {
|
|
45
|
-
/** Recompose a blank session's agent from a preset. */
|
|
46
|
-
select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Settled result of one agent-preset switch, shared by concurrent waiters. */
|
|
50
|
-
type PresetSelectResult = { ok: true } | { ok: false; error: unknown }
|
|
51
|
-
|
|
52
|
-
/** The narrow workspaces face the service needs. */
|
|
53
|
-
export interface WorkspacesExecutionFace {
|
|
54
|
-
list: {
|
|
55
|
-
getSnapshot(): {
|
|
56
|
-
items: readonly { workspaceId: string }[]
|
|
57
|
-
recentWorkspaceId: string | undefined
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
connectWorkspace(workspaceId: string): Promise<string>
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** One raw session-history event narrowed to the failure signal reconcile needs. */
|
|
64
|
-
export interface ExecutionHistoryEvent {
|
|
65
|
-
type: string
|
|
66
|
-
data?: unknown
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Optional raw-history face used to detect failures of never-opened sessions. */
|
|
70
|
-
export interface HistoryExecutionFace {
|
|
71
|
-
loadTail(sessionId: string): Promise<{ events: readonly ExecutionHistoryEvent[] } | undefined>
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The behavior verbs the service invokes on an execution session. */
|
|
75
|
-
export interface SessionDriver {
|
|
76
|
-
rename(title: string): Promise<unknown>
|
|
77
|
-
prompt(
|
|
78
|
-
content: readonly unknown[],
|
|
79
|
-
mode: 'queue',
|
|
80
|
-
): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
81
|
-
/**
|
|
82
|
-
* Admit one slash-command line against the session's agent (the
|
|
83
|
-
* `/permission <id>` mechanism). `matched` reports whether a command
|
|
84
|
-
* claimed the line.
|
|
85
|
-
*/
|
|
86
|
-
command(line: string): Promise<{ ok: true; matched: boolean } | { ok: false; error: unknown }>
|
|
87
|
-
getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
|
|
88
|
-
subscribe(fn: () => void): () => void
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Everything the service needs from the runtime. */
|
|
92
|
-
export interface ExecutionEnvironment {
|
|
93
|
-
sessions: SessionsExecutionFace
|
|
94
|
-
workspaces: WorkspacesExecutionFace
|
|
95
|
-
/** Agent-preset wire face; absent on deployments without preset support. */
|
|
96
|
-
presets?: PresetsExecutionFace
|
|
97
|
-
/** Raw-history reader for failure detection of never-opened sessions. */
|
|
98
|
-
history?: HistoryExecutionFace
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Outcome events the service emits to the controller. */
|
|
102
|
-
export type ExecutionEvent =
|
|
103
|
-
| { kind: 'started'; taskId: string; executionId: string; sessionId: string }
|
|
104
|
-
| { kind: 'settled'; taskId: string; executionId: string; outcome: 'succeeded' | 'failed' | 'cancelled'; error?: string }
|
|
105
|
-
|
|
106
|
-
/** Human copy for a run failure. */
|
|
107
|
-
function messageOf(error: unknown): string {
|
|
108
|
-
if (error instanceof Error) return error.message
|
|
109
|
-
return String(error)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Whether a rejected preset switch actually means "the session already runs
|
|
114
|
-
* this preset" (the host's agent-preset-conflict with a matching
|
|
115
|
-
* existingPreset). A blank-session reuse race can produce this even though
|
|
116
|
-
* the requested composition is in place.
|
|
117
|
-
*/
|
|
118
|
-
function presetAlreadyRuns(error: unknown, mode: string): boolean {
|
|
119
|
-
if (typeof error !== 'object' || error === null) return false
|
|
120
|
-
const details = (error as { details?: unknown }).details
|
|
121
|
-
if (typeof details !== 'object' || details === null) return false
|
|
122
|
-
return (details as { existingPreset?: unknown }).existingPreset === mode
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Whether a `turn/end` payload closed the turn with an error reason. */
|
|
126
|
-
function isErrorTurnEnd(data: unknown): boolean {
|
|
127
|
-
if (typeof data !== 'object' || data === null) return false
|
|
128
|
-
const reason = (data as { reason?: unknown }).reason
|
|
129
|
-
return typeof reason === 'object' && reason !== null
|
|
130
|
-
&& (reason as { kind?: unknown }).kind === 'error'
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Run one task to completion (or to a settled failure).
|
|
135
|
-
*
|
|
136
|
-
* @param task - the task being executed.
|
|
137
|
-
* @param execution - the freshly opened execution record (id + start time).
|
|
138
|
-
* @param onEvent - callback for started/settled events.
|
|
139
|
-
* @returns resolves when the run settles (or fails to start); never rejects —
|
|
140
|
-
* every failure path is reported as a settled event.
|
|
141
|
-
*/
|
|
142
|
-
export class ExecutionService {
|
|
143
|
-
/**
|
|
144
|
-
* In-flight preset switches keyed by `sessionId\u0000mode`. The session
|
|
145
|
-
* list mirror can lag behind a just-applied switch, so concurrent runs
|
|
146
|
-
* against one blank session would otherwise each issue the same select
|
|
147
|
-
* RPC (an amplified storm); sharing one in-flight call keeps the wire to
|
|
148
|
-
* a single request. The settled entry is forgotten so a later run can
|
|
149
|
-
* switch again.
|
|
150
|
-
*/
|
|
151
|
-
private readonly presetSwitches = new Map<string, Promise<PresetSelectResult>>()
|
|
152
|
-
|
|
153
|
-
/** @param env - the runtime faces (real or fake). */
|
|
154
|
-
constructor(private readonly env: ExecutionEnvironment) {}
|
|
155
|
-
|
|
156
|
-
async run(
|
|
157
|
-
task: TaskRecord,
|
|
158
|
-
execution: ExecutionRecord,
|
|
159
|
-
onEvent: (event: ExecutionEvent) => void,
|
|
160
|
-
): Promise<void> {
|
|
161
|
-
const settleFailed = (error: string): void => {
|
|
162
|
-
onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error })
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
const sessionId = await this.connectSession(task.workspaceId)
|
|
166
|
-
onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
|
|
167
|
-
const driver = this.driverOf(sessionId)
|
|
168
|
-
if (driver === undefined) {
|
|
169
|
-
settleFailed('execution session is not ready')
|
|
170
|
-
return
|
|
171
|
-
}
|
|
172
|
-
// Task-pinned execution targets are applied BEFORE any prompt: a preset
|
|
173
|
-
// can only be recomposed while the session is blank, and the permission
|
|
174
|
-
// command must run before the task's turn starts. A rejected target
|
|
175
|
-
// fails the run without sending the prompt — running the task under
|
|
176
|
-
// different settings than it declared would be worse than not running.
|
|
177
|
-
if (!await this.applyMode(task, sessionId, settleFailed)) return
|
|
178
|
-
if (!await this.applyPermission(driver, task, settleFailed)) return
|
|
179
|
-
// Best-effort rename so the execution is recognizable in the session list.
|
|
180
|
-
await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
|
|
181
|
-
// Baseline the turn counter BEFORE the prompt round-trip: a turn that
|
|
182
|
-
// completes while prompt is in flight must still advance past this
|
|
183
|
-
// baseline, or the watch below would never observe it settle.
|
|
184
|
-
const baseline = driver.getSnapshot().turnEnds.size
|
|
185
|
-
const accepted = await this.sendPrompt(driver, task)
|
|
186
|
-
if (!accepted.ok) {
|
|
187
|
-
settleFailed(messageOf(accepted.error))
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
|
|
191
|
-
} catch (error) {
|
|
192
|
-
settleFailed(messageOf(error))
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Recompose the execution session's agent from the task-pinned preset.
|
|
198
|
-
* No-op when the task pins none or the session already runs it; fails the
|
|
199
|
-
* run when the session is no longer blank, the preset face is missing, or
|
|
200
|
-
* the wire refuses.
|
|
201
|
-
*/
|
|
202
|
-
private async applyMode(
|
|
203
|
-
task: TaskRecord,
|
|
204
|
-
sessionId: string,
|
|
205
|
-
settleFailed: (error: string) => void,
|
|
206
|
-
): Promise<boolean> {
|
|
207
|
-
const mode = task.mode
|
|
208
|
-
if (mode === undefined || mode === '') return true
|
|
209
|
-
const summary = this.env.sessions.list.getSnapshot().byId[sessionId]
|
|
210
|
-
if (summary?.blank === false) {
|
|
211
|
-
settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
|
|
212
|
-
return false
|
|
213
|
-
}
|
|
214
|
-
// Fast path: the list mirror already reports the pinned preset. The
|
|
215
|
-
// mirror can lag a just-applied switch, though, so this guard alone is
|
|
216
|
-
// not enough under concurrent runs — `switchPreset` below dedupes the
|
|
217
|
-
// wire call for the lag window.
|
|
218
|
-
if (summary?.agentPreset === mode) return true
|
|
219
|
-
const presets = this.env.presets
|
|
220
|
-
if (presets === undefined) {
|
|
221
|
-
settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
|
|
222
|
-
return false
|
|
223
|
-
}
|
|
224
|
-
try {
|
|
225
|
-
const result = await this.switchPreset(presets, sessionId, mode)
|
|
226
|
-
if (!result.ok) {
|
|
227
|
-
// A list race can leave the summary without the preset label even
|
|
228
|
-
// though the blank session already runs it; the wire answers that
|
|
229
|
-
// case with agent-preset-conflict (existingPreset === requested).
|
|
230
|
-
// The requested composition is already in place, so count it as
|
|
231
|
-
// applied instead of failing the run.
|
|
232
|
-
if (presetAlreadyRuns(result.error, mode)) {
|
|
233
|
-
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
234
|
-
return true
|
|
235
|
-
}
|
|
236
|
-
settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`)
|
|
237
|
-
return false
|
|
238
|
-
}
|
|
239
|
-
} catch (error) {
|
|
240
|
-
settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`)
|
|
241
|
-
return false
|
|
242
|
-
}
|
|
243
|
-
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
244
|
-
return true
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* One in-flight `select` per (session, preset): concurrent runs against
|
|
249
|
-
* the same blank session share the same wire call instead of each issuing
|
|
250
|
-
* a duplicate RPC. The entry is removed once the call settles, so a later
|
|
251
|
-
* run (after a shared failure, say) issues a fresh switch.
|
|
252
|
-
*/
|
|
253
|
-
private switchPreset(
|
|
254
|
-
presets: PresetsExecutionFace,
|
|
255
|
-
sessionId: string,
|
|
256
|
-
mode: string,
|
|
257
|
-
): Promise<PresetSelectResult> {
|
|
258
|
-
const key = `${sessionId}\u0000${mode}`
|
|
259
|
-
const inflight = this.presetSwitches.get(key)
|
|
260
|
-
if (inflight !== undefined) return inflight
|
|
261
|
-
const attempt = presets.select(sessionId, mode).finally(() => {
|
|
262
|
-
if (this.presetSwitches.get(key) === attempt) this.presetSwitches.delete(key)
|
|
263
|
-
})
|
|
264
|
-
this.presetSwitches.set(key, attempt)
|
|
265
|
-
return attempt
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Apply the task-pinned permission preset through the `/permission <id>`
|
|
270
|
-
* slash command. No-op when the task pins none; fails the run when the
|
|
271
|
-
* admission is rejected or no command claimed the line.
|
|
272
|
-
*/
|
|
273
|
-
private async applyPermission(
|
|
274
|
-
driver: SessionDriver,
|
|
275
|
-
task: TaskRecord,
|
|
276
|
-
settleFailed: (error: string) => void,
|
|
277
|
-
): Promise<boolean> {
|
|
278
|
-
const permission = task.permission
|
|
279
|
-
if (permission === undefined) return true
|
|
280
|
-
const line = `/permission ${permission}`
|
|
281
|
-
try {
|
|
282
|
-
const result = await driver.command(line)
|
|
283
|
-
if (!result.ok) {
|
|
284
|
-
settleFailed(`permission command rejected: ${messageOf(result.error)}`)
|
|
285
|
-
return false
|
|
286
|
-
}
|
|
287
|
-
if (!result.matched) {
|
|
288
|
-
settleFailed(`permission command not recognized: ${line}`)
|
|
289
|
-
return false
|
|
290
|
-
}
|
|
291
|
-
} catch (error) {
|
|
292
|
-
settleFailed(`permission command failed: ${messageOf(error)}`)
|
|
293
|
-
return false
|
|
294
|
-
}
|
|
295
|
-
return true
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Inspect a reloaded/background task that was left 'running' and emit a
|
|
300
|
-
* settled event when its session already finished.
|
|
301
|
-
*
|
|
302
|
-
* A session that was never opened keeps a cold conversation snapshot (the
|
|
303
|
-
* runtime only maintains the window for the staged/current session), so the
|
|
304
|
-
* settled outcome is decided by the strongest available signal, in order:
|
|
305
|
-
* 1. the list summary — missing session → cancelled; still running → pending;
|
|
306
|
-
* 2. a warm conversation snapshot → `lastAgentError` decides failed/succeeded;
|
|
307
|
-
* 3. the raw history tail (when a history face is wired) — a `turn/end`
|
|
308
|
-
* error reason proves failure;
|
|
309
|
-
* 4. otherwise a finished session counts as succeeded.
|
|
310
|
-
*
|
|
311
|
-
* @param task - a task whose latest execution has no endedAt.
|
|
312
|
-
* @returns a settled event when the session state proves completion, else undefined.
|
|
313
|
-
*/
|
|
314
|
-
async reconcile(task: TaskRecord): Promise<ExecutionEvent | undefined> {
|
|
315
|
-
const execution = task.executions[task.executions.length - 1]
|
|
316
|
-
if (execution === undefined || execution.sessionId === undefined || execution.endedAt !== undefined) return undefined
|
|
317
|
-
const list = this.env.sessions.list.getSnapshot()
|
|
318
|
-
// The host list baseline has not arrived yet (page load): a session "not
|
|
319
|
-
// found" now would be a false cancel. Wait for a later list change.
|
|
320
|
-
if (list.phase !== 'ready') return undefined
|
|
321
|
-
const summary = list.byId[execution.sessionId]
|
|
322
|
-
if (summary === undefined) {
|
|
323
|
-
return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'cancelled', error: 'execution session no longer exists' }
|
|
324
|
-
}
|
|
325
|
-
if (summary.running) return undefined
|
|
326
|
-
const driver = this.driverOf(execution.sessionId)
|
|
327
|
-
if (driver !== undefined) {
|
|
328
|
-
const snapshot = driver.getSnapshot()
|
|
329
|
-
if (snapshot.turnEnds.size > 0) {
|
|
330
|
-
const outcome = snapshot.lastAgentError !== null ? 'failed' : 'succeeded'
|
|
331
|
-
return {
|
|
332
|
-
kind: 'settled', taskId: task.id, executionId: execution.id, outcome,
|
|
333
|
-
error: snapshot.lastAgentError ?? undefined,
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
const failed = await this.historyShowsFailure(execution.sessionId)
|
|
338
|
-
if (failed) {
|
|
339
|
-
return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'agent turn failed' }
|
|
340
|
-
}
|
|
341
|
-
return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'succeeded' }
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** Best-effort failure probe over the raw history tail (false when unavailable). */
|
|
345
|
-
private async historyShowsFailure(sessionId: string): Promise<boolean> {
|
|
346
|
-
const history = this.env.history
|
|
347
|
-
if (history === undefined) return false
|
|
348
|
-
try {
|
|
349
|
-
const tail = await history.loadTail(sessionId)
|
|
350
|
-
if (tail === undefined) return false
|
|
351
|
-
return tail.events.some(event => event.type === 'turn/end' && isErrorTurnEnd(event.data))
|
|
352
|
-
} catch (error) {
|
|
353
|
-
// A failed history read must not block settlement; fall back to success.
|
|
354
|
-
console.error('[dsh-task-board] history failure probe failed', error)
|
|
355
|
-
return false
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private async connectSession(taskWorkspaceId: string | undefined): Promise<string> {
|
|
360
|
-
const workspace = this.env.workspaces.list.getSnapshot()
|
|
361
|
-
if (taskWorkspaceId !== undefined && taskWorkspaceId !== '') {
|
|
362
|
-
// A task-pinned workspace must exist in the list: connecting an
|
|
363
|
-
// unknown id would only defer the failure into the wire.
|
|
364
|
-
if (!workspace.items.some(item => item.workspaceId === taskWorkspaceId)) {
|
|
365
|
-
throw new Error(`task workspace is not available: ${taskWorkspaceId}`)
|
|
366
|
-
}
|
|
367
|
-
return this.env.workspaces.connectWorkspace(taskWorkspaceId)
|
|
368
|
-
}
|
|
369
|
-
const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
|
|
370
|
-
if (workspaceId === undefined) {
|
|
371
|
-
throw new Error('no workspace available to run the task in')
|
|
372
|
-
}
|
|
373
|
-
return this.env.workspaces.connectWorkspace(workspaceId)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
private driverOf(sessionId: string): SessionDriver | undefined {
|
|
377
|
-
return this.env.sessions.binding(sessionId)?.session
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
private async sendPrompt(
|
|
381
|
-
driver: SessionDriver,
|
|
382
|
-
task: TaskRecord,
|
|
383
|
-
): Promise<{ ok: true } | { ok: false; error: unknown }> {
|
|
384
|
-
const text = task.prompt.trim() !== '' ? task.prompt : task.title
|
|
385
|
-
try {
|
|
386
|
-
const result = await driver.prompt([{ type: 'text', text }], 'queue')
|
|
387
|
-
return result
|
|
388
|
-
} catch (error) {
|
|
389
|
-
return { ok: false, error }
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Subscribe to the execution session and settle the run once the accepted
|
|
395
|
-
* turn completes (turn counter advanced past the acceptance baseline and
|
|
396
|
-
* the session is no longer running). Never settles while the session is
|
|
397
|
-
* still running; unsubscribes on settle.
|
|
398
|
-
*/
|
|
399
|
-
private watchForSettlement(
|
|
400
|
-
driver: SessionDriver,
|
|
401
|
-
taskId: string,
|
|
402
|
-
executionId: string,
|
|
403
|
-
onEvent: (event: ExecutionEvent) => void,
|
|
404
|
-
baseline: number,
|
|
405
|
-
): void {
|
|
406
|
-
let settled = false
|
|
407
|
-
let unsubscribe: () => void = () => {}
|
|
408
|
-
const check = (): void => {
|
|
409
|
-
if (settled) return
|
|
410
|
-
const snapshot = driver.getSnapshot()
|
|
411
|
-
if (snapshot.running || snapshot.turnEnds.size <= baseline) return
|
|
412
|
-
settled = true
|
|
413
|
-
unsubscribe()
|
|
414
|
-
onEvent({
|
|
415
|
-
kind: 'settled', taskId, executionId,
|
|
416
|
-
outcome: snapshot.lastAgentError !== null ? 'failed' : 'succeeded',
|
|
417
|
-
error: snapshot.lastAgentError ?? undefined,
|
|
418
|
-
})
|
|
419
|
-
}
|
|
420
|
-
unsubscribe = driver.subscribe(check)
|
|
421
|
-
// A turn can complete during the prompt round-trip (before subscribe):
|
|
422
|
-
// re-check immediately so a fast turn is never missed.
|
|
423
|
-
check()
|
|
424
|
-
}
|
|
425
|
-
}
|