@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +42 -11
- package/README.zh.md +42 -11
- package/lib/client.js +4958 -472
- package/lib/client.js.map +1 -1
- package/lib/index.js +2841 -121
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +103 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +37 -1
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/tasks.d.ts +84 -3
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +52 -8
- package/lib/types/host-runner.d.ts +23 -1
- package/lib/types/host-service.d.ts +88 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +49 -5
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +17 -1
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +71 -26
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +43 -5
- package/src/client/locales.ts +208 -4
- package/src/client/native-panel.tsx +18 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +94 -3
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/store.ts +16 -0
- package/src/core/tasks.ts +100 -6
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +10 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +20 -18
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +143 -32
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +60 -11
- package/src/host-service.ts +253 -10
- package/src/index.ts +204 -16
- package/src/protocol.ts +102 -8
- package/src/tool-surface.ts +95 -0
package/src/host-runner.ts
CHANGED
|
@@ -3,9 +3,13 @@ import type { SessionAddress, SessionHistoryRecord, SessionListValue, SessionPag
|
|
|
3
3
|
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
|
4
4
|
import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
|
|
5
5
|
import { teammateName } from './core/subtask.ts'
|
|
6
|
+
import { mostRecentWorkspaceId } from './core/workspace-target.ts'
|
|
6
7
|
import type { TaskPermission, TaskRecord } from './core/tasks.ts'
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Host services needed to validate a task's workspace before creating a
|
|
11
|
+
* session, and to resolve the workspace an unpinned card runs in.
|
|
12
|
+
*/
|
|
9
13
|
export interface TaskBoardWorkspaceRegistry {
|
|
10
14
|
list(): readonly Workspace[]
|
|
11
15
|
}
|
|
@@ -298,6 +302,8 @@ function nonCompletedTurnEnd(data: unknown): string | undefined {
|
|
|
298
302
|
*/
|
|
299
303
|
function invokeWireArgs(namespace: string, method: string, request: Record<string, unknown>): Record<string, unknown> {
|
|
300
304
|
if (namespace === 'agentPresets' && method === 'list') return {}
|
|
305
|
+
// session/modelCatalog declares zero parameters, so its args must be {}.
|
|
306
|
+
if (namespace === 'session' && method === 'modelCatalog') return {}
|
|
301
307
|
if (namespace === 'session' && method === 'list') return { _request: request }
|
|
302
308
|
return { request }
|
|
303
309
|
}
|
|
@@ -328,6 +334,18 @@ export class HostExecutionRunner {
|
|
|
328
334
|
return this.gateway.stream({ namespace, method, args: { request }, ...(signal === undefined ? {} : { signal }) })
|
|
329
335
|
}
|
|
330
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The workspace an unpinned run lands in: the deployment's most recently used
|
|
339
|
+
* one. Undefined when the deployment serves no workspace registry or has
|
|
340
|
+
* registered none yet; the launch then keeps the "let the Host decide" shape,
|
|
341
|
+
* which is the only remaining path to the Host's own working directory.
|
|
342
|
+
* @returns the workspace id to create the session in, when one is known.
|
|
343
|
+
*/
|
|
344
|
+
private recentWorkspaceId(): string | undefined {
|
|
345
|
+
if (this.workspaceRegistry === undefined) return undefined
|
|
346
|
+
return mostRecentWorkspaceId(this.workspaceRegistry.list())
|
|
347
|
+
}
|
|
348
|
+
|
|
331
349
|
/**
|
|
332
350
|
* Launch one execution. Without `options.reuseSessionId` a fresh session is
|
|
333
351
|
* created, renamed, pinned, and prompted (the historical contract). With it,
|
|
@@ -338,10 +356,14 @@ export class HostExecutionRunner {
|
|
|
338
356
|
* @param options - optional session to continue in.
|
|
339
357
|
* @returns the session id the execution runs in.
|
|
340
358
|
*/
|
|
341
|
-
async launch(task: TaskRecord, options: { reuseSessionId?: string; promptContext?: PromptContext } = {}): Promise<string> {
|
|
359
|
+
async launch(task: TaskRecord, options: { reuseSessionId?: string; promptContext?: PromptContext; onSession?: (sessionId: string) => void; onGoalArmed?: (armed: boolean) => void } = {}): Promise<string> {
|
|
342
360
|
// A handover bundle overrides the legacy pin fields: the bundle is the
|
|
343
|
-
// authoritative execution triplet for a continuation card (issue #5).
|
|
344
|
-
|
|
361
|
+
// authoritative execution triplet for a continuation card (issue #5). A
|
|
362
|
+
// card that pins nothing is resolved to the deployment's most recently used
|
|
363
|
+
// workspace instead of being left to `session.create`, whose own fallback is
|
|
364
|
+
// the Host process working directory (the desktop app's profile directory) —
|
|
365
|
+
// never the project the user was in when the card was written.
|
|
366
|
+
const workspaceId = task.handover?.workspaceId ?? task.workspaceId ?? this.recentWorkspaceId()
|
|
345
367
|
const mode = task.handover?.mode ?? task.mode
|
|
346
368
|
const permission = task.handover?.permission ?? task.permission
|
|
347
369
|
|
|
@@ -369,8 +391,12 @@ export class HostExecutionRunner {
|
|
|
369
391
|
// whose session was composed from a different preset now fails closed
|
|
370
392
|
// instead of silently running under the wrong composition, matching
|
|
371
393
|
// the fresh branch's `agentPreset` assertion (issue #1708).
|
|
394
|
+
// Report the session before anything is queued into it, so the board
|
|
395
|
+
// can bind the execution first and a completion claim can never arrive
|
|
396
|
+
// at the gate without its binding.
|
|
397
|
+
options.onSession?.(reused)
|
|
372
398
|
await this.assertReusedPreset(reused, mode)
|
|
373
|
-
await this.pinAndPrompt(reused, task, permission, options.promptContext)
|
|
399
|
+
await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed)
|
|
374
400
|
} catch (error) {
|
|
375
401
|
throw new SessionLaunchError(reused, error)
|
|
376
402
|
}
|
|
@@ -381,9 +407,10 @@ export class HostExecutionRunner {
|
|
|
381
407
|
...(mode === undefined ? {} : { agentPreset: mode }),
|
|
382
408
|
}) as { sessionId: ExecutionSessionId }
|
|
383
409
|
const sessionId = created.sessionId
|
|
410
|
+
options.onSession?.(sessionId)
|
|
384
411
|
try {
|
|
385
412
|
await this.invoke('session', 'rename', { sessionId, title: task.title })
|
|
386
|
-
await this.pinAndPrompt(sessionId, task, permission, options.promptContext)
|
|
413
|
+
await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed)
|
|
387
414
|
} catch (error) {
|
|
388
415
|
throw new SessionLaunchError(sessionId, error)
|
|
389
416
|
}
|
|
@@ -428,6 +455,7 @@ export class HostExecutionRunner {
|
|
|
428
455
|
task: TaskRecord,
|
|
429
456
|
permission: TaskPermission | undefined,
|
|
430
457
|
context: PromptContext = {},
|
|
458
|
+
onGoalArmed?: (armed: boolean) => void,
|
|
431
459
|
): Promise<void> {
|
|
432
460
|
if (permission !== undefined) {
|
|
433
461
|
if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
|
|
@@ -456,7 +484,25 @@ export class HostExecutionRunner {
|
|
|
456
484
|
mode: 'queue' as const,
|
|
457
485
|
content: [{ type: 'text' as const, text: promptText(task, context) }],
|
|
458
486
|
})
|
|
459
|
-
await this.armGoal(sessionId, task, context)
|
|
487
|
+
const armed = await this.armGoal(sessionId, task, context)
|
|
488
|
+
onGoalArmed?.(armed)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Read the host's model catalog: the default route an unconfigured session
|
|
493
|
+
* starts at, and each provider's models with the reasoning metadata its
|
|
494
|
+
* adapter exposes. The acceptance settings card and the frozen contract both
|
|
495
|
+
* resolve against it, so "inherit host" means the host's own configuration
|
|
496
|
+
* rather than a session's or a card's pinned model.
|
|
497
|
+
* @returns the raw catalog value, or undefined when this cohort serves none.
|
|
498
|
+
*/
|
|
499
|
+
async modelCatalog(): Promise<unknown> {
|
|
500
|
+
try {
|
|
501
|
+
return await this.invoke('session', 'modelCatalog', {})
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.warn('[dsh-task-board] session/modelCatalog failed; the acceptance settings fall back to the configured route', error)
|
|
504
|
+
return undefined
|
|
505
|
+
}
|
|
460
506
|
}
|
|
461
507
|
|
|
462
508
|
/**
|
|
@@ -472,11 +518,11 @@ export class HostExecutionRunner {
|
|
|
472
518
|
* plain turn: the task was asked to run, and a missing goal mode must not
|
|
473
519
|
* lose the work.
|
|
474
520
|
*/
|
|
475
|
-
private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<
|
|
476
|
-
if (task.goalRun === false) return
|
|
521
|
+
private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<boolean> {
|
|
522
|
+
if (task.goalRun === false) return false
|
|
477
523
|
if (this.commands === undefined) {
|
|
478
524
|
console.warn('[dsh-task-board] no command dispatcher is available; task ' + task.id + ' runs without /goal')
|
|
479
|
-
return
|
|
525
|
+
return false
|
|
480
526
|
}
|
|
481
527
|
try {
|
|
482
528
|
const command = await this.commands.execute(
|
|
@@ -486,13 +532,16 @@ export class HostExecutionRunner {
|
|
|
486
532
|
)
|
|
487
533
|
if (command === undefined) {
|
|
488
534
|
console.warn('[dsh-task-board] /goal was not acknowledged for session ' + sessionId + '; the run continues as a plain turn')
|
|
489
|
-
return
|
|
535
|
+
return false
|
|
490
536
|
}
|
|
491
537
|
if (command.kind !== 'success') {
|
|
492
538
|
console.warn('[dsh-task-board] /goal was refused for session ' + sessionId + ': ' + (command.text ?? 'no reason reported') + '; the run continues as a plain turn')
|
|
539
|
+
return false
|
|
493
540
|
}
|
|
541
|
+
return true
|
|
494
542
|
} catch (error) {
|
|
495
543
|
console.warn('[dsh-task-board] could not arm /goal on session ' + sessionId + '; the run continues as a plain turn', error)
|
|
544
|
+
return false
|
|
496
545
|
}
|
|
497
546
|
}
|
|
498
547
|
|
package/src/host-service.ts
CHANGED
|
@@ -4,8 +4,13 @@ import { reusableSessionId } from './core/session-reuse.ts'
|
|
|
4
4
|
import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
|
|
5
5
|
import { HostExecutionRunner, SessionLaunchError, promptText, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
|
|
6
6
|
import { teammateName } from './core/subtask.ts'
|
|
7
|
+
import { workspaceOwningSession } from './core/workspace-target.ts'
|
|
7
8
|
import { PowerInhibitor } from './power-inhibitor.ts'
|
|
8
9
|
import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
|
|
10
|
+
import { TaskBoardExtensionRegistry } from './host/extension-registry.ts'
|
|
11
|
+
import type { TaskBoardExtension } from './core/extension.ts'
|
|
12
|
+
import type { ExecutionOutcome, TaskStatus } from './core/tasks.ts'
|
|
13
|
+
import { passedAttempt, resolveContract, verificationRequired, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
|
|
9
14
|
import type { TaskPermission } from './core/handover.ts'
|
|
10
15
|
|
|
11
16
|
/** One teammate the Host asks the Agent Teams service to spawn for a team run. */
|
|
@@ -129,10 +134,20 @@ export class TaskBoardHostService {
|
|
|
129
134
|
*/
|
|
130
135
|
private readonly unreadablePolls = new Map<string, number>()
|
|
131
136
|
private preventIdleSleep = false
|
|
137
|
+
private readonly verificationSettings: () => VerificationSettings
|
|
138
|
+
private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
|
|
132
139
|
private readonly team: TaskBoardTeamDispatcher | undefined
|
|
133
140
|
private readonly timers: HostTimerFace
|
|
134
141
|
private lastPowerJson = ''
|
|
135
142
|
private readonly now: () => number
|
|
143
|
+
/** The deployment's workspaces, consulted to resolve a card that pins none. */
|
|
144
|
+
private readonly workspaceRegistry: TaskBoardWorkspaceRegistry | undefined
|
|
145
|
+
/**
|
|
146
|
+
* External provider extensions. The board owns task lifecycle and execution;
|
|
147
|
+
* a provider observes them through the registry's capability face and never
|
|
148
|
+
* reaches into the ledger itself.
|
|
149
|
+
*/
|
|
150
|
+
readonly extensions: TaskBoardExtensionRegistry
|
|
136
151
|
|
|
137
152
|
constructor(gateway: TypertGateway, options: {
|
|
138
153
|
ledger?: HostTaskLedger
|
|
@@ -140,20 +155,42 @@ export class TaskBoardHostService {
|
|
|
140
155
|
now?: () => number
|
|
141
156
|
commandDispatcher?: SessionCommandDispatcher
|
|
142
157
|
workspaceRegistry?: TaskBoardWorkspaceRegistry
|
|
143
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Permission baseline of the confirmation gate: a fixed value, or a live
|
|
160
|
+
* resolver the board re-reads so a Host Settings change needs no remount.
|
|
161
|
+
*/
|
|
162
|
+
sessionDefaultPermission?: TaskPermission | (() => TaskPermission)
|
|
144
163
|
maxSubtaskDepth?: number
|
|
145
164
|
team?: TaskBoardTeamDispatcher
|
|
146
165
|
timers?: HostTimerFace
|
|
166
|
+
/**
|
|
167
|
+
* Live acceptance settings (volatile config reads). Absent keeps goal
|
|
168
|
+
* acceptance OFF for every execution this service opens, which is what a
|
|
169
|
+
* programmatic mount without the settings domain gets.
|
|
170
|
+
*/
|
|
171
|
+
verificationSettings?: () => VerificationSettings
|
|
172
|
+
/** The host model catalog the acceptance contract resolves its route from. */
|
|
173
|
+
verificationCatalog?: () => Promise<ModelCatalogView | undefined>
|
|
147
174
|
} = {}) {
|
|
148
175
|
this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
|
|
149
176
|
sessionDefaultPermission: options.sessionDefaultPermission,
|
|
150
177
|
maxSubtaskDepth: options.maxSubtaskDepth,
|
|
151
178
|
})
|
|
152
179
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
|
|
180
|
+
this.workspaceRegistry = options.workspaceRegistry
|
|
153
181
|
this.team = options.team
|
|
154
182
|
this.timers = options.timers ?? PROCESS_TIMERS
|
|
155
183
|
this.power = options.power ?? new PowerInhibitor()
|
|
156
184
|
this.now = options.now ?? Date.now
|
|
185
|
+
this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
|
|
186
|
+
this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
|
|
187
|
+
this.extensions = new TaskBoardExtensionRegistry({
|
|
188
|
+
ledger: this.ledger,
|
|
189
|
+
// Capability writes go through the board's own action path, so every
|
|
190
|
+
// board gate (content freeze, running lock, permission gate) still holds.
|
|
191
|
+
apply: (action, initiator) => this.applyBoardAction(crypto.randomUUID(), action, initiator),
|
|
192
|
+
now: this.now,
|
|
193
|
+
})
|
|
157
194
|
installStreamErrorGuards()
|
|
158
195
|
this.ledger.subscribe(() => {
|
|
159
196
|
this.syncPowerReasons()
|
|
@@ -195,6 +232,9 @@ export class TaskBoardHostService {
|
|
|
195
232
|
})
|
|
196
233
|
}
|
|
197
234
|
this.power.setEnabled(active && preventIdleSleep)
|
|
235
|
+
// Providers follow the board's master switch: the registry stops their
|
|
236
|
+
// provider-side work and hides their seats without clearing any data.
|
|
237
|
+
this.extensions.setEnabled(active)
|
|
198
238
|
if (resumed) {
|
|
199
239
|
this.schedulePoll()
|
|
200
240
|
this.recoverSchedule()
|
|
@@ -208,6 +248,7 @@ export class TaskBoardHostService {
|
|
|
208
248
|
|
|
209
249
|
snapshot(): TaskBoardSnapshot {
|
|
210
250
|
const state = this.ledger.state()
|
|
251
|
+
const published = this.extensions.published()
|
|
211
252
|
return {
|
|
212
253
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
213
254
|
revision: state.revision,
|
|
@@ -217,6 +258,38 @@ export class TaskBoardHostService {
|
|
|
217
258
|
sessionDefaultPermission: this.ledger.sessionDefaultPermission,
|
|
218
259
|
maxSubtaskDepth: this.ledger.maxSubtaskDepth,
|
|
219
260
|
teamRunAvailable: this.team !== undefined,
|
|
261
|
+
...(Object.keys(published).length === 0 ? {} : { extensions: published }),
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Admit one external provider. Idempotent by extension id; the returned
|
|
267
|
+
* disposer releases it (its stored data stays).
|
|
268
|
+
*/
|
|
269
|
+
registerExtension(extension: TaskBoardExtension): () => void {
|
|
270
|
+
return this.extensions.registerExtension(extension)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The acceptance configuration the settings card displays: the live settings,
|
|
275
|
+
* the contract they resolve to, and the host catalog the choices come from.
|
|
276
|
+
*
|
|
277
|
+
* The card shows the RESOLVED route, not the raw configuration, so "inherit
|
|
278
|
+
* host" reads as the concrete model and reasoning level the next execution
|
|
279
|
+
* will actually freeze, including any recorded effort fallback.
|
|
280
|
+
* @returns the resolved acceptance options.
|
|
281
|
+
*/
|
|
282
|
+
async verificationOptions(): Promise<{
|
|
283
|
+
settings: VerificationSettings
|
|
284
|
+
contract: VerificationContract
|
|
285
|
+
catalog: ModelCatalogView
|
|
286
|
+
}> {
|
|
287
|
+
const settings = this.verificationSettings()
|
|
288
|
+
const catalog = await this.verificationCatalog()
|
|
289
|
+
return {
|
|
290
|
+
settings,
|
|
291
|
+
contract: resolveContract(settings, catalog),
|
|
292
|
+
catalog: catalog ?? { groups: [] },
|
|
220
293
|
}
|
|
221
294
|
}
|
|
222
295
|
|
|
@@ -231,8 +304,55 @@ export class TaskBoardHostService {
|
|
|
231
304
|
return () => { this.listeners.delete(listener) }
|
|
232
305
|
}
|
|
233
306
|
|
|
234
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Give a root card created without a workspace pin the workspace its creator
|
|
309
|
+
* is in.
|
|
310
|
+
*
|
|
311
|
+
* An agent's `task_board_create` arrives with the calling session, and the
|
|
312
|
+
* GUI submits the session the main view shows, so the card lands where the
|
|
313
|
+
* work was asked for instead of where the Host process happens to run. A card
|
|
314
|
+
* that names a workspace keeps it, and a subtask keeps inheriting its lineage
|
|
315
|
+
* (the runner walks the ancestor chain for that).
|
|
316
|
+
*
|
|
317
|
+
* The initiator is client-asserted, so it only chooses among workspaces the
|
|
318
|
+
* deployment already knows: it can neither register one nor reach outside the
|
|
319
|
+
* list the user sees.
|
|
320
|
+
* @param action - the action about to be applied.
|
|
321
|
+
* @param initiator - the session that issued the action, when one was asserted.
|
|
322
|
+
* @returns the action, with an inherited workspace on a root creation.
|
|
323
|
+
*/
|
|
324
|
+
private withInheritedWorkspace(action: TaskBoardAction, initiator?: string): TaskBoardAction {
|
|
325
|
+
if (action.kind !== 'create' || initiator === undefined || initiator === '') return action
|
|
326
|
+
const input = action.input
|
|
327
|
+
// A subtask inherits its lineage instead, and an explicit pin is the user's.
|
|
328
|
+
if ((input.parentId ?? '').trim() !== '') return action
|
|
329
|
+
if ((input.workspaceId ?? '').trim() !== '') return action
|
|
330
|
+
const workspaceId = workspaceOwningSession(this.workspaceRegistry?.list() ?? [], initiator)
|
|
331
|
+
if (workspaceId === undefined) return action
|
|
332
|
+
return { ...action, input: { ...input, workspaceId } }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
apply(requestId: string, action: Extract<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): Promise<TaskBoardSnapshot>
|
|
336
|
+
apply(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot
|
|
337
|
+
apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot>
|
|
338
|
+
apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot> {
|
|
235
339
|
if (!this.active) throw new Error('task board is disabled')
|
|
340
|
+
// Provider actions never touch the ledger directly: the registry routes the
|
|
341
|
+
// action to the owning extension, whose own capability calls come back
|
|
342
|
+
// through applyBoardAction and every board gate below.
|
|
343
|
+
if (action.kind === 'extension-action') {
|
|
344
|
+
return this.handleExtensionAction(action)
|
|
345
|
+
}
|
|
346
|
+
return this.applyBoardAction(requestId, action, initiator)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Apply one board-owned action with every gate intact. Provider capability
|
|
351
|
+
* calls and the same-origin wire both land here, so the two surfaces can
|
|
352
|
+
* never disagree about a card.
|
|
353
|
+
*/
|
|
354
|
+
private applyBoardAction(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot {
|
|
355
|
+
const before = this.statusMap()
|
|
236
356
|
// Fail closed before the ledger opens anything: a card opted into team
|
|
237
357
|
// execution cannot run in a deployment that serves no Agent Teams service,
|
|
238
358
|
// and silently degrading it to a plain cascade would misreport the work.
|
|
@@ -240,12 +360,14 @@ export class TaskBoardHostService {
|
|
|
240
360
|
const task = this.ledger.state().tasks.find(item => item.id === action.taskId)
|
|
241
361
|
if (task?.teamRun === true) throw new Error('Agent Teams is unavailable in this deployment')
|
|
242
362
|
}
|
|
243
|
-
const result = this.ledger.applyRequest(requestId, action, initiator)
|
|
363
|
+
const result = this.ledger.applyRequest(requestId, this.withInheritedWorkspace(action, initiator), initiator)
|
|
244
364
|
if (result.runs !== undefined) this.dispatchRuns(result.runs)
|
|
245
365
|
// A committed schedule write (create / update / toggle / delete) moves the
|
|
246
366
|
// nearest trigger; re-arm on every action so a newly enabled schedule fires
|
|
247
367
|
// at its own instant without waiting for the previous target to elapse.
|
|
248
368
|
if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
|
|
369
|
+
this.emitStatusChanges(before, initiator)
|
|
370
|
+
if (action.kind === 'delete') this.extensions.emitTaskDeleted({ taskId: action.taskId })
|
|
249
371
|
return {
|
|
250
372
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
251
373
|
revision: result.state.revision,
|
|
@@ -255,8 +377,39 @@ export class TaskBoardHostService {
|
|
|
255
377
|
}
|
|
256
378
|
}
|
|
257
379
|
|
|
380
|
+
/** Route one provider action and answer the resulting board snapshot. */
|
|
381
|
+
private async handleExtensionAction(action: Extract<TaskBoardAction, { kind: 'extension-action' }>): Promise<TaskBoardSnapshot> {
|
|
382
|
+
await this.extensions.handleAction({
|
|
383
|
+
extensionId: action.extensionId,
|
|
384
|
+
action: action.action,
|
|
385
|
+
...(action.taskId === undefined ? {} : { taskId: action.taskId }),
|
|
386
|
+
...(action.payload === undefined ? {} : { payload: action.payload }),
|
|
387
|
+
})
|
|
388
|
+
return this.snapshot()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Current task id -> status map, for change detection around a mutation. */
|
|
392
|
+
private statusMap(): Map<string, TaskStatus> {
|
|
393
|
+
return new Map(this.ledger.allTasks().map(task => [task.id, task.status]))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Emit a status change for every task whose column moved, isolated per callback. */
|
|
397
|
+
private emitStatusChanges(before: Map<string, TaskStatus>, initiator?: string): void {
|
|
398
|
+
for (const task of this.ledger.allTasks()) {
|
|
399
|
+
const previous = before.get(task.id)
|
|
400
|
+
if (previous === undefined || previous === task.status) continue
|
|
401
|
+
this.extensions.emitStatusChanged({
|
|
402
|
+
taskId: task.id,
|
|
403
|
+
status: task.status,
|
|
404
|
+
previous,
|
|
405
|
+
...(initiator === undefined || initiator === '' ? {} : { initiator }),
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
258
410
|
dispose(): void {
|
|
259
411
|
this.disposed = true
|
|
412
|
+
this.extensions.dispose()
|
|
260
413
|
this.clearScheduleTimer()
|
|
261
414
|
this.pollTimer?.()
|
|
262
415
|
this.pollTimer = undefined
|
|
@@ -286,17 +439,48 @@ export class TaskBoardHostService {
|
|
|
286
439
|
...(team ? { team: true } : {}),
|
|
287
440
|
...(schedule === undefined ? {} : { schedule }),
|
|
288
441
|
}
|
|
442
|
+
// Freeze this execution's acceptance contract BEFORE the session is
|
|
443
|
+
// prompted: a settings change made while the run is in flight must not
|
|
444
|
+
// retrofit the rule that will judge it. A card that opted out resolves the
|
|
445
|
+
// same contract from an OFF switch: the board-wide switch decides the
|
|
446
|
+
// default, the card decides this execution, and both are read here.
|
|
447
|
+
const settings = this.verificationSettings()
|
|
448
|
+
// The card's opt-out is a DISTINCT reason, not the board switch: the
|
|
449
|
+
// report must be able to say which of the two turned the gate off.
|
|
450
|
+
const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
|
|
451
|
+
const contract = resolveContract(
|
|
452
|
+
{ ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
|
|
453
|
+
await this.verificationCatalog(),
|
|
454
|
+
)
|
|
455
|
+
const initial: ExecutionVerification = {
|
|
456
|
+
contract,
|
|
457
|
+
attempts: [],
|
|
458
|
+
applicability: team ? 'team-member' : !contract.enabled ? skippedBy : 'goal-unavailable',
|
|
459
|
+
}
|
|
460
|
+
this.ledger.setVerification(opened.task.id, opened.execution.id, initial)
|
|
461
|
+
let attached: string | undefined
|
|
289
462
|
const sessionId = await this.runner.launch(opened.task, {
|
|
290
463
|
...(reuseSessionId === undefined ? {} : { reuseSessionId }),
|
|
291
464
|
...(promptContext === undefined ? {} : { promptContext }),
|
|
465
|
+
// Bind the session to the execution before the prompt is queued, so the
|
|
466
|
+
// completion gate can never observe the agent without its binding.
|
|
467
|
+
onSession: (id) => {
|
|
468
|
+
attached = id
|
|
469
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, id)
|
|
470
|
+
},
|
|
471
|
+
onGoalArmed: (armed) => {
|
|
472
|
+
this.setApplicability(opened.task.id, opened.execution.id, team
|
|
473
|
+
? 'team-member'
|
|
474
|
+
: !contract.enabled ? skippedBy : armed ? 'enforced' : 'goal-unavailable')
|
|
475
|
+
},
|
|
292
476
|
})
|
|
293
|
-
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
477
|
+
if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
294
478
|
if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
|
|
295
479
|
} catch (error) {
|
|
296
480
|
if (error instanceof SessionLaunchError) {
|
|
297
481
|
this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
|
|
298
482
|
}
|
|
299
|
-
this.
|
|
483
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
300
484
|
}
|
|
301
485
|
}
|
|
302
486
|
|
|
@@ -316,7 +500,7 @@ export class TaskBoardHostService {
|
|
|
316
500
|
private async spawnTeammate(opened: OpenedRun, leadSessionId: string): Promise<void> {
|
|
317
501
|
const team = this.team
|
|
318
502
|
if (team === undefined) {
|
|
319
|
-
this.
|
|
503
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
|
|
320
504
|
return
|
|
321
505
|
}
|
|
322
506
|
try {
|
|
@@ -327,12 +511,12 @@ export class TaskBoardHostService {
|
|
|
327
511
|
prompt: promptText(opened.task),
|
|
328
512
|
})
|
|
329
513
|
if (member.sessionId === undefined || member.sessionId === '') {
|
|
330
|
-
this.
|
|
514
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
|
|
331
515
|
return
|
|
332
516
|
}
|
|
333
517
|
this.ledger.attachSession(opened.task.id, opened.execution.id, member.sessionId)
|
|
334
518
|
} catch (error) {
|
|
335
|
-
this.
|
|
519
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
336
520
|
}
|
|
337
521
|
}
|
|
338
522
|
|
|
@@ -354,7 +538,9 @@ export class TaskBoardHostService {
|
|
|
354
538
|
// Fold whatever the board can already decide before spending inspection
|
|
355
539
|
// RPCs: a team run whose Lead recorded its verdict, and any lineage whose
|
|
356
540
|
// members are all settled. Idempotent, so an already folded board is free.
|
|
541
|
+
const foldedBefore = this.statusMap()
|
|
357
542
|
this.ledger.finalizeReadyRuns()
|
|
543
|
+
this.emitStatusChanges(foldedBefore)
|
|
358
544
|
// Read after the RPC so executions attached while it was in flight are
|
|
359
545
|
// included in this pass, matching the former full-state snapshot timing.
|
|
360
546
|
const runtime = this.ledger.runtimeView()
|
|
@@ -375,6 +561,16 @@ export class TaskBoardHostService {
|
|
|
375
561
|
): Promise<void> {
|
|
376
562
|
for (const execution of executions) {
|
|
377
563
|
if (execution.sessionId === undefined) continue
|
|
564
|
+
const record = this.ledger.getTask(execution.taskId)?.executions.find(entry => entry.id === execution.executionId)
|
|
565
|
+
const verification = record?.verification
|
|
566
|
+
// A cycle the gate already closed is decided here, without another
|
|
567
|
+
// inspection: the goal may still be active (or un-blockable) exactly
|
|
568
|
+
// because the acceptance failed, and the card must not stay in the
|
|
569
|
+
// running column waiting for a verdict that is already recorded.
|
|
570
|
+
if (verification?.failedReason !== undefined) {
|
|
571
|
+
this.settleAndNotify(execution.taskId, execution.executionId, 'failed', verification.failedReason)
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
378
574
|
try {
|
|
379
575
|
// A team member's turn is read even while the roster calls its session
|
|
380
576
|
// running: a durable teammate never goes idle for good.
|
|
@@ -387,7 +583,20 @@ export class TaskBoardHostService {
|
|
|
387
583
|
continue
|
|
388
584
|
}
|
|
389
585
|
this.unreadablePolls.delete(execution.executionId)
|
|
390
|
-
|
|
586
|
+
// Forced acceptance: the old fallback paths — a completed turn, a
|
|
587
|
+
// paused goal, an unreadable projection, a manual settle — must never
|
|
588
|
+
// be mistaken for a verified success. Only a matching pass record
|
|
589
|
+
// settles this execution as succeeded.
|
|
590
|
+
if (result.outcome === 'succeeded' && verificationRequired(verification) && passedAttempt(verification) === undefined) {
|
|
591
|
+
this.settleAndNotify(
|
|
592
|
+
execution.taskId,
|
|
593
|
+
execution.executionId,
|
|
594
|
+
'failed',
|
|
595
|
+
'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。',
|
|
596
|
+
)
|
|
597
|
+
continue
|
|
598
|
+
}
|
|
599
|
+
this.settleAndNotify(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
|
|
391
600
|
} catch {
|
|
392
601
|
// A transient inspection failure never settles a running execution.
|
|
393
602
|
}
|
|
@@ -407,6 +616,28 @@ export class TaskBoardHostService {
|
|
|
407
616
|
* ancestor of it — in the running column with no way out. The first poll of
|
|
408
617
|
* each streak is logged, so the Host log names the session.
|
|
409
618
|
*/
|
|
619
|
+
private settleAndNotify(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
|
|
620
|
+
const before = this.ledger.getTask(taskId)
|
|
621
|
+
if (before?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) {
|
|
622
|
+
// Already settled: the ledger would no-op, and re-emitting would report
|
|
623
|
+
// the same settlement twice.
|
|
624
|
+
this.ledger.settle(taskId, executionId, outcome, error)
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
const previousStatus = before?.status
|
|
628
|
+
this.ledger.settle(taskId, executionId, outcome, error)
|
|
629
|
+
const task = this.ledger.getTask(taskId)
|
|
630
|
+
if (task !== undefined && previousStatus !== undefined && previousStatus !== task.status) {
|
|
631
|
+
this.extensions.emitStatusChanged({ taskId, status: task.status, previous: previousStatus })
|
|
632
|
+
}
|
|
633
|
+
this.extensions.emitExecutionSettled({
|
|
634
|
+
taskId,
|
|
635
|
+
executionId,
|
|
636
|
+
outcome,
|
|
637
|
+
...(error === undefined ? {} : { error }),
|
|
638
|
+
})
|
|
639
|
+
}
|
|
640
|
+
|
|
410
641
|
private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
|
|
411
642
|
const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1
|
|
412
643
|
this.unreadablePolls.set(execution.executionId, polls)
|
|
@@ -417,7 +648,7 @@ export class TaskBoardHostService {
|
|
|
417
648
|
}
|
|
418
649
|
if (polls < UNREADABLE_SETTLE_POLLS) return
|
|
419
650
|
this.unreadablePolls.delete(execution.executionId)
|
|
420
|
-
this.
|
|
651
|
+
this.settleAndNotify(
|
|
421
652
|
execution.taskId,
|
|
422
653
|
execution.executionId,
|
|
423
654
|
'failed',
|
|
@@ -484,6 +715,7 @@ export class TaskBoardHostService {
|
|
|
484
715
|
this.recoverSchedule()
|
|
485
716
|
return
|
|
486
717
|
}
|
|
718
|
+
const before = this.statusMap()
|
|
487
719
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
488
720
|
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone)
|
|
489
721
|
this.dispatchRuns(
|
|
@@ -491,6 +723,7 @@ export class TaskBoardHostService {
|
|
|
491
723
|
{ triggeredAt: now, timeZone: schedule.timeZone, cron: schedule.cron },
|
|
492
724
|
)
|
|
493
725
|
}
|
|
726
|
+
this.emitStatusChanges(before)
|
|
494
727
|
// The launched run (or the rolled-forward target) moved every due schedule,
|
|
495
728
|
// so the next nearest target has to be recomputed from the ledger.
|
|
496
729
|
this.armSchedule()
|
|
@@ -517,6 +750,16 @@ export class TaskBoardHostService {
|
|
|
517
750
|
}
|
|
518
751
|
}
|
|
519
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Move one execution's applicability without touching its frozen contract or
|
|
755
|
+
* its recorded attempts (used when the run's goal form becomes known).
|
|
756
|
+
*/
|
|
757
|
+
private setApplicability(taskId: string, executionId: string, applicability: ExecutionVerification['applicability']): void {
|
|
758
|
+
const verification = this.ledger.getTask(taskId)?.executions.find(entry => entry.id === executionId)?.verification
|
|
759
|
+
if (verification === undefined || verification.applicability === applicability) return
|
|
760
|
+
this.ledger.setVerification(taskId, executionId, { ...verification, applicability })
|
|
761
|
+
}
|
|
762
|
+
|
|
520
763
|
private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
|
|
521
764
|
void this.launch(opened, others, schedule).catch(error => {
|
|
522
765
|
safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
|