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

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 (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +28 -3
  3. package/README.zh.md +28 -3
  4. package/lib/client.js +1142 -467
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2066 -1475
  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/index.d.ts +15 -1
  12. package/lib/types/client/locales.d.ts +33 -0
  13. package/lib/types/client/native-panel.d.ts +62 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +71 -3
  16. package/lib/types/core/subtask.d.ts +122 -0
  17. package/lib/types/core/tasks.d.ts +46 -3
  18. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  19. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  20. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  21. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/agent-tools.d.ts +27 -0
  24. package/lib/types/host-ledger.d.ts +97 -4
  25. package/lib/types/host-runner.d.ts +39 -1
  26. package/lib/types/host-service.d.ts +98 -5
  27. package/lib/types/index.d.ts +93 -5
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +14 -0
  30. package/package.json +26 -21
  31. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  32. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  33. package/src/client/board/NewTaskModal.tsx +40 -6
  34. package/src/client/board/TaskBoard.tsx +88 -8
  35. package/src/client/board/TaskCard.tsx +52 -2
  36. package/src/client/board/TaskDetail.tsx +124 -1
  37. package/src/client/board.module.css +112 -131
  38. package/src/client/host-api.ts +14 -4
  39. package/src/client/index.ts +56 -18
  40. package/src/client/locales.ts +70 -2
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/settings-entry-form.ts +142 -0
  43. package/src/core/controller.ts +128 -10
  44. package/src/core/store.ts +15 -0
  45. package/src/core/subtask.ts +286 -0
  46. package/src/core/tasks.ts +50 -2
  47. package/src/core/use-cases/task-archive.ts +46 -12
  48. package/src/core/use-cases/task-create.ts +42 -7
  49. package/src/core/use-cases/task-delete.ts +11 -1
  50. package/src/core/use-cases/task-parent.ts +64 -0
  51. package/src/core/use-cases/task-update.ts +3 -1
  52. package/src/host/agent-tools.ts +643 -0
  53. package/src/host-ledger.ts +343 -45
  54. package/src/host-routes.ts +37 -1
  55. package/src/host-runner.ts +141 -12
  56. package/src/host-service.ts +265 -32
  57. package/src/index.ts +203 -9
  58. package/src/mount-once.ts +107 -13
  59. package/src/protocol.ts +26 -2
  60. package/lib/types/client/board-mount.d.ts +0 -23
  61. package/lib/types/client/body-mutations.d.ts +0 -45
  62. package/lib/types/client/panel-mount-core.d.ts +0 -56
  63. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  64. package/lib/types/client/sidebar-entry.d.ts +0 -29
  65. package/src/client/board-mount.tsx +0 -43
  66. package/src/client/body-mutations.ts +0 -133
  67. package/src/client/panel-mount-core.ts +0 -155
  68. package/src/client/sidebar-entry-core.ts +0 -237
  69. package/src/client/sidebar-entry.ts +0 -53
@@ -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,25 +2,98 @@ 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
+
41
+ /** Session-roster poll cadence — the one recurring Host timer this service still holds. */
10
42
  const SESSION_POLL_MS = 5_000
11
- const SCHEDULE_TICK_MS = 30_000
12
- const RESUME_GAP_MS = SCHEDULE_TICK_MS + 15_000
43
+ /**
44
+ * How late an armed schedule fire may be before it counts as a resume rather
45
+ * than a normal occurrence. The schedule timer is armed AT the next due
46
+ * instant, so landing this far past its target means the Host was suspended,
47
+ * the process throttled, or the wall clock jumped forward — the same condition
48
+ * the old fixed 30 s heartbeat detected through its own gap threshold.
49
+ */
50
+ const RECOVERY_TOLERANCE_MS = 60_000
51
+ /** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
52
+ const MAX_TIMER_DELAY_MS = 2_147_483_647
53
+
54
+ /**
55
+ * Actions that can move an armed trigger. Only these re-arm the native timer;
56
+ * an unrelated card edit leaves the pending fire untouched.
57
+ */
58
+ const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['set-schedule', 'delete', 'archive'])
59
+
60
+ /**
61
+ * The native timer face the Host arms through. The cordis `timer` service
62
+ * (dsh-base's own `cordis-plugin-timer` row) provides it: its handles are
63
+ * registered on the owning fiber, so unloading the board clears every armed
64
+ * timer without this service tracking handles by hand. A composition that
65
+ * serves no timer service falls back to the process globals.
66
+ */
67
+ export interface HostTimerFace {
68
+ timeout(callback: () => void, delay: number): () => void
69
+ interval(callback: () => void, delay: number): () => void
70
+ }
71
+
72
+ /** Process-global fallback used when the deployment serves no cordis timer service. */
73
+ const PROCESS_TIMERS: HostTimerFace = {
74
+ timeout(callback: () => void, delay: number): () => void {
75
+ const handle = setTimeout(callback, delay)
76
+ return () => { clearTimeout(handle) }
77
+ },
78
+ interval(callback: () => void, delay: number): () => void {
79
+ const handle = setInterval(callback, delay)
80
+ return () => { clearInterval(handle) }
81
+ },
82
+ }
13
83
 
14
84
  export class TaskBoardHostService {
15
85
  readonly ledger: HostTaskLedger
16
86
  readonly runner: HostExecutionRunner
17
87
  readonly power: PowerInhibitor
18
88
  private readonly listeners = new Set<() => void>()
19
- private timers: Array<ReturnType<typeof setInterval>> = []
20
- private lastScheduleTick: number | undefined
89
+ /** The one recurring timer: the session-roster poll. */
90
+ private pollTimer: (() => void) | undefined
91
+ /** The armed schedule timer, if a trigger is pending. */
92
+ private scheduleTimer: (() => void) | undefined
93
+ /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
94
+ private scheduleTarget: number | undefined
21
95
  private disposed = false
22
96
  private pollInFlight = false
23
- private tickInFlight = false
24
97
  private active = true
25
98
  /**
26
99
  * Ids the last roster poll saw as present and idle; undefined while the
@@ -30,6 +103,8 @@ export class TaskBoardHostService {
30
103
  */
31
104
  private idleSessionIds: ReadonlySet<string> | undefined
32
105
  private preventIdleSleep = false
106
+ private readonly team: TaskBoardTeamDispatcher | undefined
107
+ private readonly timers: HostTimerFace
33
108
  private lastPowerJson = ''
34
109
  private readonly now: () => number
35
110
 
@@ -40,9 +115,17 @@ export class TaskBoardHostService {
40
115
  commandDispatcher?: SessionCommandDispatcher
41
116
  workspaceRegistry?: TaskBoardWorkspaceRegistry
42
117
  sessionDefaultPermission?: TaskPermission
118
+ maxSubtaskDepth?: number
119
+ team?: TaskBoardTeamDispatcher
120
+ timers?: HostTimerFace
43
121
  } = {}) {
44
- this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, { sessionDefaultPermission: options.sessionDefaultPermission })
122
+ this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
123
+ sessionDefaultPermission: options.sessionDefaultPermission,
124
+ maxSubtaskDepth: options.maxSubtaskDepth,
125
+ })
45
126
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
127
+ this.team = options.team
128
+ this.timers = options.timers ?? PROCESS_TIMERS
46
129
  this.power = options.power ?? new PowerInhibitor()
47
130
  this.now = options.now ?? Date.now
48
131
  installStreamErrorGuards()
@@ -62,12 +145,15 @@ export class TaskBoardHostService {
62
145
  }
63
146
 
64
147
  start(): void {
65
- if (this.disposed || this.timers.length > 0) return
148
+ if (this.disposed || this.pollTimer !== undefined) return
66
149
  this.syncPowerReasons()
67
- this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS))
68
- this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
150
+ this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, SESSION_POLL_MS)
69
151
  this.schedulePoll()
70
- this.scheduleTick(true)
152
+ // Boot is a recovery point: an occurrence armed while the Host was down is
153
+ // not replayed, and each schedule rolls to its next future target. A
154
+ // schedule the Board should have served while running is then armed
155
+ // normally by the timer below.
156
+ this.recoverSchedule()
71
157
  }
72
158
 
73
159
  setConfiguration(active: boolean, preventIdleSleep: boolean): void {
@@ -85,7 +171,11 @@ export class TaskBoardHostService {
85
171
  this.power.setEnabled(active && preventIdleSleep)
86
172
  if (resumed) {
87
173
  this.schedulePoll()
88
- this.scheduleTick(true)
174
+ this.recoverSchedule()
175
+ } else if (!active) {
176
+ // A disabled board holds no timer: its schedules must not fire while the
177
+ // master switch is off.
178
+ this.clearScheduleTimer()
89
179
  }
90
180
  this.emit()
91
181
  }
@@ -99,6 +189,8 @@ export class TaskBoardHostService {
99
189
  scheduler: state.scheduler,
100
190
  power: this.power.snapshot(),
101
191
  sessionDefaultPermission: this.ledger.sessionDefaultPermission,
192
+ maxSubtaskDepth: this.ledger.maxSubtaskDepth,
193
+ teamRunAvailable: this.team !== undefined,
102
194
  }
103
195
  }
104
196
 
@@ -115,8 +207,19 @@ export class TaskBoardHostService {
115
207
 
116
208
  apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot {
117
209
  if (!this.active) throw new Error('task board is disabled')
210
+ // Fail closed before the ledger opens anything: a card opted into team
211
+ // execution cannot run in a deployment that serves no Agent Teams service,
212
+ // and silently degrading it to a plain cascade would misreport the work.
213
+ if (this.team === undefined && (action.kind === 'run' || action.kind === 'rerun')) {
214
+ const task = this.ledger.state().tasks.find(item => item.id === action.taskId)
215
+ if (task?.teamRun === true) throw new Error('Agent Teams is unavailable in this deployment')
216
+ }
118
217
  const result = this.ledger.applyRequest(requestId, action, initiator)
119
- if (result.run !== undefined) this.scheduleLaunch(result.run)
218
+ if (result.runs !== undefined) this.dispatchRuns(result.runs)
219
+ // A committed schedule write (create / update / toggle / delete) moves the
220
+ // nearest trigger; re-arm on every action so a newly enabled schedule fires
221
+ // at its own instant without waiting for the previous target to elapse.
222
+ if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
120
223
  return {
121
224
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
122
225
  revision: result.state.revision,
@@ -128,17 +231,37 @@ export class TaskBoardHostService {
128
231
 
129
232
  dispose(): void {
130
233
  this.disposed = true
131
- for (const timer of this.timers.splice(0)) clearInterval(timer)
234
+ this.clearScheduleTimer()
235
+ this.pollTimer?.()
236
+ this.pollTimer = undefined
132
237
  this.power.dispose()
133
238
  this.ledger.dispose()
134
239
  this.listeners.clear()
135
240
  }
136
241
 
137
- private async launch(opened: OpenedRun): Promise<void> {
242
+ private async launch(opened: OpenedRun, others: readonly OpenedRun[] = []): Promise<void> {
138
243
  try {
139
- const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds)
140
- const sessionId = await this.runner.launch(opened.task, reuseSessionId === undefined ? {} : { reuseSessionId })
244
+ // A team run always mints a fresh Lead session: teammates are immutable
245
+ // children of that session, so reusing an older one would collide on
246
+ // their names and orphan the previous team.
247
+ const team = opened.task.teamRun === true
248
+ const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
249
+ // Both modes tell the launched agent what else this run opens; only a team
250
+ // run names teammates, because only then does this session own them.
251
+ const promptContext = others.length === 0 ? undefined : {
252
+ peers: others.map(other => ({
253
+ id: other.task.id,
254
+ title: other.task.title,
255
+ ...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id) } : {}),
256
+ })),
257
+ ...(team ? { team: true } : {}),
258
+ }
259
+ const sessionId = await this.runner.launch(opened.task, {
260
+ ...(reuseSessionId === undefined ? {} : { reuseSessionId }),
261
+ ...(promptContext === undefined ? {} : { promptContext }),
262
+ })
141
263
  this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
264
+ if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
142
265
  } catch (error) {
143
266
  if (error instanceof SessionLaunchError) {
144
267
  this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
@@ -147,6 +270,42 @@ export class TaskBoardHostService {
147
270
  }
148
271
  }
149
272
 
273
+ private scheduleTeammate(opened: OpenedRun, leadSessionId: string): void {
274
+ void this.spawnTeammate(opened, leadSessionId).catch(error => {
275
+ safeConsoleError('[dsh-task-board] teammate spawn settlement failed', error)
276
+ })
277
+ }
278
+
279
+ /**
280
+ * Spawn one teammate inside the Lead session and attach the teammate's
281
+ * session to the subtask execution, so the existing session monitor settles
282
+ * it from the teammate's own turn like any other execution. A spawn that
283
+ * fails settles the subtask as failed immediately, which then folds into the
284
+ * Lead's cascade verdict.
285
+ */
286
+ private async spawnTeammate(opened: OpenedRun, leadSessionId: string): Promise<void> {
287
+ const team = this.team
288
+ if (team === undefined) {
289
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
290
+ return
291
+ }
292
+ try {
293
+ const member = await team.spawn({
294
+ leadSessionId,
295
+ name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
296
+ description: opened.task.title,
297
+ prompt: promptText(opened.task),
298
+ })
299
+ if (member.sessionId === undefined || member.sessionId === '') {
300
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
301
+ return
302
+ }
303
+ this.ledger.attachSession(opened.task.id, opened.execution.id, member.sessionId)
304
+ } catch (error) {
305
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
306
+ }
307
+ }
308
+
150
309
  private async pollSessions(): Promise<void> {
151
310
  if (this.disposed) return
152
311
  if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return
@@ -192,29 +351,97 @@ export class TaskBoardHostService {
192
351
  }
193
352
  }
194
353
 
195
- private async tickSchedule(first: boolean): Promise<void> {
354
+ /** Drop the armed schedule timer and forget its target. */
355
+ private clearScheduleTimer(): void {
356
+ this.scheduleTimer?.()
357
+ this.scheduleTimer = undefined
358
+ this.scheduleTarget = undefined
359
+ }
360
+
361
+ /**
362
+ * Boot / resume recovery: skip every occurrence that came due while the
363
+ * board was not running and roll each schedule to its next future target,
364
+ * then arm the timer for the nearest one. Rendering the occurrence is
365
+ * deliberately not attempted: the ACL of a card that fired hours ago is
366
+ * stale, and the board's own recovery contract is "missed triggers are
367
+ * skipped, never replayed".
368
+ */
369
+ private recoverSchedule(): void {
370
+ if (this.disposed) return
371
+ this.clearScheduleTimer()
372
+ const now = this.now()
373
+ this.ledger.setScheduler({ lastTickAt: now })
374
+ this.ledger.skipMissed(now)
375
+ this.armSchedule()
376
+ }
377
+
378
+ /**
379
+ * Arm the native timer at the nearest armed future trigger. One timer serves
380
+ * every schedule: the ledger's next target is the only instant the Host has
381
+ * to wake for. A target beyond the platform's timer ceiling re-arms in
382
+ * segments, and a target already past (the wall clock jumped, or the process
383
+ * was suspended) is handled immediately as a recovery.
384
+ */
385
+ private armSchedule(): void {
386
+ if (this.disposed || !this.active) return
387
+ this.clearScheduleTimer()
388
+ const target = this.ledger.nextArmedRunAt(this.now())
389
+ if (target === undefined) return
390
+ this.scheduleTarget = target
391
+ const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS))
392
+ this.scheduleTimer = this.timers.timeout(() => {
393
+ this.scheduleTimer = undefined
394
+ this.onScheduleFire(target)
395
+ }, delay)
396
+ }
397
+
398
+ /**
399
+ * One armed target became due. A fire landing well past its target is a
400
+ * resume (suspend, throttle, forward clock jump) rather than a normal
401
+ * occurrence, so it takes the recovery path instead of launching a run for a
402
+ * long-stale instant.
403
+ */
404
+ private onScheduleFire(target: number): void {
196
405
  if (this.disposed || !this.active) return
197
406
  const now = this.now()
198
- const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS)
199
- this.lastScheduleTick = now
407
+ this.scheduleTarget = undefined
200
408
  this.ledger.setScheduler({ lastTickAt: now })
201
- if (recovered) {
202
- this.ledger.skipMissed(now)
409
+ if (now - target > RECOVERY_TOLERANCE_MS) {
410
+ this.recoverSchedule()
203
411
  return
204
412
  }
205
413
  for (const schedule of this.ledger.dueSchedules(now)) {
206
414
  const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
207
- const opened = this.ledger.openScheduled(schedule.taskId, next, now)
208
- if (opened !== undefined) this.scheduleLaunch(opened)
415
+ this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now))
209
416
  }
417
+ // The launched run (or the rolled-forward target) moved every due schedule,
418
+ // so the next nearest target has to be recomputed from the ledger.
419
+ this.armSchedule()
210
420
  }
211
421
 
212
422
  private armedSchedules(): number {
213
423
  return this.ledger.armedScheduleCount()
214
424
  }
215
425
 
216
- private scheduleLaunch(opened: OpenedRun): void {
217
- void this.launch(opened).catch(error => {
426
+ /**
427
+ * Launch one run set. The root goes first and receives the run shape in its
428
+ * prompt (which members this run opens, and how they run); every plain-cascade
429
+ * member then launches on its own so one refused participant cannot hold the
430
+ * others back. A team run's members are spawned inside the root's Lead
431
+ * session instead, once that session exists.
432
+ */
433
+ private dispatchRuns(runs: readonly OpenedRun[]): void {
434
+ if (runs.length === 0) return
435
+ const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
436
+ const others = runs.filter(run => run !== root)
437
+ this.scheduleLaunch(root, others)
438
+ for (const run of others) {
439
+ if (run.dispatch !== 'teammate') this.scheduleLaunch(run)
440
+ }
441
+ }
442
+
443
+ private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = []): void {
444
+ void this.launch(opened, others).catch(error => {
218
445
  safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
219
446
  })
220
447
  }
@@ -227,12 +454,18 @@ export class TaskBoardHostService {
227
454
  }).finally(() => { this.pollInFlight = false })
228
455
  }
229
456
 
230
- private scheduleTick(first: boolean): void {
231
- if (this.tickInFlight || this.disposed) return
232
- this.tickInFlight = true
233
- void this.tickSchedule(first).catch(error => {
234
- safeConsoleError('[dsh-task-board] scheduler tick failed', error)
235
- }).finally(() => { this.tickInFlight = false })
457
+ /**
458
+ * Re-arm from the ledger's current targets. Callers that just changed a
459
+ * schedule (the host routes, the agent tools) invoke this after the write
460
+ * commits, so a new or edited trigger arms without waiting for the next fire.
461
+ */
462
+ refreshSchedule(): void {
463
+ if (this.disposed) return
464
+ if (!this.active) {
465
+ this.clearScheduleTimer()
466
+ return
467
+ }
468
+ this.armSchedule()
236
469
  }
237
470
 
238
471
  private syncPowerReasons(): void {