@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5

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 (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +23 -2
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -208,7 +208,7 @@ export function makeTaskBoardRoutes(
208
208
  if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
209
209
  return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
210
210
  }
211
- writeJson(res, 200, service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
211
+ writeJson(res, 200, await service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
212
212
  } catch (error) {
213
213
  const message = error instanceof Error ? error.message : String(error)
214
214
  writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
@@ -289,5 +289,18 @@ export function makeTaskBoardRoutes(
289
289
  }
290
290
  },
291
291
  }
292
- return [state, action, events, parse]
292
+ const verification: WebRoute = {
293
+ kind: 'exact',
294
+ path: `${TASK_BOARD_API_PREFIX}/verification`,
295
+ handler: async (req, res): Promise<void> => {
296
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
297
+ if (!guard(req, res)) return
298
+ try {
299
+ writeJson(res, 200, await service.verificationOptions(), { 'cache-control': 'no-store' })
300
+ } catch (error) {
301
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }, { 'cache-control': 'no-store' })
302
+ }
303
+ },
304
+ }
305
+ return [state, action, events, parse, verification]
293
306
  }
@@ -3,9 +3,13 @@ import type { SessionAddress, SessionHistoryRecord, SessionListValue, SessionPag
3
3
  import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
4
4
  import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
5
5
  import { teammateName } from './core/subtask.ts'
6
+ import { mostRecentWorkspaceId } from './core/workspace-target.ts'
6
7
  import type { TaskPermission, TaskRecord } from './core/tasks.ts'
7
8
 
8
- /** Host services needed to validate a task's workspace before creating a session. */
9
+ /**
10
+ * Host services needed to validate a task's workspace before creating a
11
+ * session, and to resolve the workspace an unpinned card runs in.
12
+ */
9
13
  export interface TaskBoardWorkspaceRegistry {
10
14
  list(): readonly Workspace[]
11
15
  }
@@ -60,6 +64,17 @@ function isDefinitionWithdrawn(error: unknown): boolean {
60
64
 
61
65
  const SERVICE_UNAVAILABLE_ATTEMPTS = 5
62
66
  const SERVICE_UNAVAILABLE_BACKOFF_MS = 2_000
67
+ /**
68
+ * Largest number of history pages one inspection walks back. A session whose
69
+ * window is wider than this cannot be resolved from its log, which the caller
70
+ * treats as an unreadable history rather than as pending work.
71
+ */
72
+ const HISTORY_PAGE_LIMIT = 100
73
+
74
+ /** One-line text of a gateway or stream failure, for a decideable reason. */
75
+ function failureText(error: unknown): string {
76
+ return error instanceof Error ? error.message : String(error)
77
+ }
63
78
 
64
79
  function delay(ms: number): Promise<void> {
65
80
  return new Promise(resolve => { setTimeout(resolve, ms) })
@@ -87,7 +102,14 @@ export interface SessionCommandDispatcher {
87
102
  }
88
103
 
89
104
  export type ExecutionInspection =
90
- | { outcome: 'pending' }
105
+ /**
106
+ * No verdict yet. `unreadable` separates "the work may still be in progress"
107
+ * from "this session's history could not be read at all": a reader failure is
108
+ * not progress, and the caller decides how long to keep waiting before it
109
+ * reports the execution as undeterminable instead of holding a card open
110
+ * forever.
111
+ */
112
+ | { outcome: 'pending'; unreadable?: true; reason?: string }
91
113
  | { outcome: 'succeeded' }
92
114
  | { outcome: 'failed'; error: string }
93
115
  | { outcome: 'cancelled'; error: string }
@@ -136,6 +158,13 @@ export interface PromptContext {
136
158
  peers?: readonly PromptPeer[]
137
159
  /** True when those members run as teammates inside this session (Team Lead). */
138
160
  team?: boolean
161
+ /**
162
+ * Scheduled-run provenance: the instant the cron rule actually fired and the
163
+ * IANA zone its wall clock was read in. Present only for a cron-triggered
164
+ * run, so the model can resolve an unqualified date in the run's own terms
165
+ * instead of guessing from the Host clock (issue #1722).
166
+ */
167
+ schedule?: { triggeredAt: number; timeZone: string; cron: string }
139
168
  }
140
169
 
141
170
  /**
@@ -147,13 +176,27 @@ function peerPromptPreamble(context: PromptContext): string | undefined {
147
176
  const peers = context.peers ?? []
148
177
  if (peers.length === 0) return undefined
149
178
  if (context.team === true) {
150
- const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id)})`)
179
+ const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id, peer.id)})`)
151
180
  return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join('\n')}`
152
181
  }
153
182
  const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`)
154
183
  return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join('\n')}`
155
184
  }
156
185
 
186
+ /**
187
+ * The scheduled-run section: this execution was triggered by the card's cron
188
+ * rule rather than by a person. It states the firing instant, the rule's zone,
189
+ * and the rule text, so the run's own clock is unambiguous — the board's
190
+ * scheduler fires on a wall clock in a specific IANA zone, which is not
191
+ * necessarily the zone of whatever session the run lands in.
192
+ */
193
+ function schedulePromptPreamble(context: PromptContext): string | undefined {
194
+ const schedule = context.schedule
195
+ if (schedule === undefined) return undefined
196
+ const stamp = new Date(schedule.triggeredAt).toISOString()
197
+ return `本次执行由任务看板的定时规则自动触发:触发时间 ${stamp}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`
198
+ }
199
+
157
200
  /**
158
201
  * Build the execution prompt for one task.
159
202
  * @param task - the task being launched.
@@ -167,11 +210,14 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
167
210
  ? undefined
168
211
  : `交接包引用(来自任务看板续接卡片,冻结于 ${new Date(handover.bundledAt).toISOString()}):\n${handover.references.map(reference => `- ${reference}`).join('\n')}`
169
212
  // Tag prompts come first: they are the run's standing context (business line,
170
- // output location), the handover preamble is a per-card note, and the task
171
- // body is the instruction itself.
213
+ // output location), the handover preamble is a per-card note, the scheduled
214
+ // section states why this run exists, and the task body is the instruction.
172
215
  const tagPreamble = tagPromptPreamble(task)
216
+ const handoverSection = handoverPreamble
217
+ const schedulePreamble = schedulePromptPreamble(context)
173
218
  const runPreamble = peerPromptPreamble(context)
174
- const preambles = [tagPreamble, handoverPreamble, runPreamble].filter((part): part is string => part !== undefined)
219
+ const preambles = [tagPreamble, handoverSection, schedulePreamble, runPreamble]
220
+ .filter((part): part is string => part !== undefined)
175
221
  const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
176
222
  const freeze = task.freeze
177
223
  if (freeze === undefined) {
@@ -182,6 +228,27 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
182
228
  return preamble === undefined ? declaration : `${preamble}\n\n${declaration}`
183
229
  }
184
230
 
231
+ /**
232
+ * The parts of an objective the built-in `/goal` command reads as an operation
233
+ * rather than as an objective: an exact `clear`/`pause`/`resume`/`edit`
234
+ * (case-insensitive) and a leading `edit <objective>`.
235
+ */
236
+ const GOAL_COMMAND_KEYWORD = /^(?:clear|pause|resume|edit)$|^edit\s/i
237
+
238
+ /**
239
+ * Build the objective the built-in `/goal` command receives. A prompt whose
240
+ * text would be read as a goal operation is prefixed with a neutral label: the
241
+ * instruction stays intact, and the command arms an objective instead of
242
+ * clearing, pausing, or editing an unrelated goal (or refusing for a missing
243
+ * one).
244
+ * @param prompt - the composed execution prompt.
245
+ * @returns the objective text for `/goal <objective>`.
246
+ */
247
+ export function goalObjective(prompt: string): string {
248
+ const text = prompt.trim()
249
+ return GOAL_COMMAND_KEYWORD.test(text) ? `Goal: ${text}` : text
250
+ }
251
+
185
252
  /**
186
253
  * Build the tag section of the execution prompt (issue #1521). Only tags with
187
254
  * a non-blank `promptPrefix` contribute; a task whose tags are all bare names
@@ -235,6 +302,8 @@ function nonCompletedTurnEnd(data: unknown): string | undefined {
235
302
  */
236
303
  function invokeWireArgs(namespace: string, method: string, request: Record<string, unknown>): Record<string, unknown> {
237
304
  if (namespace === 'agentPresets' && method === 'list') return {}
305
+ // session/modelCatalog declares zero parameters, so its args must be {}.
306
+ if (namespace === 'session' && method === 'modelCatalog') return {}
238
307
  if (namespace === 'session' && method === 'list') return { _request: request }
239
308
  return { request }
240
309
  }
@@ -265,6 +334,18 @@ export class HostExecutionRunner {
265
334
  return this.gateway.stream({ namespace, method, args: { request }, ...(signal === undefined ? {} : { signal }) })
266
335
  }
267
336
 
337
+ /**
338
+ * The workspace an unpinned run lands in: the deployment's most recently used
339
+ * one. Undefined when the deployment serves no workspace registry or has
340
+ * registered none yet; the launch then keeps the "let the Host decide" shape,
341
+ * which is the only remaining path to the Host's own working directory.
342
+ * @returns the workspace id to create the session in, when one is known.
343
+ */
344
+ private recentWorkspaceId(): string | undefined {
345
+ if (this.workspaceRegistry === undefined) return undefined
346
+ return mostRecentWorkspaceId(this.workspaceRegistry.list())
347
+ }
348
+
268
349
  /**
269
350
  * Launch one execution. Without `options.reuseSessionId` a fresh session is
270
351
  * created, renamed, pinned, and prompted (the historical contract). With it,
@@ -275,10 +356,14 @@ export class HostExecutionRunner {
275
356
  * @param options - optional session to continue in.
276
357
  * @returns the session id the execution runs in.
277
358
  */
278
- async launch(task: TaskRecord, options: { reuseSessionId?: string; promptContext?: PromptContext } = {}): Promise<string> {
359
+ async launch(task: TaskRecord, options: { reuseSessionId?: string; promptContext?: PromptContext; onSession?: (sessionId: string) => void; onGoalArmed?: (armed: boolean) => void } = {}): Promise<string> {
279
360
  // A handover bundle overrides the legacy pin fields: the bundle is the
280
- // authoritative execution triplet for a continuation card (issue #5).
281
- const workspaceId = task.handover?.workspaceId ?? task.workspaceId
361
+ // authoritative execution triplet for a continuation card (issue #5). A
362
+ // card that pins nothing is resolved to the deployment's most recently used
363
+ // workspace instead of being left to `session.create`, whose own fallback is
364
+ // the Host process working directory (the desktop app's profile directory) —
365
+ // never the project the user was in when the card was written.
366
+ const workspaceId = task.handover?.workspaceId ?? task.workspaceId ?? this.recentWorkspaceId()
282
367
  const mode = task.handover?.mode ?? task.mode
283
368
  const permission = task.handover?.permission ?? task.permission
284
369
 
@@ -306,8 +391,12 @@ export class HostExecutionRunner {
306
391
  // whose session was composed from a different preset now fails closed
307
392
  // instead of silently running under the wrong composition, matching
308
393
  // the fresh branch's `agentPreset` assertion (issue #1708).
394
+ // Report the session before anything is queued into it, so the board
395
+ // can bind the execution first and a completion claim can never arrive
396
+ // at the gate without its binding.
397
+ options.onSession?.(reused)
309
398
  await this.assertReusedPreset(reused, mode)
310
- await this.pinAndPrompt(reused, task, permission, options.promptContext)
399
+ await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed)
311
400
  } catch (error) {
312
401
  throw new SessionLaunchError(reused, error)
313
402
  }
@@ -318,9 +407,10 @@ export class HostExecutionRunner {
318
407
  ...(mode === undefined ? {} : { agentPreset: mode }),
319
408
  }) as { sessionId: ExecutionSessionId }
320
409
  const sessionId = created.sessionId
410
+ options.onSession?.(sessionId)
321
411
  try {
322
412
  await this.invoke('session', 'rename', { sessionId, title: task.title })
323
- await this.pinAndPrompt(sessionId, task, permission, options.promptContext)
413
+ await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed)
324
414
  } catch (error) {
325
415
  throw new SessionLaunchError(sessionId, error)
326
416
  }
@@ -365,6 +455,7 @@ export class HostExecutionRunner {
365
455
  task: TaskRecord,
366
456
  permission: TaskPermission | undefined,
367
457
  context: PromptContext = {},
458
+ onGoalArmed?: (armed: boolean) => void,
368
459
  ): Promise<void> {
369
460
  if (permission !== undefined) {
370
461
  if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
@@ -393,6 +484,103 @@ export class HostExecutionRunner {
393
484
  mode: 'queue' as const,
394
485
  content: [{ type: 'text' as const, text: promptText(task, context) }],
395
486
  })
487
+ const armed = await this.armGoal(sessionId, task, context)
488
+ onGoalArmed?.(armed)
489
+ }
490
+
491
+ /**
492
+ * Read the host's model catalog: the default route an unconfigured session
493
+ * starts at, and each provider's models with the reasoning metadata its
494
+ * adapter exposes. The acceptance settings card and the frozen contract both
495
+ * resolve against it, so "inherit host" means the host's own configuration
496
+ * rather than a session's or a card's pinned model.
497
+ * @returns the raw catalog value, or undefined when this cohort serves none.
498
+ */
499
+ async modelCatalog(): Promise<unknown> {
500
+ try {
501
+ return await this.invoke('session', 'modelCatalog', {})
502
+ } catch (error) {
503
+ console.warn('[dsh-task-board] session/modelCatalog failed; the acceptance settings fall back to the configured route', error)
504
+ return undefined
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Arm dsh's built-in goal on the session. The task prompt is queued FIRST, so
510
+ * the session's first turn carries the instruction verbatim; `/goal
511
+ * <objective>` then turns the same text into a persistent objective, and
512
+ * dsh's goal-round driver keeps starting continuation rounds until the agent
513
+ * marks it complete (see {@link goalVerdict} for how the run settles).
514
+ *
515
+ * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
516
+ * A refusal (no command dispatcher, no `/goal` command in this cohort, an
517
+ * objective the command rejects) is reported and the run continues as that
518
+ * plain turn: the task was asked to run, and a missing goal mode must not
519
+ * lose the work.
520
+ */
521
+ private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<boolean> {
522
+ if (task.goalRun === false) return false
523
+ if (this.commands === undefined) {
524
+ console.warn('[dsh-task-board] no command dispatcher is available; task ' + task.id + ' runs without /goal')
525
+ return false
526
+ }
527
+ try {
528
+ const command = await this.commands.execute(
529
+ sessionId,
530
+ '/goal ' + goalObjective(promptText(task, context)),
531
+ AbortSignal.timeout(30_000),
532
+ )
533
+ if (command === undefined) {
534
+ console.warn('[dsh-task-board] /goal was not acknowledged for session ' + sessionId + '; the run continues as a plain turn')
535
+ return false
536
+ }
537
+ if (command.kind !== 'success') {
538
+ console.warn('[dsh-task-board] /goal was refused for session ' + sessionId + ': ' + (command.text ?? 'no reason reported') + '; the run continues as a plain turn')
539
+ return false
540
+ }
541
+ return true
542
+ } catch (error) {
543
+ console.warn('[dsh-task-board] could not arm /goal on session ' + sessionId + '; the run continues as a plain turn', error)
544
+ return false
545
+ }
546
+ }
547
+
548
+ /**
549
+ * The verdict an armed dsh goal imposes on an otherwise completed turn.
550
+ *
551
+ * A goal run is one session working many automatic continuation rounds:
552
+ * dsh's goal-round driver queues the next round whenever the agent goes
553
+ * idle, so the first completed `turn/end` is not the execution's result.
554
+ * Settling there would leave the card in `done` while the session keeps
555
+ * working, and a scheduled task would fire a second, concurrent session.
556
+ * The session projection is the authority: an `active` goal keeps the
557
+ * execution pending, a `blocked` goal fails it with the recorded reason, and
558
+ * any other phase (complete, paused, or no goal at all) leaves the turn
559
+ * verdict in place.
560
+ *
561
+ * An unreadable projection falls back to that turn verdict: a cohort whose
562
+ * gateway withdrew `session/projections` must not hang every execution.
563
+ */
564
+ private async goalVerdict(sessionId: string): Promise<ExecutionInspection | undefined> {
565
+ let projections: { values?: { goal?: unknown } } | null
566
+ try {
567
+ projections = await this.invoke('session', 'projections', { sessionId }) as { values?: { goal?: unknown } } | null
568
+ } catch (error) {
569
+ console.warn('[dsh-task-board] session/projections failed while reading the goal phase; settling from the turn result', error)
570
+ return undefined
571
+ }
572
+ const goal = projections?.values?.goal
573
+ if (goal === undefined || goal === null) return undefined
574
+ const view = (goal as { goal?: { phase?: unknown; blockedReason?: { message?: unknown } } }).goal
575
+ if (view?.phase === 'active') return { outcome: 'pending' }
576
+ if (view?.phase === 'blocked') {
577
+ const message = view.blockedReason?.message
578
+ return {
579
+ outcome: 'failed',
580
+ error: 'goal is blocked: ' + (typeof message === 'string' && message !== '' ? message : 'no reason recorded'),
581
+ }
582
+ }
583
+ return undefined
396
584
  }
397
585
 
398
586
  async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
@@ -424,8 +612,17 @@ export class HostExecutionRunner {
424
612
  }
425
613
  }
426
614
 
427
- /** Resolve an execution outcome from the session list and bounded history pages. */
428
- async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection> {
615
+ /**
616
+ * Resolve an execution outcome from the session list and bounded history pages.
617
+ * @param sessionId - the session the execution runs in.
618
+ * @param startedAt - instant the execution opened; earlier events are not its own.
619
+ * @param sessions - roster from the caller's own poll, when it already has one.
620
+ * @param options.whileRunning - read the first completed turn even while the
621
+ * roster reports the session as running. A durable Agent Teams teammate
622
+ * stays alive after its turn ends, so `running` never clears for it and the
623
+ * turn it completed is the only verdict it will ever expose.
624
+ */
625
+ async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[], options: { whileRunning?: boolean } = {}): Promise<ExecutionInspection> {
429
626
  let items: readonly SessionSummary[]
430
627
  if (sessions !== undefined) {
431
628
  items = sessions
@@ -455,7 +652,7 @@ export class HostExecutionRunner {
455
652
  this.scanMemos.delete(sessionId)
456
653
  return { outcome: 'cancelled', error: 'execution session no longer exists' }
457
654
  }
458
- if (summary.running) return { outcome: 'pending' }
655
+ if (summary.running && options.whileRunning !== true) return { outcome: 'pending' }
459
656
 
460
657
  let opening: { cursor: number; records: readonly SessionHistoryRecord[]; hasMore: boolean }
461
658
  try {
@@ -465,12 +662,12 @@ export class HostExecutionRunner {
465
662
  if (typeof iterator.return === 'function') await iterator.return()
466
663
  const follow = next.done === true ? undefined : next.value as { type?: string; cursor?: number; records?: readonly SessionHistoryRecord[]; hasMore?: boolean }
467
664
  if (follow === undefined || follow.type !== 'snapshot' || typeof follow.cursor !== 'number' || follow.records === undefined || typeof follow.hasMore !== 'boolean') {
468
- return { outcome: 'pending' }
665
+ return { outcome: 'pending', unreadable: true, reason: 'session/follow returned no opening frame' }
469
666
  }
470
667
  opening = { cursor: follow.cursor, records: follow.records, hasMore: follow.hasMore }
471
668
  } catch (error) {
472
669
  console.warn('[dsh-task-board] session/follow failed during execution inspection; keeping the outcome pending', error)
473
- return { outcome: 'pending' }
670
+ return { outcome: 'pending', unreadable: true, reason: 'session/follow failed: ' + failureText(error) }
474
671
  }
475
672
  const openingEvents = opening.records.map(record => ({ event: recordEvent(record) }))
476
673
  const newestSeq = openingEvents.reduce<number | undefined>((newest, entry) => newest === undefined ? entry.event.seq : Math.max(newest, entry.event.seq), undefined)
@@ -478,7 +675,7 @@ export class HostExecutionRunner {
478
675
  const events: Array<{ event: { type: string; seq: number; time: number; data: unknown } }> = [...openingEvents]
479
676
  let beforeSeq: number | undefined
480
677
  let reachedExecutionBoundary = !opening.hasMore
481
- for (let page = 0; page < 100 && !reachedExecutionBoundary; page += 1) {
678
+ for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
482
679
  let history: SessionPage
483
680
  try {
484
681
  history = await this.invoke('session', 'page', {
@@ -489,7 +686,7 @@ export class HostExecutionRunner {
489
686
  }) as SessionPage
490
687
  } catch (error) {
491
688
  console.warn('[dsh-task-board] session/page failed during execution inspection; keeping the outcome pending', error)
492
- return { outcome: 'pending' }
689
+ return { outcome: 'pending', unreadable: true, reason: 'session/page failed: ' + failureText(error) }
493
690
  }
494
691
  const pageEntries = pageEvents(history)
495
692
  events.push(...pageEntries)
@@ -502,7 +699,9 @@ export class HostExecutionRunner {
502
699
  if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
503
700
  beforeSeq = oldestSeq
504
701
  }
505
- if (!reachedExecutionBoundary) return { outcome: 'pending' }
702
+ if (!reachedExecutionBoundary) {
703
+ return { outcome: 'pending', unreadable: true, reason: 'history scan did not reach the execution start within ' + HISTORY_PAGE_LIMIT + ' pages' }
704
+ }
506
705
  const turnEnd = events
507
706
  .filter(entry => entry.event.type === 'turn/end' && (startedAt <= 0 || entry.event.time >= startedAt))
508
707
  .sort((a, b) => a.event.seq - b.event.seq)[0]
@@ -512,7 +711,7 @@ export class HostExecutionRunner {
512
711
  }
513
712
  this.scanMemos.delete(sessionId)
514
713
  const reason = nonCompletedTurnEnd(turnEnd.event.data)
515
- if (reason === undefined) return { outcome: 'succeeded' }
714
+ if (reason === undefined) return await this.goalVerdict(sessionId) ?? { outcome: 'succeeded' }
516
715
  // `error` keeps its historical wording; every other non-completed reason
517
716
  // (aborted / blocked / max-tokens / interrupted) now reports instead of
518
717
  // silently counting as success.