@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.
- package/README.i18n.yaml +2 -2
- package/README.md +24 -0
- package/README.zh.md +24 -0
- package/lib/client.js +917 -81
- package/lib/client.js.map +1 -1
- package/lib/index.js +1774 -1420
- package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
- package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
- package/lib/types/client/board/NewTaskModal.d.ts +7 -1
- package/lib/types/client/board/TaskCard.d.ts +11 -1
- package/lib/types/client/locales.d.ts +33 -0
- package/lib/types/core/controller.d.ts +30 -1
- package/lib/types/core/subtask.d.ts +122 -0
- package/lib/types/core/tasks.d.ts +46 -3
- package/lib/types/core/use-cases/task-archive.d.ts +10 -13
- package/lib/types/core/use-cases/task-create.d.ts +13 -4
- package/lib/types/core/use-cases/task-delete.d.ts +7 -1
- package/lib/types/core/use-cases/task-parent.d.ts +21 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/agent-tools.d.ts +27 -0
- package/lib/types/host-ledger.d.ts +88 -4
- package/lib/types/host-runner.d.ts +39 -1
- package/lib/types/host-service.d.ts +47 -0
- package/lib/types/index.d.ts +83 -5
- package/lib/types/protocol.d.ts +14 -0
- package/package.json +23 -21
- package/src/client/TaskBoardSettingsCard.tsx +48 -3
- package/src/client/board/LinkSubtaskModal.tsx +82 -0
- package/src/client/board/NewTaskModal.tsx +40 -6
- package/src/client/board/TaskBoard.tsx +88 -8
- package/src/client/board/TaskCard.tsx +52 -2
- package/src/client/board/TaskDetail.tsx +124 -1
- package/src/client/board.module.css +96 -0
- package/src/client/host-api.ts +14 -4
- package/src/client/index.ts +11 -1
- package/src/client/locales.ts +70 -2
- package/src/core/controller.ts +69 -8
- package/src/core/store.ts +15 -0
- package/src/core/subtask.ts +286 -0
- package/src/core/tasks.ts +50 -2
- package/src/core/use-cases/task-archive.ts +46 -12
- package/src/core/use-cases/task-create.ts +42 -7
- package/src/core/use-cases/task-delete.ts +11 -1
- package/src/core/use-cases/task-parent.ts +64 -0
- package/src/core/use-cases/task-update.ts +3 -1
- package/src/host/agent-tools.ts +643 -0
- package/src/host-ledger.ts +323 -45
- package/src/host-runner.ts +141 -12
- package/src/host-service.ts +127 -10
- package/src/index.ts +181 -9
- package/src/protocol.ts +26 -2
package/src/host-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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: '
|
|
521
|
+
: { outcome: 'failed', error: 'agent turn ended without completing: ' + reason }
|
|
393
522
|
}
|
|
394
523
|
}
|
package/src/host-service.ts
CHANGED
|
@@ -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, {
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
}
|