@linxin666/dsh-client-ui-task-board 0.4.0 → 0.4.2

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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +24 -0
  3. package/README.zh.md +24 -0
  4. package/lib/client.js +917 -81
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1774 -1420
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
  8. package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
  9. package/lib/types/client/board/NewTaskModal.d.ts +7 -1
  10. package/lib/types/client/board/TaskCard.d.ts +11 -1
  11. package/lib/types/client/locales.d.ts +33 -0
  12. package/lib/types/core/controller.d.ts +30 -1
  13. package/lib/types/core/subtask.d.ts +122 -0
  14. package/lib/types/core/tasks.d.ts +46 -3
  15. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  16. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  17. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  18. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  19. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  20. package/lib/types/host/agent-tools.d.ts +27 -0
  21. package/lib/types/host-ledger.d.ts +88 -4
  22. package/lib/types/host-runner.d.ts +39 -1
  23. package/lib/types/host-service.d.ts +47 -0
  24. package/lib/types/index.d.ts +83 -5
  25. package/lib/types/protocol.d.ts +14 -0
  26. package/package.json +23 -21
  27. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  28. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  29. package/src/client/board/NewTaskModal.tsx +40 -6
  30. package/src/client/board/TaskBoard.tsx +88 -8
  31. package/src/client/board/TaskCard.tsx +52 -2
  32. package/src/client/board/TaskDetail.tsx +124 -1
  33. package/src/client/board.module.css +96 -0
  34. package/src/client/host-api.ts +14 -4
  35. package/src/client/index.ts +11 -1
  36. package/src/client/locales.ts +70 -2
  37. package/src/core/controller.ts +69 -8
  38. package/src/core/store.ts +15 -0
  39. package/src/core/subtask.ts +286 -0
  40. package/src/core/tasks.ts +50 -2
  41. package/src/core/use-cases/task-archive.ts +46 -12
  42. package/src/core/use-cases/task-create.ts +42 -7
  43. package/src/core/use-cases/task-delete.ts +11 -1
  44. package/src/core/use-cases/task-parent.ts +64 -0
  45. package/src/core/use-cases/task-update.ts +3 -1
  46. package/src/host/agent-tools.ts +643 -0
  47. package/src/host-ledger.ts +323 -45
  48. package/src/host-runner.ts +141 -12
  49. package/src/host-service.ts +127 -10
  50. package/src/index.ts +181 -9
  51. package/src/protocol.ts +26 -2
@@ -2,6 +2,7 @@ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
2
2
  import type { SessionAddress, SessionHistoryRecord, SessionListValue, SessionPage, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/types'
3
3
  import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
4
4
  import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
5
+ import { teammateName } from './core/subtask.ts'
5
6
  import type { TaskPermission, TaskRecord } from './core/tasks.ts'
6
7
 
7
8
  /** Host services needed to validate a task's workspace before creating a session. */
@@ -45,6 +46,18 @@ function isInvocationUnavailable(error: unknown): boolean {
45
46
  return code === 'invocation-unavailable' || code === 'gateway/invocation-unavailable'
46
47
  }
47
48
 
49
+ /**
50
+ * Whether the endpoint exists but its strict definition was withdrawn by the
51
+ * running gateway (the cohort changed under this plugin). The plugin can send
52
+ * the call correctly and still be refused, and nothing in this repository can
53
+ * restore the definition, so the roster is treated as unknown quietly instead
54
+ * of printing an error the user has no action for (#1672).
55
+ */
56
+ function isDefinitionWithdrawn(error: unknown): boolean {
57
+ const code = (error as { code?: unknown }).code
58
+ return code === 'definition-unavailable' || code === 'gateway/definition-unavailable'
59
+ }
60
+
48
61
  const SERVICE_UNAVAILABLE_ATTEMPTS = 5
49
62
  const SERVICE_UNAVAILABLE_BACKOFF_MS = 2_000
50
63
 
@@ -107,7 +120,47 @@ function escapeProvenanceDelimiter(value: string): string {
107
120
  * first, the provenance wrap then encloses the instruction. Plain tasks (no
108
121
  * freeze) keep the bare handover preamble + prompt.
109
122
  */
110
- export function promptText(task: TaskRecord): string {
123
+ /** One other execution member of a run, as named in the launched task's prompt. */
124
+ export interface PromptPeer {
125
+ /** Task id of the member. */
126
+ id: string
127
+ /** Member title. */
128
+ title: string
129
+ /** Teammate name when this member runs as a teammate. */
130
+ name?: string
131
+ }
132
+
133
+ /** Execution-shape context appended to the launched task's prompt. */
134
+ export interface PromptContext {
135
+ /** The other members this run opens, excluding the launched task itself. */
136
+ peers?: readonly PromptPeer[]
137
+ /** True when those members run as teammates inside this session (Team Lead). */
138
+ team?: boolean
139
+ }
140
+
141
+ /**
142
+ * The execution-shape section: what else this run opens. A plain cascade opens
143
+ * one independent session per member; a team run starts every other member as a
144
+ * teammate inside THIS session, so the prompt names the Team tools instead.
145
+ */
146
+ function peerPromptPreamble(context: PromptContext): string | undefined {
147
+ const peers = context.peers ?? []
148
+ if (peers.length === 0) return undefined
149
+ if (context.team === true) {
150
+ const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id)})`)
151
+ return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join('\n')}`
152
+ }
153
+ const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`)
154
+ return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join('\n')}`
155
+ }
156
+
157
+ /**
158
+ * Build the execution prompt for one task.
159
+ * @param task - the task being launched.
160
+ * @param context - the run's other members, for the execution-shape section.
161
+ * @returns the prompt text.
162
+ */
163
+ export function promptText(task: TaskRecord, context: PromptContext = {}): string {
111
164
  const body = task.prompt !== '' ? task.prompt : task.title
112
165
  const handover = task.handover
113
166
  const handoverPreamble = handover === undefined || handover.references.length === 0
@@ -117,7 +170,8 @@ export function promptText(task: TaskRecord): string {
117
170
  // output location), the handover preamble is a per-card note, and the task
118
171
  // body is the instruction itself.
119
172
  const tagPreamble = tagPromptPreamble(task)
120
- const preambles = [tagPreamble, handoverPreamble].filter((part): part is string => part !== undefined)
173
+ const runPreamble = peerPromptPreamble(context)
174
+ const preambles = [tagPreamble, handoverPreamble, runPreamble].filter((part): part is string => part !== undefined)
121
175
  const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
122
176
  const freeze = task.freeze
123
177
  if (freeze === undefined) {
@@ -145,10 +199,27 @@ function tagPromptPreamble(task: TaskRecord): string | undefined {
145
199
  return `标签提示(任务看板标签,每次执行前注入):\n${lines.join('\n')}`
146
200
  }
147
201
 
148
- function isErrorTurnEnd(data: unknown): boolean {
149
- if (typeof data !== 'object' || data === null) return false
202
+ /**
203
+ * Why a `turn/end` did not complete successfully, or undefined when it did.
204
+ *
205
+ * The harness treats exactly `completed` as success (`headless` maps the turn
206
+ * reason to its exit code, `subagent/projection` records `lastTurnCompleted`,
207
+ * and `max-tokens` is a ceiling, not a result). A task-board execution is a
208
+ * SUCCESS only on positive evidence of a completed turn; every other reason —
209
+ * and an unreadable one — must be reported, or the board stamps a failed run
210
+ * as `succeeded` and a schedule dies silently (issue #1708). `interrupted` is
211
+ * the reason a restart produces: `interruptedTurnClosers` synthesizes it for a
212
+ * log whose tail turn never ended, which is exactly an aborted resume.
213
+ * @param data - the `turn/end` event payload.
214
+ * @returns the reason kind when it is not `completed`, else undefined.
215
+ */
216
+ function nonCompletedTurnEnd(data: unknown): string | undefined {
217
+ if (typeof data !== 'object' || data === null) return 'unknown'
150
218
  const reason = (data as { reason?: unknown }).reason
151
- return typeof reason === 'object' && reason !== null && (reason as { kind?: unknown }).kind === 'error'
219
+ if (typeof reason !== 'object' || reason === null) return 'unknown'
220
+ const kind = (reason as { kind?: unknown }).kind
221
+ if (typeof kind !== 'string') return 'unknown'
222
+ return kind === 'completed' ? undefined : kind
152
223
  }
153
224
 
154
225
  /**
@@ -204,7 +275,7 @@ export class HostExecutionRunner {
204
275
  * @param options - optional session to continue in.
205
276
  * @returns the session id the execution runs in.
206
277
  */
207
- async launch(task: TaskRecord, options: { reuseSessionId?: string } = {}): Promise<string> {
278
+ async launch(task: TaskRecord, options: { reuseSessionId?: string; promptContext?: PromptContext } = {}): Promise<string> {
208
279
  // A handover bundle overrides the legacy pin fields: the bundle is the
209
280
  // authoritative execution triplet for a continuation card (issue #5).
210
281
  const workspaceId = task.handover?.workspaceId ?? task.workspaceId
@@ -227,7 +298,16 @@ export class HostExecutionRunner {
227
298
  const reused = options.reuseSessionId as ExecutionSessionId | undefined
228
299
  if (reused !== undefined) {
229
300
  try {
230
- await this.pinAndPrompt(reused, task, permission)
301
+ // Assert the pinned preset against the one the session records before
302
+ // prompting. The reuse path must not call `session/create` (that would
303
+ // create or re-adopt a session the board only meant to continue), so
304
+ // the recorded preset is read through `session/projections` — the
305
+ // documented read that resolves NO Agent — and compared here. A card
306
+ // whose session was composed from a different preset now fails closed
307
+ // instead of silently running under the wrong composition, matching
308
+ // the fresh branch's `agentPreset` assertion (issue #1708).
309
+ await this.assertReusedPreset(reused, mode)
310
+ await this.pinAndPrompt(reused, task, permission, options.promptContext)
231
311
  } catch (error) {
232
312
  throw new SessionLaunchError(reused, error)
233
313
  }
@@ -240,19 +320,52 @@ export class HostExecutionRunner {
240
320
  const sessionId = created.sessionId
241
321
  try {
242
322
  await this.invoke('session', 'rename', { sessionId, title: task.title })
243
- await this.pinAndPrompt(sessionId, task, permission)
323
+ await this.pinAndPrompt(sessionId, task, permission, options.promptContext)
244
324
  } catch (error) {
245
325
  throw new SessionLaunchError(sessionId, error)
246
326
  }
247
327
  return sessionId
248
328
  }
249
329
 
330
+ /**
331
+ * Fail closed when a reused session records a different Agent preset than
332
+ * the one this task pins.
333
+ *
334
+ * The fresh branch asserts the pin by passing `agentPreset` to
335
+ * `session/create`; the reuse branch must not call that method (it would
336
+ * create or re-adopt the very session the board only meant to continue), so
337
+ * the recorded value is read through `session/projections` — the documented
338
+ * read that resolves no Agent — and compared here (issue #1708). A session
339
+ * whose preset cannot be read is not treated as a match: silently continuing
340
+ * under an unknown composition is the failure this guards.
341
+ * @param sessionId - the session this execution continues in.
342
+ * @param pinned - the preset id the task pins, when it pins one.
343
+ */
344
+ private async assertReusedPreset(sessionId: ExecutionSessionId, pinned: string | undefined): Promise<void> {
345
+ if (pinned === undefined) return
346
+ const projections = await this.invoke('session', 'projections', { sessionId }) as
347
+ | { values?: { agentPreset?: unknown } }
348
+ | null
349
+ const recorded = projections?.values?.agentPreset
350
+ if (recorded === pinned) return
351
+ throw new Error(
352
+ 'reused session ' + sessionId + ' was composed from agent preset '
353
+ + (typeof recorded === 'string' ? '"' + recorded + '"' : 'an unreadable value')
354
+ + ', but the task pins "' + pinned + '"',
355
+ )
356
+ }
357
+
250
358
  /**
251
359
  * Re-assert the pinned execution contract on a session and queue the task
252
360
  * prompt. Shared by the fresh-session and reuse paths so both apply exactly
253
361
  * the same permission/model pins before the prompt.
254
362
  */
255
- private async pinAndPrompt(sessionId: ExecutionSessionId, task: TaskRecord, permission: TaskPermission | undefined): Promise<void> {
363
+ private async pinAndPrompt(
364
+ sessionId: ExecutionSessionId,
365
+ task: TaskRecord,
366
+ permission: TaskPermission | undefined,
367
+ context: PromptContext = {},
368
+ ): Promise<void> {
256
369
  if (permission !== undefined) {
257
370
  if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
258
371
  const command = await this.commands.execute(sessionId, '/permission ' + permission, AbortSignal.timeout(30_000))
@@ -278,7 +391,7 @@ export class HostExecutionRunner {
278
391
  sessionId,
279
392
  requestId: 'task-board-' + crypto.randomUUID(),
280
393
  mode: 'queue' as const,
281
- content: [{ type: 'text' as const, text: promptText(task) }],
394
+ content: [{ type: 'text' as const, text: promptText(task, context) }],
282
395
  })
283
396
  }
284
397
 
@@ -295,6 +408,13 @@ export class HostExecutionRunner {
295
408
  }
296
409
  return { known: false }
297
410
  }
411
+ if (isDefinitionWithdrawn(error)) {
412
+ if (!this.unsupportedSessionListWarned) {
413
+ this.unsupportedSessionListWarned = true
414
+ console.warn('[dsh-task-board] the host gateway withdrew the session/list definition for this DSH cohort; task board roster auto-discovery is disabled', error)
415
+ }
416
+ return { known: false }
417
+ }
298
418
  if (!isServiceUnavailable(error) || attempt >= this.unavailableAttempts) {
299
419
  console.error('[dsh-task-board] session/list failed; treating the host session roster as unknown', error)
300
420
  return { known: false }
@@ -321,6 +441,10 @@ export class HostExecutionRunner {
321
441
  }
322
442
  return { outcome: 'pending' }
323
443
  }
444
+ if (isDefinitionWithdrawn(error)) {
445
+ console.warn('[dsh-task-board] the host gateway withdrew the session/list definition for this DSH cohort; keeping the outcome pending', error)
446
+ return { outcome: 'pending' }
447
+ }
324
448
  console.warn('[dsh-task-board] session/list failed during execution inspection; keeping the outcome pending', error)
325
449
  return { outcome: 'pending' }
326
450
  }
@@ -387,8 +511,13 @@ export class HostExecutionRunner {
387
511
  return { outcome: 'pending' }
388
512
  }
389
513
  this.scanMemos.delete(sessionId)
390
- return isErrorTurnEnd(turnEnd.event.data)
514
+ const reason = nonCompletedTurnEnd(turnEnd.event.data)
515
+ if (reason === undefined) return { outcome: 'succeeded' }
516
+ // `error` keeps its historical wording; every other non-completed reason
517
+ // (aborted / blocked / max-tokens / interrupted) now reports instead of
518
+ // silently counting as success.
519
+ return reason === 'error'
391
520
  ? { outcome: 'failed', error: 'agent turn ended with an error' }
392
- : { outcome: 'succeeded' }
521
+ : { outcome: 'failed', error: 'agent turn ended without completing: ' + reason }
393
522
  }
394
523
  }
@@ -2,11 +2,42 @@ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
2
2
  import { nextRunAtMs } from './core/schedule.ts'
3
3
  import { reusableSessionId } from './core/session-reuse.ts'
4
4
  import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
5
- import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
5
+ import { HostExecutionRunner, SessionLaunchError, promptText, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
6
+ import { teammateName } from './core/subtask.ts'
6
7
  import { PowerInhibitor } from './power-inhibitor.ts'
7
8
  import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
8
9
  import type { TaskPermission } from './core/handover.ts'
9
10
 
11
+ /** One teammate the Host asks the Agent Teams service to spawn for a team run. */
12
+ export interface TeamSpawnInput {
13
+ /** Session id of the run's Team Lead (the root task's execution session). */
14
+ leadSessionId: string
15
+ /** Immutable lower-kebab-case teammate name, unique inside the Team. */
16
+ name: string
17
+ /** Short description of the delegated responsibility. */
18
+ description: string
19
+ /** The subtask's execution prompt. */
20
+ prompt: string
21
+ }
22
+
23
+ /** Result of one teammate spawn attempt. */
24
+ export interface TeamSpawnResult {
25
+ /** The teammate's session id when it reached the active edge. */
26
+ sessionId?: string
27
+ /** Why the spawn failed: a thrown call, or a member that settled as failed. */
28
+ error?: string
29
+ }
30
+
31
+ /**
32
+ * The Agent Teams capability the Host needs for team-mode runs. The plugin
33
+ * builds it from the optional `agentTeams` service; it is absent when this
34
+ * deployment does not serve that service, in which case a team run is refused
35
+ * instead of silently degrading into a plain cascade.
36
+ */
37
+ export interface TaskBoardTeamDispatcher {
38
+ spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>
39
+ }
40
+
10
41
  const SESSION_POLL_MS = 5_000
11
42
  const SCHEDULE_TICK_MS = 30_000
12
43
  const RESUME_GAP_MS = SCHEDULE_TICK_MS + 15_000
@@ -30,6 +61,7 @@ export class TaskBoardHostService {
30
61
  */
31
62
  private idleSessionIds: ReadonlySet<string> | undefined
32
63
  private preventIdleSleep = false
64
+ private readonly team: TaskBoardTeamDispatcher | undefined
33
65
  private lastPowerJson = ''
34
66
  private readonly now: () => number
35
67
 
@@ -40,9 +72,15 @@ export class TaskBoardHostService {
40
72
  commandDispatcher?: SessionCommandDispatcher
41
73
  workspaceRegistry?: TaskBoardWorkspaceRegistry
42
74
  sessionDefaultPermission?: TaskPermission
75
+ maxSubtaskDepth?: number
76
+ team?: TaskBoardTeamDispatcher
43
77
  } = {}) {
44
- this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, { sessionDefaultPermission: options.sessionDefaultPermission })
78
+ this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
79
+ sessionDefaultPermission: options.sessionDefaultPermission,
80
+ maxSubtaskDepth: options.maxSubtaskDepth,
81
+ })
45
82
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
83
+ this.team = options.team
46
84
  this.power = options.power ?? new PowerInhibitor()
47
85
  this.now = options.now ?? Date.now
48
86
  installStreamErrorGuards()
@@ -99,6 +137,8 @@ export class TaskBoardHostService {
99
137
  scheduler: state.scheduler,
100
138
  power: this.power.snapshot(),
101
139
  sessionDefaultPermission: this.ledger.sessionDefaultPermission,
140
+ maxSubtaskDepth: this.ledger.maxSubtaskDepth,
141
+ teamRunAvailable: this.team !== undefined,
102
142
  }
103
143
  }
104
144
 
@@ -115,8 +155,15 @@ export class TaskBoardHostService {
115
155
 
116
156
  apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot {
117
157
  if (!this.active) throw new Error('task board is disabled')
158
+ // Fail closed before the ledger opens anything: a card opted into team
159
+ // execution cannot run in a deployment that serves no Agent Teams service,
160
+ // and silently degrading it to a plain cascade would misreport the work.
161
+ if (this.team === undefined && (action.kind === 'run' || action.kind === 'rerun')) {
162
+ const task = this.ledger.state().tasks.find(item => item.id === action.taskId)
163
+ if (task?.teamRun === true) throw new Error('Agent Teams is unavailable in this deployment')
164
+ }
118
165
  const result = this.ledger.applyRequest(requestId, action, initiator)
119
- if (result.run !== undefined) this.scheduleLaunch(result.run)
166
+ if (result.runs !== undefined) this.dispatchRuns(result.runs)
120
167
  return {
121
168
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
122
169
  revision: result.state.revision,
@@ -134,11 +181,29 @@ export class TaskBoardHostService {
134
181
  this.listeners.clear()
135
182
  }
136
183
 
137
- private async launch(opened: OpenedRun): Promise<void> {
184
+ private async launch(opened: OpenedRun, others: readonly OpenedRun[] = []): Promise<void> {
138
185
  try {
139
- const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds)
140
- const sessionId = await this.runner.launch(opened.task, reuseSessionId === undefined ? {} : { reuseSessionId })
186
+ // A team run always mints a fresh Lead session: teammates are immutable
187
+ // children of that session, so reusing an older one would collide on
188
+ // their names and orphan the previous team.
189
+ const team = opened.task.teamRun === true
190
+ const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
191
+ // Both modes tell the launched agent what else this run opens; only a team
192
+ // run names teammates, because only then does this session own them.
193
+ const promptContext = others.length === 0 ? undefined : {
194
+ peers: others.map(other => ({
195
+ id: other.task.id,
196
+ title: other.task.title,
197
+ ...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id) } : {}),
198
+ })),
199
+ ...(team ? { team: true } : {}),
200
+ }
201
+ const sessionId = await this.runner.launch(opened.task, {
202
+ ...(reuseSessionId === undefined ? {} : { reuseSessionId }),
203
+ ...(promptContext === undefined ? {} : { promptContext }),
204
+ })
141
205
  this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
206
+ if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
142
207
  } catch (error) {
143
208
  if (error instanceof SessionLaunchError) {
144
209
  this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
@@ -147,6 +212,42 @@ export class TaskBoardHostService {
147
212
  }
148
213
  }
149
214
 
215
+ private scheduleTeammate(opened: OpenedRun, leadSessionId: string): void {
216
+ void this.spawnTeammate(opened, leadSessionId).catch(error => {
217
+ safeConsoleError('[dsh-task-board] teammate spawn settlement failed', error)
218
+ })
219
+ }
220
+
221
+ /**
222
+ * Spawn one teammate inside the Lead session and attach the teammate's
223
+ * session to the subtask execution, so the existing session monitor settles
224
+ * it from the teammate's own turn like any other execution. A spawn that
225
+ * fails settles the subtask as failed immediately, which then folds into the
226
+ * Lead's cascade verdict.
227
+ */
228
+ private async spawnTeammate(opened: OpenedRun, leadSessionId: string): Promise<void> {
229
+ const team = this.team
230
+ if (team === undefined) {
231
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
232
+ return
233
+ }
234
+ try {
235
+ const member = await team.spawn({
236
+ leadSessionId,
237
+ name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
238
+ description: opened.task.title,
239
+ prompt: promptText(opened.task),
240
+ })
241
+ if (member.sessionId === undefined || member.sessionId === '') {
242
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
243
+ return
244
+ }
245
+ this.ledger.attachSession(opened.task.id, opened.execution.id, member.sessionId)
246
+ } catch (error) {
247
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
248
+ }
249
+ }
250
+
150
251
  private async pollSessions(): Promise<void> {
151
252
  if (this.disposed) return
152
253
  if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return
@@ -204,8 +305,7 @@ export class TaskBoardHostService {
204
305
  }
205
306
  for (const schedule of this.ledger.dueSchedules(now)) {
206
307
  const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
207
- const opened = this.ledger.openScheduled(schedule.taskId, next, now)
208
- if (opened !== undefined) this.scheduleLaunch(opened)
308
+ this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now))
209
309
  }
210
310
  }
211
311
 
@@ -213,8 +313,25 @@ export class TaskBoardHostService {
213
313
  return this.ledger.armedScheduleCount()
214
314
  }
215
315
 
216
- private scheduleLaunch(opened: OpenedRun): void {
217
- void this.launch(opened).catch(error => {
316
+ /**
317
+ * Launch one run set. The root goes first and receives the run shape in its
318
+ * prompt (which members this run opens, and how they run); every plain-cascade
319
+ * member then launches on its own so one refused participant cannot hold the
320
+ * others back. A team run's members are spawned inside the root's Lead
321
+ * session instead, once that session exists.
322
+ */
323
+ private dispatchRuns(runs: readonly OpenedRun[]): void {
324
+ if (runs.length === 0) return
325
+ const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
326
+ const others = runs.filter(run => run !== root)
327
+ this.scheduleLaunch(root, others)
328
+ for (const run of others) {
329
+ if (run.dispatch !== 'teammate') this.scheduleLaunch(run)
330
+ }
331
+ }
332
+
333
+ private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = []): void {
334
+ void this.launch(opened, others).catch(error => {
218
335
  safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
219
336
  })
220
337
  }