@linxin666/dsh-client-ui-task-board 0.1.1

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.
Files changed (50) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +211 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +2592 -0
  5. package/lib/index.js +54 -0
  6. package/lib/invariant.js +6 -0
  7. package/lib/types/client/PluginSettingsCard.d.ts +77 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +51 -0
  9. package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
  10. package/lib/types/client/board/NewTaskModal.d.ts +6 -0
  11. package/lib/types/client/board/TaskBoard.d.ts +5 -0
  12. package/lib/types/client/board/TaskCard.d.ts +12 -0
  13. package/lib/types/client/board/TaskDetail.d.ts +7 -0
  14. package/lib/types/client/board-mount.d.ts +10 -0
  15. package/lib/types/client/index.d.ts +43 -0
  16. package/lib/types/client/locales.d.ts +105 -0
  17. package/lib/types/client/settings-form.d.ts +118 -0
  18. package/lib/types/client/sidebar-entry.d.ts +25 -0
  19. package/lib/types/core/controller.d.ts +129 -0
  20. package/lib/types/core/execution.d.ts +136 -0
  21. package/lib/types/core/schedule.d.ts +40 -0
  22. package/lib/types/core/scheduler.d.ts +49 -0
  23. package/lib/types/core/store.d.ts +36 -0
  24. package/lib/types/core/tasks.d.ts +111 -0
  25. package/lib/types/index.d.ts +44 -0
  26. package/lib/types/invariant.d.ts +3 -0
  27. package/package.json +85 -0
  28. package/src/client/PluginSettingsCard.tsx +207 -0
  29. package/src/client/TaskBoardSettingsCard.tsx +124 -0
  30. package/src/client/board/ConfirmDialog.tsx +40 -0
  31. package/src/client/board/NewTaskModal.tsx +82 -0
  32. package/src/client/board/TaskBoard.tsx +107 -0
  33. package/src/client/board/TaskCard.tsx +62 -0
  34. package/src/client/board/TaskDetail.tsx +286 -0
  35. package/src/client/board-mount.tsx +74 -0
  36. package/src/client/board.module.css +849 -0
  37. package/src/client/css-modules.d.ts +5 -0
  38. package/src/client/index.ts +167 -0
  39. package/src/client/locales.ts +211 -0
  40. package/src/client/settings-card.module.css +261 -0
  41. package/src/client/settings-form.ts +294 -0
  42. package/src/client/sidebar-entry.ts +118 -0
  43. package/src/core/controller.ts +379 -0
  44. package/src/core/execution.ts +259 -0
  45. package/src/core/schedule.ts +140 -0
  46. package/src/core/scheduler.ts +104 -0
  47. package/src/core/store.ts +183 -0
  48. package/src/core/tasks.ts +204 -0
  49. package/src/index.ts +94 -0
  50. package/src/invariant.ts +4 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Board controller: the single owner of task-ledger state and view state.
3
+ *
4
+ * It keeps the ledger in memory, persists every mutation through the
5
+ * {@link TaskStore}, drives real executions through the
6
+ * {@link ExecutionService}, and closes the board view whenever the user
7
+ * navigates to a session (the sessions-list `current` selection changes).
8
+ * Framework-free (structural runtime faces) so the whole orchestration is
9
+ * unit-testable with fakes.
10
+ */
11
+ import { ExecutionService, type ExecutionEvent } from './execution.ts'
12
+ import { isValidCron, nextRunAtMs } from './schedule.ts'
13
+ import type { TaskStore } from './store.ts'
14
+ import {
15
+ createTask, settleExecution, startExecution, withSchedule, withStatus,
16
+ type NewTaskInput, type TaskRecord, type TaskStatus,
17
+ } from './tasks.ts'
18
+
19
+ /** The sessions face the controller needs for navigation awareness. */
20
+ export interface SessionsControllerFace {
21
+ list: {
22
+ getSnapshot(): { current: string | undefined }
23
+ subscribe(fn: () => void): () => void
24
+ }
25
+ /** Select a session as current (navigates the conversation view). */
26
+ open(id: string): void
27
+ }
28
+
29
+ /** Controller dependencies (all swappable in tests). */
30
+ export interface ControllerDeps {
31
+ store: TaskStore
32
+ exec: ExecutionService
33
+ sessions: SessionsControllerFace
34
+ /** Clock; defaults to Date.now. */
35
+ now?: () => number
36
+ /** Id minting; defaults to a random-uuid. */
37
+ uuid?: () => string
38
+ /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
39
+ reconcileDebounceMs?: number
40
+ }
41
+
42
+ /** Immutable controller snapshot for UI subscriptions. */
43
+ export interface ControllerSnapshot {
44
+ tasks: readonly TaskRecord[]
45
+ boardOpen: boolean
46
+ selectedTaskId: string | undefined
47
+ }
48
+
49
+ /** The selected task (resolved from the ledger), or undefined. */
50
+ export function selectedTaskOf(snapshot: ControllerSnapshot): TaskRecord | undefined {
51
+ if (snapshot.selectedTaskId === undefined) return undefined
52
+ return snapshot.tasks.find(task => task.id === snapshot.selectedTaskId)
53
+ }
54
+
55
+ function randomUuid(): string {
56
+ const bytes = globalThis.crypto?.getRandomValues(new Uint8Array(16))
57
+ if (bytes === undefined) {
58
+ // Non-secure fallback (tests, odd environments).
59
+ return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
60
+ }
61
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
62
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
63
+ const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
64
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
65
+ }
66
+
67
+ /** Read the current selection off a session-list snapshot (structural). */
68
+ function currentOf(sessions: SessionsControllerFace): string | undefined {
69
+ return sessions.list.getSnapshot().current
70
+ }
71
+
72
+ /**
73
+ * Board controller (see module doc). All mutations bump the snapshot and
74
+ * persist through the store; UI and DOM mounts subscribe and re-render.
75
+ */
76
+ export class BoardController {
77
+ private tasks: TaskRecord[] = []
78
+ private boardOpen = false
79
+ private selectedTaskId: string | undefined
80
+ private listeners = new Set<() => void>()
81
+ private disposers: Array<() => void> = []
82
+ private readonly now: () => number
83
+ private readonly uuid: () => string
84
+
85
+ /** @param deps - store, execution service, and the sessions navigation face. */
86
+ constructor(private readonly deps: ControllerDeps) {
87
+ this.now = deps.now ?? (() => Date.now())
88
+ this.uuid = deps.uuid ?? randomUuid
89
+ }
90
+
91
+ // --- lifecycle -------------------------------------------------------------
92
+
93
+ /** Load the persisted ledger and start the navigation/status subscriptions. */
94
+ start(): void {
95
+ this.tasks = this.deps.store.load()
96
+ void this.reconcileRunningTasks()
97
+ this.disposers.push(this.deps.sessions.list.subscribe(() => {
98
+ this.onSessionsChanged()
99
+ }))
100
+ this.notify()
101
+ }
102
+
103
+ /** Stop all subscriptions and drop retained state (idempotent). */
104
+ dispose(): void {
105
+ for (const dispose of this.disposers.splice(0)) dispose()
106
+ this.listeners.clear()
107
+ if (this.reconcileTimer !== undefined) clearTimeout(this.reconcileTimer)
108
+ this.reconcileTimer = undefined
109
+ }
110
+
111
+ // --- snapshot / subscription ------------------------------------------------
112
+
113
+ getSnapshot(): ControllerSnapshot {
114
+ return {
115
+ tasks: this.tasks,
116
+ boardOpen: this.boardOpen,
117
+ selectedTaskId: this.selectedTaskId,
118
+ }
119
+ }
120
+
121
+ subscribe(fn: () => void): () => void {
122
+ this.listeners.add(fn)
123
+ return () => { this.listeners.delete(fn) }
124
+ }
125
+
126
+ // --- view state -------------------------------------------------------------
127
+
128
+ openBoard(): void {
129
+ if (this.boardOpen) return
130
+ // Baseline the selection the board opened against: the board stays open
131
+ // until the user navigates (selection changes), never on mere status
132
+ // updates of the already-selected session.
133
+ this.lastCurrent = currentOf(this.deps.sessions)
134
+ this.boardOpen = true
135
+ this.notify()
136
+ }
137
+
138
+ closeBoard(): void {
139
+ if (!this.boardOpen) return
140
+ this.boardOpen = false
141
+ this.notify()
142
+ }
143
+
144
+ toggleBoard(): void {
145
+ if (this.boardOpen) this.closeBoard()
146
+ else this.openBoard()
147
+ }
148
+
149
+ openTask(id: string): void {
150
+ if (this.tasks.some(task => task.id === id)) {
151
+ this.selectedTaskId = id
152
+ this.notify()
153
+ }
154
+ }
155
+
156
+ closeTask(): void {
157
+ if (this.selectedTaskId === undefined) return
158
+ this.selectedTaskId = undefined
159
+ this.notify()
160
+ }
161
+
162
+ // --- task mutations ---------------------------------------------------------
163
+
164
+ createTask(input: NewTaskInput): TaskRecord | undefined {
165
+ const title = input.title.trim()
166
+ if (title === '') return undefined
167
+ const task = createTask(input, this.now(), this.uuid())
168
+ this.tasks = [...this.tasks, task]
169
+ this.persistAndNotify()
170
+ return task
171
+ }
172
+
173
+ updateTask(id: string, patch: Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>): void {
174
+ this.tasks = this.tasks.map(task => task.id === id
175
+ ? { ...task, ...patch, updatedAt: this.now() }
176
+ : task)
177
+ this.persistAndNotify()
178
+ }
179
+
180
+ moveTask(id: string, status: TaskStatus): void {
181
+ this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
182
+ this.persistAndNotify()
183
+ }
184
+
185
+ deleteTask(id: string): void {
186
+ this.tasks = this.tasks.filter(task => task.id !== id)
187
+ if (this.selectedTaskId === id) this.selectedTaskId = undefined
188
+ this.persistAndNotify()
189
+ }
190
+
191
+ // --- scheduling ---------------------------------------------------------------
192
+
193
+ /**
194
+ * Update a task's schedule rule. A blank or invalid cron expression is
195
+ * rejected (returns false, state untouched). When the rule ends up enabled
196
+ * the next run instant is computed immediately; a disabled rule carries no
197
+ * next-run instant.
198
+ * @param id - the task to schedule.
199
+ * @param patch - fields to change (absent fields keep their current value).
200
+ * @returns true when applied, false when rejected (invalid cron / unknown task).
201
+ */
202
+ setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
203
+ const task = this.tasks.find(candidate => candidate.id === id)
204
+ if (task === undefined) return false
205
+ const current = task.schedule
206
+ const cron = (patch.cron ?? current?.cron ?? '').trim()
207
+ if (cron === '' || !isValidCron(cron)) return false
208
+ const enabled = patch.enabled ?? current?.enabled ?? false
209
+ const nextRunAt = enabled ? nextRunAtMs(cron, this.now()) : undefined
210
+ this.tasks = this.tasks.map(candidate =>
211
+ candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, this.now()) : candidate)
212
+ this.persistAndNotify()
213
+ return true
214
+ }
215
+
216
+ /**
217
+ * Roll a task's schedule forward (scheduler callback): persist the next due
218
+ * instant and the trigger instant of this run. No-op when the task has no
219
+ * schedule rule (it was deleted mid-tick, for example).
220
+ */
221
+ applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void {
222
+ this.tasks = this.tasks.map(task =>
223
+ task.id === id && task.schedule !== undefined
224
+ ? withSchedule(task, { nextRunAt, lastTriggeredAt }, this.now())
225
+ : task)
226
+ this.persistAndNotify()
227
+ }
228
+
229
+ /**
230
+ * Jump to an execution's session transcript. Selecting the session changes
231
+ * `current`, which closes the board (the conversation view takes over).
232
+ * @param sessionId - the execution session to open.
233
+ */
234
+ openSession(sessionId: string): void {
235
+ this.deps.sessions.open(sessionId)
236
+ }
237
+
238
+ // --- execution ---------------------------------------------------------------
239
+
240
+ /**
241
+ * Execute a task for real: move it to 'running', open an execution record,
242
+ * and hand off to the ExecutionService. A second call while the task is
243
+ * already running is ignored.
244
+ */
245
+ async runTask(id: string): Promise<boolean> {
246
+ const task = this.tasks.find(candidate => candidate.id === id)
247
+ if (task === undefined || task.status === 'running') return false
248
+ const { task: next, execution } = startExecution(task, this.now(), this.uuid())
249
+ this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
250
+ this.persistAndNotify()
251
+ // This page owns the settlement of its own launches: the live watch
252
+ // (ExecutionService.run) settles on the turn boundary, and list
253
+ // reconciliation must not pre-empt it with a session that has not
254
+ // started a turn yet (its list row is idle, not completed).
255
+ this.activeExecutionIds.add(execution.id)
256
+ await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
257
+ return true
258
+ }
259
+
260
+ /** Re-run a settled task: move it back to 'todo' first, then execute. */
261
+ async rerunTask(id: string): Promise<void> {
262
+ const task = this.tasks.find(candidate => candidate.id === id)
263
+ if (task === undefined) return
264
+ if (task.status !== 'running') {
265
+ this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
266
+ this.persistAndNotify()
267
+ }
268
+ await this.runTask(id)
269
+ }
270
+
271
+ private handleExecutionEvent(event: ExecutionEvent): void {
272
+ if (event.kind === 'started') {
273
+ this.tasks = this.tasks.map(task => task.id === event.taskId
274
+ ? attachSessionId(task, event.executionId, event.sessionId, this.now())
275
+ : task)
276
+ this.persistAndNotify()
277
+ return
278
+ }
279
+ this.activeExecutionIds.delete(event.executionId)
280
+ this.tasks = this.tasks.map(task => task.id === event.taskId
281
+ ? settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
282
+ : task)
283
+ this.persistAndNotify()
284
+ }
285
+
286
+ // --- internals ---------------------------------------------------------------
287
+
288
+ /** Reconcile running tasks and close the board when the user navigates. */
289
+ private onSessionsChanged(): void {
290
+ // Background/leftover executions settle through the session list (their
291
+ // conversation snapshots stay cold until opened). Coalesce the burst of
292
+ // list notifications into one reconcile pass instead of fanning out a
293
+ // history read per notification; see scheduleReconcile.
294
+ this.scheduleReconcile()
295
+ if (!this.boardOpen) return
296
+ const current = currentOf(this.deps.sessions)
297
+ if (current !== this.lastCurrent) this.closeBoard()
298
+ this.lastCurrent = current
299
+ }
300
+
301
+ private lastCurrent: string | undefined = undefined
302
+
303
+ /** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
304
+ private readonly activeExecutionIds = new Set<string>()
305
+
306
+ /** Debounce timer for {@link reconcileRunningTasks}. */
307
+ private reconcileTimer: ReturnType<typeof setTimeout> | undefined = undefined
308
+
309
+ /** Whether a reconcile pass is underway (single-flight guard). */
310
+ private reconcileInFlight = false
311
+
312
+ /**
313
+ * Debounce + single-flight trigger for the running-task reconciliation.
314
+ * Session-list notifications arrive in bursts (one per session status
315
+ * change); both guards together keep a burst from reading the history API
316
+ * once per running task.
317
+ */
318
+ private scheduleReconcile(): void {
319
+ if (this.reconcileTimer !== undefined) return
320
+ this.reconcileTimer = setTimeout(() => {
321
+ this.reconcileTimer = undefined
322
+ void this.reconcileRunningTasks()
323
+ }, this.deps.reconcileDebounceMs ?? 350)
324
+ }
325
+
326
+ /** Settle tasks left 'running' whose sessions already finished. */
327
+ private async reconcileRunningTasks(): Promise<void> {
328
+ if (this.reconcileInFlight) return
329
+ this.reconcileInFlight = true
330
+ try {
331
+ type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
332
+ const events: Array<{ task: TaskRecord; event: Settled }> = []
333
+ for (const task of this.tasks) {
334
+ if (task.status !== 'running') continue
335
+ const execution = task.executions[task.executions.length - 1]
336
+ // Runs launched on this page settle through their live watch (turn
337
+ // boundary); reconciliation exists for background/leftover runs.
338
+ if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
339
+ const event = await this.deps.exec.reconcile(task)
340
+ if (event !== undefined && event.kind === 'settled') events.push({ task, event })
341
+ }
342
+ if (events.length === 0) return
343
+ let changed = false
344
+ for (const { task, event } of events) {
345
+ const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
346
+ if (next === task) continue
347
+ this.tasks = this.tasks.map(candidate => candidate.id === task.id ? next : candidate)
348
+ changed = true
349
+ }
350
+ if (changed) this.persistAndNotify()
351
+ } finally {
352
+ this.reconcileInFlight = false
353
+ }
354
+ }
355
+
356
+ private persistAndNotify(): void {
357
+ this.deps.store.save(this.tasks)
358
+ this.notify()
359
+ }
360
+
361
+ private notify(): void {
362
+ for (const fn of [...this.listeners]) fn()
363
+ }
364
+ }
365
+
366
+ /** Record which session ran an execution (once the execution service reports it). */
367
+ function attachSessionId(
368
+ task: TaskRecord,
369
+ executionId: string,
370
+ sessionId: string,
371
+ now: number,
372
+ ): TaskRecord {
373
+ return {
374
+ ...task,
375
+ updatedAt: now,
376
+ executions: task.executions.map(execution =>
377
+ execution.id === executionId ? { ...execution, sessionId } : execution),
378
+ }
379
+ }
@@ -0,0 +1,259 @@
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
+ /** The narrow sessions face the service needs. */
19
+ export interface SessionsExecutionFace {
20
+ list: {
21
+ getSnapshot(): {
22
+ /** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
23
+ phase: 'pending' | 'ready'
24
+ byId: Record<string, { running: boolean; completed?: boolean }>
25
+ }
26
+ subscribe(fn: () => void): () => void
27
+ }
28
+ binding(id: string): { session: SessionDriver } | undefined
29
+ }
30
+
31
+ /** The narrow workspaces face the service needs. */
32
+ export interface WorkspacesExecutionFace {
33
+ list: {
34
+ getSnapshot(): {
35
+ items: readonly { workspaceId: string }[]
36
+ recentWorkspaceId: string | undefined
37
+ }
38
+ }
39
+ connectWorkspace(workspaceId: string): Promise<string>
40
+ }
41
+
42
+ /** One raw session-history event narrowed to the failure signal reconcile needs. */
43
+ export interface ExecutionHistoryEvent {
44
+ type: string
45
+ data?: unknown
46
+ }
47
+
48
+ /** Optional raw-history face used to detect failures of never-opened sessions. */
49
+ export interface HistoryExecutionFace {
50
+ loadTail(sessionId: string): Promise<{ events: readonly ExecutionHistoryEvent[] } | undefined>
51
+ }
52
+
53
+ /** The behavior verbs the service invokes on an execution session. */
54
+ export interface SessionDriver {
55
+ rename(title: string): Promise<unknown>
56
+ prompt(
57
+ content: readonly unknown[],
58
+ mode: 'queue',
59
+ ): Promise<{ ok: true } | { ok: false; error: unknown }>
60
+ getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
61
+ subscribe(fn: () => void): () => void
62
+ }
63
+
64
+ /** Everything the service needs from the runtime. */
65
+ export interface ExecutionEnvironment {
66
+ sessions: SessionsExecutionFace
67
+ workspaces: WorkspacesExecutionFace
68
+ /** Raw-history reader for failure detection of never-opened sessions. */
69
+ history?: HistoryExecutionFace
70
+ }
71
+
72
+ /** Outcome events the service emits to the controller. */
73
+ export type ExecutionEvent =
74
+ | { kind: 'started'; taskId: string; executionId: string; sessionId: string }
75
+ | { kind: 'settled'; taskId: string; executionId: string; outcome: 'succeeded' | 'failed' | 'cancelled'; error?: string }
76
+
77
+ /** Human copy for a run failure. */
78
+ function messageOf(error: unknown): string {
79
+ if (error instanceof Error) return error.message
80
+ return String(error)
81
+ }
82
+
83
+ /** Whether a `turn/end` payload closed the turn with an error reason. */
84
+ function isErrorTurnEnd(data: unknown): boolean {
85
+ if (typeof data !== 'object' || data === null) return false
86
+ const reason = (data as { reason?: unknown }).reason
87
+ return typeof reason === 'object' && reason !== null
88
+ && (reason as { kind?: unknown }).kind === 'error'
89
+ }
90
+
91
+ /**
92
+ * Run one task to completion (or to a settled failure).
93
+ *
94
+ * @param task - the task being executed.
95
+ * @param execution - the freshly opened execution record (id + start time).
96
+ * @param onEvent - callback for started/settled events.
97
+ * @returns resolves when the run settles (or fails to start); never rejects —
98
+ * every failure path is reported as a settled event.
99
+ */
100
+ export class ExecutionService {
101
+ /** @param env - the runtime faces (real or fake). */
102
+ constructor(private readonly env: ExecutionEnvironment) {}
103
+
104
+ async run(
105
+ task: TaskRecord,
106
+ execution: ExecutionRecord,
107
+ onEvent: (event: ExecutionEvent) => void,
108
+ ): Promise<void> {
109
+ try {
110
+ const sessionId = await this.connectSession()
111
+ onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
112
+ const driver = this.driverOf(sessionId)
113
+ if (driver === undefined) {
114
+ onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'execution session is not ready' })
115
+ return
116
+ }
117
+ // Best-effort rename so the execution is recognizable in the session list.
118
+ await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
119
+ // Baseline the turn counter BEFORE the prompt round-trip: a turn that
120
+ // completes while prompt is in flight must still advance past this
121
+ // baseline, or the watch below would never observe it settle.
122
+ const baseline = driver.getSnapshot().turnEnds.size
123
+ const accepted = await this.sendPrompt(driver, task)
124
+ if (!accepted.ok) {
125
+ onEvent({
126
+ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
127
+ error: messageOf(accepted.error),
128
+ })
129
+ return
130
+ }
131
+ this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
132
+ } catch (error) {
133
+ onEvent({
134
+ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
135
+ error: messageOf(error),
136
+ })
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Inspect a reloaded/background task that was left 'running' and emit a
142
+ * settled event when its session already finished.
143
+ *
144
+ * A session that was never opened keeps a cold conversation snapshot (the
145
+ * runtime only maintains the window for the staged/current session), so the
146
+ * settled outcome is decided by the strongest available signal, in order:
147
+ * 1. the list summary — missing session → cancelled; still running → pending;
148
+ * 2. a warm conversation snapshot → `lastAgentError` decides failed/succeeded;
149
+ * 3. the raw history tail (when a history face is wired) — a `turn/end`
150
+ * error reason proves failure;
151
+ * 4. otherwise a finished session counts as succeeded.
152
+ *
153
+ * @param task - a task whose latest execution has no endedAt.
154
+ * @returns a settled event when the session state proves completion, else undefined.
155
+ */
156
+ async reconcile(task: TaskRecord): Promise<ExecutionEvent | undefined> {
157
+ const execution = task.executions[task.executions.length - 1]
158
+ if (execution === undefined || execution.sessionId === undefined || execution.endedAt !== undefined) return undefined
159
+ const list = this.env.sessions.list.getSnapshot()
160
+ // The host list baseline has not arrived yet (page load): a session "not
161
+ // found" now would be a false cancel. Wait for a later list change.
162
+ if (list.phase !== 'ready') return undefined
163
+ const summary = list.byId[execution.sessionId]
164
+ if (summary === undefined) {
165
+ return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'cancelled', error: 'execution session no longer exists' }
166
+ }
167
+ if (summary.running) return undefined
168
+ const driver = this.driverOf(execution.sessionId)
169
+ if (driver !== undefined) {
170
+ const snapshot = driver.getSnapshot()
171
+ if (snapshot.turnEnds.size > 0) {
172
+ const outcome = snapshot.lastAgentError !== null ? 'failed' : 'succeeded'
173
+ return {
174
+ kind: 'settled', taskId: task.id, executionId: execution.id, outcome,
175
+ error: snapshot.lastAgentError ?? undefined,
176
+ }
177
+ }
178
+ }
179
+ const failed = await this.historyShowsFailure(execution.sessionId)
180
+ if (failed) {
181
+ return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'agent turn failed' }
182
+ }
183
+ return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'succeeded' }
184
+ }
185
+
186
+ /** Best-effort failure probe over the raw history tail (false when unavailable). */
187
+ private async historyShowsFailure(sessionId: string): Promise<boolean> {
188
+ const history = this.env.history
189
+ if (history === undefined) return false
190
+ try {
191
+ const tail = await history.loadTail(sessionId)
192
+ if (tail === undefined) return false
193
+ return tail.events.some(event => event.type === 'turn/end' && isErrorTurnEnd(event.data))
194
+ } catch (error) {
195
+ // A failed history read must not block settlement; fall back to success.
196
+ console.error('[dsh-task-board] history failure probe failed', error)
197
+ return false
198
+ }
199
+ }
200
+
201
+ private async connectSession(): Promise<string> {
202
+ const workspace = this.env.workspaces.list.getSnapshot()
203
+ const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
204
+ if (workspaceId === undefined) {
205
+ throw new Error('no workspace available to run the task in')
206
+ }
207
+ return this.env.workspaces.connectWorkspace(workspaceId)
208
+ }
209
+
210
+ private driverOf(sessionId: string): SessionDriver | undefined {
211
+ return this.env.sessions.binding(sessionId)?.session
212
+ }
213
+
214
+ private async sendPrompt(
215
+ driver: SessionDriver,
216
+ task: TaskRecord,
217
+ ): Promise<{ ok: true } | { ok: false; error: unknown }> {
218
+ const text = task.prompt.trim() !== '' ? task.prompt : task.title
219
+ try {
220
+ const result = await driver.prompt([{ type: 'text', text }], 'queue')
221
+ return result
222
+ } catch (error) {
223
+ return { ok: false, error }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Subscribe to the execution session and settle the run once the accepted
229
+ * turn completes (turn counter advanced past the acceptance baseline and
230
+ * the session is no longer running). Never settles while the session is
231
+ * still running; unsubscribes on settle.
232
+ */
233
+ private watchForSettlement(
234
+ driver: SessionDriver,
235
+ taskId: string,
236
+ executionId: string,
237
+ onEvent: (event: ExecutionEvent) => void,
238
+ baseline: number,
239
+ ): void {
240
+ let settled = false
241
+ let unsubscribe: () => void = () => {}
242
+ const check = (): void => {
243
+ if (settled) return
244
+ const snapshot = driver.getSnapshot()
245
+ if (snapshot.running || snapshot.turnEnds.size <= baseline) return
246
+ settled = true
247
+ unsubscribe()
248
+ onEvent({
249
+ kind: 'settled', taskId, executionId,
250
+ outcome: snapshot.lastAgentError !== null ? 'failed' : 'succeeded',
251
+ error: snapshot.lastAgentError ?? undefined,
252
+ })
253
+ }
254
+ unsubscribe = driver.subscribe(check)
255
+ // A turn can complete during the prompt round-trip (before subscribe):
256
+ // re-check immediately so a fast turn is never missed.
257
+ check()
258
+ }
259
+ }