@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.
- package/README.i18n.yaml +2 -2
- package/README.md +28 -3
- package/README.zh.md +28 -3
- package/lib/client.js +1142 -467
- package/lib/client.js.map +1 -1
- package/lib/index.js +2066 -1475
- 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/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +33 -0
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +71 -3
- 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 +97 -4
- package/lib/types/host-runner.d.ts +39 -1
- package/lib/types/host-service.d.ts +98 -5
- package/lib/types/index.d.ts +93 -5
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +14 -0
- package/package.json +26 -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 +112 -131
- package/src/client/host-api.ts +14 -4
- package/src/client/index.ts +56 -18
- package/src/client/locales.ts +70 -2
- package/src/client/native-panel.tsx +124 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +128 -10
- 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 +343 -45
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +141 -12
- package/src/host-service.ts +265 -32
- package/src/index.ts +203 -9
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +26 -2
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
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,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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
20
|
-
private
|
|
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, {
|
|
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.
|
|
148
|
+
if (this.disposed || this.pollTimer !== undefined) return
|
|
66
149
|
this.syncPowerReasons()
|
|
67
|
-
this.timers.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
this.lastScheduleTick = now
|
|
407
|
+
this.scheduleTarget = undefined
|
|
200
408
|
this.ledger.setScheduler({ lastTickAt: now })
|
|
201
|
-
if (
|
|
202
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 {
|