@linxin666/dsh-client-ui-task-board 0.4.3 → 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 +56 -18
- package/README.zh.md +56 -18
- package/lib/client.js +6242 -958
- package/lib/client.js.map +1 -1
- package/lib/index.js +3475 -234
- 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 +23 -2
- 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/preset-label.d.ts +22 -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/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +141 -3
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/schedule-zone.d.ts +47 -0
- 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 +38 -1
- package/lib/types/core/degraded-reason.d.ts +46 -0
- 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/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +110 -5
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- 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 +107 -11
- package/lib/types/host-runner.d.ts +99 -4
- package/lib/types/host-service.d.ts +114 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +53 -3
- 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 +30 -15
- package/src/client/board/NewTaskModal.tsx +288 -151
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +238 -86
- package/src/client/board/TaskDetail.tsx +154 -59
- package/src/client/board/TaskForm.tsx +61 -6
- 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/preset-label.ts +44 -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 +602 -68
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +48 -6
- package/src/client/locales.ts +290 -12
- package/src/client/native-panel.tsx +18 -2
- package/src/client/schedule-zone.ts +135 -0
- 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 +101 -5
- package/src/core/degraded-reason.ts +81 -0
- 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/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +26 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +138 -10
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +58 -24
- 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 +342 -51
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +219 -20
- package/src/host-service.ts +346 -26
- package/src/index.ts +204 -16
- package/src/protocol.ts +122 -9
- package/src/tool-surface.ts +95 -0
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
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. */
|
|
@@ -50,6 +55,26 @@ const SESSION_POLL_MS = 5_000
|
|
|
50
55
|
const RECOVERY_TOLERANCE_MS = 60_000
|
|
51
56
|
/** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
|
|
52
57
|
const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
58
|
+
/**
|
|
59
|
+
* Consecutive polls that may report one execution's session history as
|
|
60
|
+
* unreadable before it is reported failed. The roster poll runs every 5 s, so
|
|
61
|
+
* this is two minutes of a session that is NOT running: no turn is executing
|
|
62
|
+
* there and its history cannot be read, which means no verdict will ever
|
|
63
|
+
* arrive. Reporting that as a failure keeps a card — and every ancestor of it —
|
|
64
|
+
* out of the running column, which nothing else can rescue.
|
|
65
|
+
*/
|
|
66
|
+
const UNREADABLE_SETTLE_POLLS = 24
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Provenance of one cron-triggered cascade: when the rule fired and the zone
|
|
70
|
+
* its wall clock was read in. Passed into every launched prompt of that run so
|
|
71
|
+
* a scheduled job knows its own clock rather than inferring one.
|
|
72
|
+
*/
|
|
73
|
+
export interface ScheduledRunContext {
|
|
74
|
+
triggeredAt: number
|
|
75
|
+
timeZone: string
|
|
76
|
+
cron: string
|
|
77
|
+
}
|
|
53
78
|
|
|
54
79
|
/**
|
|
55
80
|
* Actions that can move an armed trigger. Only these re-arm the native timer;
|
|
@@ -102,11 +127,27 @@ export class TaskBoardHostService {
|
|
|
102
127
|
* conversation instead of prompting into a session it cannot see.
|
|
103
128
|
*/
|
|
104
129
|
private idleSessionIds: ReadonlySet<string> | undefined
|
|
130
|
+
/**
|
|
131
|
+
* Consecutive unreadable-history polls per open execution (see
|
|
132
|
+
* {@link noteUnreadableInspection}). Cleared as soon as an inspection
|
|
133
|
+
* resolves, so a transient reader failure never fails a card.
|
|
134
|
+
*/
|
|
135
|
+
private readonly unreadablePolls = new Map<string, number>()
|
|
105
136
|
private preventIdleSleep = false
|
|
137
|
+
private readonly verificationSettings: () => VerificationSettings
|
|
138
|
+
private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
|
|
106
139
|
private readonly team: TaskBoardTeamDispatcher | undefined
|
|
107
140
|
private readonly timers: HostTimerFace
|
|
108
141
|
private lastPowerJson = ''
|
|
109
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
|
|
110
151
|
|
|
111
152
|
constructor(gateway: TypertGateway, options: {
|
|
112
153
|
ledger?: HostTaskLedger
|
|
@@ -114,20 +155,42 @@ export class TaskBoardHostService {
|
|
|
114
155
|
now?: () => number
|
|
115
156
|
commandDispatcher?: SessionCommandDispatcher
|
|
116
157
|
workspaceRegistry?: TaskBoardWorkspaceRegistry
|
|
117
|
-
|
|
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)
|
|
118
163
|
maxSubtaskDepth?: number
|
|
119
164
|
team?: TaskBoardTeamDispatcher
|
|
120
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>
|
|
121
174
|
} = {}) {
|
|
122
175
|
this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
|
|
123
176
|
sessionDefaultPermission: options.sessionDefaultPermission,
|
|
124
177
|
maxSubtaskDepth: options.maxSubtaskDepth,
|
|
125
178
|
})
|
|
126
179
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
|
|
180
|
+
this.workspaceRegistry = options.workspaceRegistry
|
|
127
181
|
this.team = options.team
|
|
128
182
|
this.timers = options.timers ?? PROCESS_TIMERS
|
|
129
183
|
this.power = options.power ?? new PowerInhibitor()
|
|
130
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
|
+
})
|
|
131
194
|
installStreamErrorGuards()
|
|
132
195
|
this.ledger.subscribe(() => {
|
|
133
196
|
this.syncPowerReasons()
|
|
@@ -169,6 +232,9 @@ export class TaskBoardHostService {
|
|
|
169
232
|
})
|
|
170
233
|
}
|
|
171
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)
|
|
172
238
|
if (resumed) {
|
|
173
239
|
this.schedulePoll()
|
|
174
240
|
this.recoverSchedule()
|
|
@@ -182,6 +248,7 @@ export class TaskBoardHostService {
|
|
|
182
248
|
|
|
183
249
|
snapshot(): TaskBoardSnapshot {
|
|
184
250
|
const state = this.ledger.state()
|
|
251
|
+
const published = this.extensions.published()
|
|
185
252
|
return {
|
|
186
253
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
187
254
|
revision: state.revision,
|
|
@@ -191,6 +258,38 @@ export class TaskBoardHostService {
|
|
|
191
258
|
sessionDefaultPermission: this.ledger.sessionDefaultPermission,
|
|
192
259
|
maxSubtaskDepth: this.ledger.maxSubtaskDepth,
|
|
193
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: [] },
|
|
194
293
|
}
|
|
195
294
|
}
|
|
196
295
|
|
|
@@ -205,8 +304,55 @@ export class TaskBoardHostService {
|
|
|
205
304
|
return () => { this.listeners.delete(listener) }
|
|
206
305
|
}
|
|
207
306
|
|
|
208
|
-
|
|
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> {
|
|
209
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()
|
|
210
356
|
// Fail closed before the ledger opens anything: a card opted into team
|
|
211
357
|
// execution cannot run in a deployment that serves no Agent Teams service,
|
|
212
358
|
// and silently degrading it to a plain cascade would misreport the work.
|
|
@@ -214,12 +360,14 @@ export class TaskBoardHostService {
|
|
|
214
360
|
const task = this.ledger.state().tasks.find(item => item.id === action.taskId)
|
|
215
361
|
if (task?.teamRun === true) throw new Error('Agent Teams is unavailable in this deployment')
|
|
216
362
|
}
|
|
217
|
-
const result = this.ledger.applyRequest(requestId, action, initiator)
|
|
363
|
+
const result = this.ledger.applyRequest(requestId, this.withInheritedWorkspace(action, initiator), initiator)
|
|
218
364
|
if (result.runs !== undefined) this.dispatchRuns(result.runs)
|
|
219
365
|
// A committed schedule write (create / update / toggle / delete) moves the
|
|
220
366
|
// nearest trigger; re-arm on every action so a newly enabled schedule fires
|
|
221
367
|
// at its own instant without waiting for the previous target to elapse.
|
|
222
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 })
|
|
223
371
|
return {
|
|
224
372
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
225
373
|
revision: result.state.revision,
|
|
@@ -229,8 +377,39 @@ export class TaskBoardHostService {
|
|
|
229
377
|
}
|
|
230
378
|
}
|
|
231
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
|
+
|
|
232
410
|
dispose(): void {
|
|
233
411
|
this.disposed = true
|
|
412
|
+
this.extensions.dispose()
|
|
234
413
|
this.clearScheduleTimer()
|
|
235
414
|
this.pollTimer?.()
|
|
236
415
|
this.pollTimer = undefined
|
|
@@ -239,7 +418,7 @@ export class TaskBoardHostService {
|
|
|
239
418
|
this.listeners.clear()
|
|
240
419
|
}
|
|
241
420
|
|
|
242
|
-
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = []): Promise<void> {
|
|
421
|
+
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
|
|
243
422
|
try {
|
|
244
423
|
// A team run always mints a fresh Lead session: teammates are immutable
|
|
245
424
|
// children of that session, so reusing an older one would collide on
|
|
@@ -248,25 +427,60 @@ export class TaskBoardHostService {
|
|
|
248
427
|
const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
|
|
249
428
|
// Both modes tell the launched agent what else this run opens; only a team
|
|
250
429
|
// run names teammates, because only then does this session own them.
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
430
|
+
const peers = others.length === 0 ? undefined : others.map(other => ({
|
|
431
|
+
id: other.task.id,
|
|
432
|
+
title: other.task.title,
|
|
433
|
+
...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id, other.task.id) } : {}),
|
|
434
|
+
}))
|
|
435
|
+
// A cron-triggered run additionally states its own firing instant and
|
|
436
|
+
// rule zone, so a scheduled job can resolve "today" without guessing.
|
|
437
|
+
const promptContext = peers === undefined && schedule === undefined ? undefined : {
|
|
438
|
+
...(peers === undefined ? {} : { peers }),
|
|
257
439
|
...(team ? { team: true } : {}),
|
|
440
|
+
...(schedule === undefined ? {} : { schedule }),
|
|
258
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
|
|
259
462
|
const sessionId = await this.runner.launch(opened.task, {
|
|
260
463
|
...(reuseSessionId === undefined ? {} : { reuseSessionId }),
|
|
261
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
|
+
},
|
|
262
476
|
})
|
|
263
|
-
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
477
|
+
if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
264
478
|
if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
|
|
265
479
|
} catch (error) {
|
|
266
480
|
if (error instanceof SessionLaunchError) {
|
|
267
481
|
this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
|
|
268
482
|
}
|
|
269
|
-
this.
|
|
483
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
270
484
|
}
|
|
271
485
|
}
|
|
272
486
|
|
|
@@ -286,23 +500,23 @@ export class TaskBoardHostService {
|
|
|
286
500
|
private async spawnTeammate(opened: OpenedRun, leadSessionId: string): Promise<void> {
|
|
287
501
|
const team = this.team
|
|
288
502
|
if (team === undefined) {
|
|
289
|
-
this.
|
|
503
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
|
|
290
504
|
return
|
|
291
505
|
}
|
|
292
506
|
try {
|
|
293
507
|
const member = await team.spawn({
|
|
294
508
|
leadSessionId,
|
|
295
|
-
name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
|
|
509
|
+
name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id, opened.task.id),
|
|
296
510
|
description: opened.task.title,
|
|
297
511
|
prompt: promptText(opened.task),
|
|
298
512
|
})
|
|
299
513
|
if (member.sessionId === undefined || member.sessionId === '') {
|
|
300
|
-
this.
|
|
514
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
|
|
301
515
|
return
|
|
302
516
|
}
|
|
303
517
|
this.ledger.attachSession(opened.task.id, opened.execution.id, member.sessionId)
|
|
304
518
|
} catch (error) {
|
|
305
|
-
this.
|
|
519
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
306
520
|
}
|
|
307
521
|
}
|
|
308
522
|
|
|
@@ -321,6 +535,12 @@ export class TaskBoardHostService {
|
|
|
321
535
|
return
|
|
322
536
|
}
|
|
323
537
|
this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
|
|
538
|
+
// Fold whatever the board can already decide before spending inspection
|
|
539
|
+
// RPCs: a team run whose Lead recorded its verdict, and any lineage whose
|
|
540
|
+
// members are all settled. Idempotent, so an already folded board is free.
|
|
541
|
+
const foldedBefore = this.statusMap()
|
|
542
|
+
this.ledger.finalizeReadyRuns()
|
|
543
|
+
this.emitStatusChanges(foldedBefore)
|
|
324
544
|
// Read after the RPC so executions attached while it was in flight are
|
|
325
545
|
// included in this pass, matching the former full-state snapshot timing.
|
|
326
546
|
const runtime = this.ledger.runtimeView()
|
|
@@ -341,14 +561,99 @@ export class TaskBoardHostService {
|
|
|
341
561
|
): Promise<void> {
|
|
342
562
|
for (const execution of executions) {
|
|
343
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
|
+
}
|
|
344
574
|
try {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
this.
|
|
575
|
+
// A team member's turn is read even while the roster calls its session
|
|
576
|
+
// running: a durable teammate never goes idle for good.
|
|
577
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions, {
|
|
578
|
+
whileRunning: execution.teamMember,
|
|
579
|
+
})
|
|
580
|
+
if (result.outcome === 'pending') {
|
|
581
|
+
if (result.unreadable === true) this.noteUnreadableInspection(execution, result.reason)
|
|
582
|
+
else this.unreadablePolls.delete(execution.executionId)
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
this.unreadablePolls.delete(execution.executionId)
|
|
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)
|
|
348
600
|
} catch {
|
|
349
601
|
// A transient inspection failure never settles a running execution.
|
|
350
602
|
}
|
|
351
603
|
}
|
|
604
|
+
const open = new Set(executions.map(execution => execution.executionId))
|
|
605
|
+
for (const executionId of [...this.unreadablePolls.keys()]) {
|
|
606
|
+
if (!open.has(executionId)) this.unreadablePolls.delete(executionId)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Count one poll whose session history could not be read. A reader failure is
|
|
612
|
+
* not progress: the session is not running (the runner only reads history for
|
|
613
|
+
* one that is idle), so nothing will ever change that verdict. After
|
|
614
|
+
* {@link UNREADABLE_SETTLE_POLLS} consecutive polls the execution is reported
|
|
615
|
+
* failed with the recorded reason, instead of holding its card — and every
|
|
616
|
+
* ancestor of it — in the running column with no way out. The first poll of
|
|
617
|
+
* each streak is logged, so the Host log names the session.
|
|
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
|
+
|
|
641
|
+
private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
|
|
642
|
+
const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1
|
|
643
|
+
this.unreadablePolls.set(execution.executionId, polls)
|
|
644
|
+
const detail = reason ?? 'no reason reported'
|
|
645
|
+
if (polls === 1) {
|
|
646
|
+
safeConsoleError('[dsh-task-board] execution session ' + (execution.sessionId ?? 'unknown')
|
|
647
|
+
+ ' history is unreadable; it stays pending for up to ' + UNREADABLE_SETTLE_POLLS + ' polls: ' + detail)
|
|
648
|
+
}
|
|
649
|
+
if (polls < UNREADABLE_SETTLE_POLLS) return
|
|
650
|
+
this.unreadablePolls.delete(execution.executionId)
|
|
651
|
+
this.settleAndNotify(
|
|
652
|
+
execution.taskId,
|
|
653
|
+
execution.executionId,
|
|
654
|
+
'failed',
|
|
655
|
+
'execution session history is unreadable (' + UNREADABLE_SETTLE_POLLS + ' consecutive polls); the outcome cannot be determined: ' + detail,
|
|
656
|
+
)
|
|
352
657
|
}
|
|
353
658
|
|
|
354
659
|
/** Drop the armed schedule timer and forget its target. */
|
|
@@ -410,10 +715,15 @@ export class TaskBoardHostService {
|
|
|
410
715
|
this.recoverSchedule()
|
|
411
716
|
return
|
|
412
717
|
}
|
|
718
|
+
const before = this.statusMap()
|
|
413
719
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
414
|
-
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
415
|
-
this.dispatchRuns(
|
|
720
|
+
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone)
|
|
721
|
+
this.dispatchRuns(
|
|
722
|
+
this.ledger.openScheduled(schedule.taskId, next, now),
|
|
723
|
+
{ triggeredAt: now, timeZone: schedule.timeZone, cron: schedule.cron },
|
|
724
|
+
)
|
|
416
725
|
}
|
|
726
|
+
this.emitStatusChanges(before)
|
|
417
727
|
// The launched run (or the rolled-forward target) moved every due schedule,
|
|
418
728
|
// so the next nearest target has to be recomputed from the ledger.
|
|
419
729
|
this.armSchedule()
|
|
@@ -430,18 +740,28 @@ export class TaskBoardHostService {
|
|
|
430
740
|
* others back. A team run's members are spawned inside the root's Lead
|
|
431
741
|
* session instead, once that session exists.
|
|
432
742
|
*/
|
|
433
|
-
private dispatchRuns(runs: readonly OpenedRun[]): void {
|
|
743
|
+
private dispatchRuns(runs: readonly OpenedRun[], schedule?: ScheduledRunContext): void {
|
|
434
744
|
if (runs.length === 0) return
|
|
435
745
|
const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
|
|
436
746
|
const others = runs.filter(run => run !== root)
|
|
437
|
-
this.scheduleLaunch(root, others)
|
|
747
|
+
this.scheduleLaunch(root, others, schedule)
|
|
438
748
|
for (const run of others) {
|
|
439
|
-
if (run.dispatch !== 'teammate') this.scheduleLaunch(run)
|
|
749
|
+
if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule)
|
|
440
750
|
}
|
|
441
751
|
}
|
|
442
752
|
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
|
|
763
|
+
private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
|
|
764
|
+
void this.launch(opened, others, schedule).catch(error => {
|
|
445
765
|
safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
|
|
446
766
|
})
|
|
447
767
|
}
|