@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6
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 +59 -20
- package/README.zh.md +59 -20
- package/lib/client.js +6208 -624
- package/lib/client.js.map +1 -1
- package/lib/index.js +4774 -493
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -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/index.d.ts +23 -5
- package/lib/types/client/locales.d.ts +168 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -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 +46 -11
- 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/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +228 -17
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- 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-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +753 -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 +95 -0
- package/lib/types/host/verification-runner.d.ts +139 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +133 -14
- package/lib/types/host-runner.d.ts +55 -10
- package/lib/types/host-service.d.ts +205 -11
- package/lib/types/index.d.ts +134 -26
- package/lib/types/protocol.d.ts +92 -10
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +28 -31
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +378 -66
- 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 +168 -21
- 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 +289 -43
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +322 -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 +30 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +82 -51
- package/src/client/locales.ts +343 -6
- package/src/client/native-panel.tsx +18 -2
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -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 +105 -10
- 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/poll-cadence.ts +41 -0
- package/src/core/store.ts +80 -15
- package/src/core/tasks.ts +293 -45
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -7
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +1594 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +125 -38
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +487 -0
- package/src/host/verification-runner.ts +702 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +468 -71
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +106 -24
- package/src/host-service.ts +546 -40
- package/src/index.ts +305 -21
- package/src/protocol.ts +226 -28
- package/src/tool-surface.ts +95 -0
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- package/src/invariant.ts +0 -4
package/src/host-service.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
|
2
|
-
import {
|
|
2
|
+
import { DEFAULT_SESSION_POLL_SECONDS, normalizeSessionPollSeconds, sessionPollMs } from './core/poll-cadence.ts'
|
|
3
3
|
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, ScheduleMode, TaskRecord, TaskStatus } from './core/tasks.ts'
|
|
13
|
+
import { passedAttempt, resolveContract, verificationNeverInvoked, verificationOnlyInvalid, verificationRequired, withAcceptanceDetailCleared, 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. */
|
|
@@ -38,8 +43,8 @@ export interface TaskBoardTeamDispatcher {
|
|
|
38
43
|
spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
/**
|
|
42
|
-
const
|
|
46
|
+
/** Ceiling for the roster-poll failure backoff: a broken session tree is retried at most once a minute. */
|
|
47
|
+
const POLL_FAILURE_MAX_BACKOFF_MS = 60_000
|
|
43
48
|
/**
|
|
44
49
|
* How late an armed schedule fire may be before it counts as a resume rather
|
|
45
50
|
* than a normal occurrence. The schedule timer is armed AT the next due
|
|
@@ -60,6 +65,31 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
|
60
65
|
*/
|
|
61
66
|
const UNREADABLE_SETTLE_POLLS = 24
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Why an enforced goal execution settled failed while its acceptance DID run:
|
|
70
|
+
* attempts were recorded and none of them passed. The judge answered about the
|
|
71
|
+
* work and the work did not pass.
|
|
72
|
+
*/
|
|
73
|
+
export const NO_MATCHING_PASS_VERIFICATION_REASON = 'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。'
|
|
74
|
+
/**
|
|
75
|
+
* Why an enforced goal execution settled failed with ZERO acceptance records:
|
|
76
|
+
* the gate was never opened, so the judge never ran and nothing was ever judged
|
|
77
|
+
* (issue #1837). The root cause is a session that declared completion in prose
|
|
78
|
+
* instead of calling `update_goal(action: complete)` — a tool-adherence problem,
|
|
79
|
+
* NOT a quality verdict on the delivery — so this reason says so and keeps the
|
|
80
|
+
* two investigation directions apart.
|
|
81
|
+
*/
|
|
82
|
+
export const NEVER_INVOKED_VERIFICATION_REASON = 'goal 验收未触发:本次执行没有任何验收记录,验收门从未打开——执行会话没有调用 update_goal(action: complete) 来完成目标(常见于只在回复里宣告完成)。这不是质量判负:交付从未被裁判评估。'
|
|
83
|
+
/**
|
|
84
|
+
* Why an enforced goal execution settled failed while its ONLY acceptance
|
|
85
|
+
* records are INVALID: the judge answered but could not locate its own
|
|
86
|
+
* rejection, so the board stopped calling it. This is NOT an evidence-backed
|
|
87
|
+
* quality veto — the requirement is explicit that such a run must not be
|
|
88
|
+
* described as one — but the execution still has no matching pass, so it ends
|
|
89
|
+
* for lack of one, and the reason names that precisely.
|
|
90
|
+
*/
|
|
91
|
+
export const INVALID_ONLY_VERIFICATION_REASON = 'goal 验收无效:本次执行只记录了无效验收(裁判未能给出与任务要求对应、且可定位到本次证据的否决依据),看板已停止调用裁判。这**不是**有证据的质量判负:验收本身不可用,本次运行因没有匹配的通过记录而结束。请检查验收环境与证据可见性,或用 task_board_manage(action=reset-verification) 清除记录的无效验收后重新完成目标。'
|
|
92
|
+
|
|
63
93
|
/**
|
|
64
94
|
* Provenance of one cron-triggered cascade: when the rule fired and the zone
|
|
65
95
|
* its wall clock was read in. Passed into every launched prompt of that run so
|
|
@@ -68,7 +98,10 @@ const UNREADABLE_SETTLE_POLLS = 24
|
|
|
68
98
|
export interface ScheduledRunContext {
|
|
69
99
|
triggeredAt: number
|
|
70
100
|
timeZone: string
|
|
71
|
-
|
|
101
|
+
/** Whether this fire came from a recurring rule or a single planned instant. */
|
|
102
|
+
mode: ScheduleMode
|
|
103
|
+
/** Cron expression of a recurring rule; absent on a one-shot. */
|
|
104
|
+
cron?: string
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
/**
|
|
@@ -108,6 +141,12 @@ export class TaskBoardHostService {
|
|
|
108
141
|
private readonly listeners = new Set<() => void>()
|
|
109
142
|
/** The one recurring timer: the session-roster poll. */
|
|
110
143
|
private pollTimer: (() => void) | undefined
|
|
144
|
+
/** Cadence of the roster poll, in seconds (a settings field; see the config schema). */
|
|
145
|
+
private sessionPollSeconds = DEFAULT_SESSION_POLL_SECONDS
|
|
146
|
+
/** Pending delay that follows a failed poll pass, if one is armed. */
|
|
147
|
+
private pollBackoffTimer: (() => void) | undefined
|
|
148
|
+
/** Consecutive failed poll passes; doubles the next retry delay. */
|
|
149
|
+
private consecutivePollFailures = 0
|
|
111
150
|
/** The armed schedule timer, if a trigger is pending. */
|
|
112
151
|
private scheduleTimer: (() => void) | undefined
|
|
113
152
|
/** The instant the armed schedule timer targets (ms epoch), for resume detection. */
|
|
@@ -115,13 +154,6 @@ export class TaskBoardHostService {
|
|
|
115
154
|
private disposed = false
|
|
116
155
|
private pollInFlight = false
|
|
117
156
|
private active = true
|
|
118
|
-
/**
|
|
119
|
-
* Ids the last roster poll saw as present and idle; undefined while the
|
|
120
|
-
* roster is unknown. Session reuse (issue #1419) requires this positive
|
|
121
|
-
* evidence, so a launch before the first successful poll mints a fresh
|
|
122
|
-
* conversation instead of prompting into a session it cannot see.
|
|
123
|
-
*/
|
|
124
|
-
private idleSessionIds: ReadonlySet<string> | undefined
|
|
125
157
|
/**
|
|
126
158
|
* Consecutive unreadable-history polls per open execution (see
|
|
127
159
|
* {@link noteUnreadableInspection}). Cleared as soon as an inspection
|
|
@@ -129,10 +161,22 @@ export class TaskBoardHostService {
|
|
|
129
161
|
*/
|
|
130
162
|
private readonly unreadablePolls = new Map<string, number>()
|
|
131
163
|
private preventIdleSleep = false
|
|
164
|
+
private readonly verificationSettings: () => VerificationSettings
|
|
165
|
+
private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
|
|
132
166
|
private readonly team: TaskBoardTeamDispatcher | undefined
|
|
133
167
|
private readonly timers: HostTimerFace
|
|
168
|
+
/** Live reader of the global native-/goal switch; frozen per execution. */
|
|
169
|
+
private readonly goalRunEnabled: () => boolean
|
|
134
170
|
private lastPowerJson = ''
|
|
135
171
|
private readonly now: () => number
|
|
172
|
+
/** The deployment's workspaces, consulted to resolve a card that pins none. */
|
|
173
|
+
private readonly workspaceRegistry: TaskBoardWorkspaceRegistry | undefined
|
|
174
|
+
/**
|
|
175
|
+
* External provider extensions. The board owns task lifecycle and execution;
|
|
176
|
+
* a provider observes them through the registry's capability face and never
|
|
177
|
+
* reaches into the ledger itself.
|
|
178
|
+
*/
|
|
179
|
+
readonly extensions: TaskBoardExtensionRegistry
|
|
136
180
|
|
|
137
181
|
constructor(gateway: TypertGateway, options: {
|
|
138
182
|
ledger?: HostTaskLedger
|
|
@@ -140,20 +184,51 @@ export class TaskBoardHostService {
|
|
|
140
184
|
now?: () => number
|
|
141
185
|
commandDispatcher?: SessionCommandDispatcher
|
|
142
186
|
workspaceRegistry?: TaskBoardWorkspaceRegistry
|
|
143
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Permission baseline of the confirmation gate: a fixed value, or a live
|
|
189
|
+
* resolver the board re-reads so a Host Settings change needs no remount.
|
|
190
|
+
*/
|
|
191
|
+
sessionDefaultPermission?: TaskPermission | (() => TaskPermission)
|
|
144
192
|
maxSubtaskDepth?: number
|
|
145
193
|
team?: TaskBoardTeamDispatcher
|
|
146
194
|
timers?: HostTimerFace
|
|
195
|
+
/**
|
|
196
|
+
* Live GLOBAL native /goal switch (a volatile config read). Read ONCE per
|
|
197
|
+
* execution, at launch, and carried by value from then on: a settings edit
|
|
198
|
+
* made while a run is in flight must not arm, disarm or otherwise change the
|
|
199
|
+
* goal/acceptance contract of that run. Absent means OFF, which is the
|
|
200
|
+
* default for every deployment that never configured it.
|
|
201
|
+
*/
|
|
202
|
+
goalRunEnabled?: () => boolean
|
|
203
|
+
/**
|
|
204
|
+
* Live acceptance settings (volatile config reads). Absent keeps goal
|
|
205
|
+
* acceptance OFF for every execution this service opens, which is what a
|
|
206
|
+
* programmatic mount without the settings domain gets.
|
|
207
|
+
*/
|
|
208
|
+
verificationSettings?: () => VerificationSettings
|
|
209
|
+
/** The host model catalog the acceptance contract resolves its route from. */
|
|
210
|
+
verificationCatalog?: () => Promise<ModelCatalogView | undefined>
|
|
147
211
|
} = {}) {
|
|
148
212
|
this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
|
|
149
213
|
sessionDefaultPermission: options.sessionDefaultPermission,
|
|
150
214
|
maxSubtaskDepth: options.maxSubtaskDepth,
|
|
151
215
|
})
|
|
152
216
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
|
|
217
|
+
this.workspaceRegistry = options.workspaceRegistry
|
|
153
218
|
this.team = options.team
|
|
154
219
|
this.timers = options.timers ?? PROCESS_TIMERS
|
|
155
220
|
this.power = options.power ?? new PowerInhibitor()
|
|
156
221
|
this.now = options.now ?? Date.now
|
|
222
|
+
this.goalRunEnabled = options.goalRunEnabled ?? (() => false)
|
|
223
|
+
this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
|
|
224
|
+
this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
|
|
225
|
+
this.extensions = new TaskBoardExtensionRegistry({
|
|
226
|
+
ledger: this.ledger,
|
|
227
|
+
// Capability writes go through the board's own action path, so every
|
|
228
|
+
// board gate (content freeze, running lock, permission gate) still holds.
|
|
229
|
+
apply: (action, initiator) => this.applyBoardAction(crypto.randomUUID(), action, initiator),
|
|
230
|
+
now: this.now,
|
|
231
|
+
})
|
|
157
232
|
installStreamErrorGuards()
|
|
158
233
|
this.ledger.subscribe(() => {
|
|
159
234
|
this.syncPowerReasons()
|
|
@@ -173,8 +248,11 @@ export class TaskBoardHostService {
|
|
|
173
248
|
start(): void {
|
|
174
249
|
if (this.disposed || this.pollTimer !== undefined) return
|
|
175
250
|
this.syncPowerReasons()
|
|
176
|
-
this.pollTimer = this.timers.interval(() => { this.schedulePoll() },
|
|
251
|
+
this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, sessionPollMs(this.sessionPollSeconds))
|
|
177
252
|
this.schedulePoll()
|
|
253
|
+
// Boot is also the cleanup catch-up point: an execution that passed before
|
|
254
|
+
// the Host stopped may still hold its detail (a crash, a failed write).
|
|
255
|
+
this.cleanupPendingAcceptanceDetail()
|
|
178
256
|
// Boot is a recovery point: an occurrence armed while the Host was down is
|
|
179
257
|
// not replayed, and each schedule rolls to its next future target. A
|
|
180
258
|
// schedule the Board should have served while running is then armed
|
|
@@ -182,10 +260,30 @@ export class TaskBoardHostService {
|
|
|
182
260
|
this.recoverSchedule()
|
|
183
261
|
}
|
|
184
262
|
|
|
185
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Apply the live configuration. The roster cadence is re-read on every
|
|
265
|
+
* commit, so a settings edit takes effect without a restart; the poll is
|
|
266
|
+
* scheduled at the new cadence immediately rather than waiting out the old
|
|
267
|
+
* interval.
|
|
268
|
+
* @param active - the board's master switch.
|
|
269
|
+
* @param preventIdleSleep - whether the board holds an idle-sleep assertion.
|
|
270
|
+
* @param sessionPollSeconds - roster-poll cadence in seconds (clamped).
|
|
271
|
+
*/
|
|
272
|
+
setConfiguration(active: boolean, preventIdleSleep: boolean, sessionPollSeconds?: number): void {
|
|
186
273
|
const resumed = !this.active && active
|
|
187
274
|
this.active = active
|
|
188
275
|
this.preventIdleSleep = preventIdleSleep
|
|
276
|
+
const cadence = normalizeSessionPollSeconds(sessionPollSeconds)
|
|
277
|
+
const retimed = this.pollTimer !== undefined && cadence !== this.sessionPollSeconds
|
|
278
|
+
this.sessionPollSeconds = cadence
|
|
279
|
+
if (retimed) {
|
|
280
|
+
// The recurring timer was armed at the old cadence: replace it and take
|
|
281
|
+
// the next pass now, so the new interval starts from this commit.
|
|
282
|
+
this.pollTimer?.()
|
|
283
|
+
this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, sessionPollMs(this.sessionPollSeconds))
|
|
284
|
+
this.clearPollBackoff()
|
|
285
|
+
this.schedulePoll()
|
|
286
|
+
}
|
|
189
287
|
if (resumed) {
|
|
190
288
|
const current = this.power.snapshot()
|
|
191
289
|
this.power.updateReasons({
|
|
@@ -195,6 +293,9 @@ export class TaskBoardHostService {
|
|
|
195
293
|
})
|
|
196
294
|
}
|
|
197
295
|
this.power.setEnabled(active && preventIdleSleep)
|
|
296
|
+
// Providers follow the board's master switch: the registry stops their
|
|
297
|
+
// provider-side work and hides their seats without clearing any data.
|
|
298
|
+
this.extensions.setEnabled(active)
|
|
198
299
|
if (resumed) {
|
|
199
300
|
this.schedulePoll()
|
|
200
301
|
this.recoverSchedule()
|
|
@@ -208,6 +309,7 @@ export class TaskBoardHostService {
|
|
|
208
309
|
|
|
209
310
|
snapshot(): TaskBoardSnapshot {
|
|
210
311
|
const state = this.ledger.state()
|
|
312
|
+
const published = this.extensions.published()
|
|
211
313
|
return {
|
|
212
314
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
213
315
|
revision: state.revision,
|
|
@@ -217,6 +319,42 @@ export class TaskBoardHostService {
|
|
|
217
319
|
sessionDefaultPermission: this.ledger.sessionDefaultPermission,
|
|
218
320
|
maxSubtaskDepth: this.ledger.maxSubtaskDepth,
|
|
219
321
|
teamRunAvailable: this.team !== undefined,
|
|
322
|
+
// The GLOBAL native-/goal switch is published live for the UI, but the
|
|
323
|
+
// service freezes its own read at launch: this mirror is for the FORM
|
|
324
|
+
// (enable/disable + explanation), never for a run already in flight.
|
|
325
|
+
goalRunEnabled: this.goalRunEnabled(),
|
|
326
|
+
...(Object.keys(published).length === 0 ? {} : { extensions: published }),
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Admit one external provider. Idempotent by extension id; the returned
|
|
332
|
+
* disposer releases it (its stored data stays).
|
|
333
|
+
*/
|
|
334
|
+
registerExtension(extension: TaskBoardExtension): () => void {
|
|
335
|
+
return this.extensions.registerExtension(extension)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The acceptance configuration the settings card displays: the live settings,
|
|
340
|
+
* the contract they resolve to, and the host catalog the choices come from.
|
|
341
|
+
*
|
|
342
|
+
* The card shows the RESOLVED route, not the raw configuration, so "inherit
|
|
343
|
+
* host" reads as the concrete model and reasoning level the next execution
|
|
344
|
+
* will actually freeze, including any recorded effort fallback.
|
|
345
|
+
* @returns the resolved acceptance options.
|
|
346
|
+
*/
|
|
347
|
+
async verificationOptions(): Promise<{
|
|
348
|
+
settings: VerificationSettings
|
|
349
|
+
contract: VerificationContract
|
|
350
|
+
catalog: ModelCatalogView
|
|
351
|
+
}> {
|
|
352
|
+
const settings = this.verificationSettings()
|
|
353
|
+
const catalog = await this.verificationCatalog()
|
|
354
|
+
return {
|
|
355
|
+
settings,
|
|
356
|
+
contract: resolveContract(settings, catalog),
|
|
357
|
+
catalog: catalog ?? { groups: [] },
|
|
220
358
|
}
|
|
221
359
|
}
|
|
222
360
|
|
|
@@ -231,8 +369,55 @@ export class TaskBoardHostService {
|
|
|
231
369
|
return () => { this.listeners.delete(listener) }
|
|
232
370
|
}
|
|
233
371
|
|
|
234
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Give a root card created without a workspace pin the workspace its creator
|
|
374
|
+
* is in.
|
|
375
|
+
*
|
|
376
|
+
* An agent's `task_board_create` arrives with the calling session, and the
|
|
377
|
+
* GUI submits the session the main view shows, so the card lands where the
|
|
378
|
+
* work was asked for instead of where the Host process happens to run. A card
|
|
379
|
+
* that names a workspace keeps it, and a subtask keeps inheriting its lineage
|
|
380
|
+
* (the runner walks the ancestor chain for that).
|
|
381
|
+
*
|
|
382
|
+
* The initiator is client-asserted, so it only chooses among workspaces the
|
|
383
|
+
* deployment already knows: it can neither register one nor reach outside the
|
|
384
|
+
* list the user sees.
|
|
385
|
+
* @param action - the action about to be applied.
|
|
386
|
+
* @param initiator - the session that issued the action, when one was asserted.
|
|
387
|
+
* @returns the action, with an inherited workspace on a root creation.
|
|
388
|
+
*/
|
|
389
|
+
private withInheritedWorkspace(action: TaskBoardAction, initiator?: string): TaskBoardAction {
|
|
390
|
+
if (action.kind !== 'create' || initiator === undefined || initiator === '') return action
|
|
391
|
+
const input = action.input
|
|
392
|
+
// A subtask inherits its lineage instead, and an explicit pin is the user's.
|
|
393
|
+
if ((input.parentId ?? '').trim() !== '') return action
|
|
394
|
+
if ((input.workspaceId ?? '').trim() !== '') return action
|
|
395
|
+
const workspaceId = workspaceOwningSession(this.workspaceRegistry?.list() ?? [], initiator)
|
|
396
|
+
if (workspaceId === undefined) return action
|
|
397
|
+
return { ...action, input: { ...input, workspaceId } }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
apply(requestId: string, action: Extract<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): Promise<TaskBoardSnapshot>
|
|
401
|
+
apply(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot
|
|
402
|
+
apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot>
|
|
403
|
+
apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot> {
|
|
235
404
|
if (!this.active) throw new Error('task board is disabled')
|
|
405
|
+
// Provider actions never touch the ledger directly: the registry routes the
|
|
406
|
+
// action to the owning extension, whose own capability calls come back
|
|
407
|
+
// through applyBoardAction and every board gate below.
|
|
408
|
+
if (action.kind === 'extension-action') {
|
|
409
|
+
return this.handleExtensionAction(action)
|
|
410
|
+
}
|
|
411
|
+
return this.applyBoardAction(requestId, action, initiator)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Apply one board-owned action with every gate intact. Provider capability
|
|
416
|
+
* calls and the same-origin wire both land here, so the two surfaces can
|
|
417
|
+
* never disagree about a card.
|
|
418
|
+
*/
|
|
419
|
+
private applyBoardAction(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot {
|
|
420
|
+
const before = this.statusMap()
|
|
236
421
|
// Fail closed before the ledger opens anything: a card opted into team
|
|
237
422
|
// execution cannot run in a deployment that serves no Agent Teams service,
|
|
238
423
|
// and silently degrading it to a plain cascade would misreport the work.
|
|
@@ -240,12 +425,14 @@ export class TaskBoardHostService {
|
|
|
240
425
|
const task = this.ledger.state().tasks.find(item => item.id === action.taskId)
|
|
241
426
|
if (task?.teamRun === true) throw new Error('Agent Teams is unavailable in this deployment')
|
|
242
427
|
}
|
|
243
|
-
const result = this.ledger.applyRequest(requestId, action, initiator)
|
|
428
|
+
const result = this.ledger.applyRequest(requestId, this.withInheritedWorkspace(action, initiator), initiator)
|
|
244
429
|
if (result.runs !== undefined) this.dispatchRuns(result.runs)
|
|
245
430
|
// A committed schedule write (create / update / toggle / delete) moves the
|
|
246
431
|
// nearest trigger; re-arm on every action so a newly enabled schedule fires
|
|
247
432
|
// at its own instant without waiting for the previous target to elapse.
|
|
248
433
|
if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
|
|
434
|
+
this.emitStatusChanges(before, initiator)
|
|
435
|
+
if (action.kind === 'delete') this.extensions.emitTaskDeleted({ taskId: action.taskId })
|
|
249
436
|
return {
|
|
250
437
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
251
438
|
revision: result.state.revision,
|
|
@@ -255,9 +442,41 @@ export class TaskBoardHostService {
|
|
|
255
442
|
}
|
|
256
443
|
}
|
|
257
444
|
|
|
445
|
+
/** Route one provider action and answer the resulting board snapshot. */
|
|
446
|
+
private async handleExtensionAction(action: Extract<TaskBoardAction, { kind: 'extension-action' }>): Promise<TaskBoardSnapshot> {
|
|
447
|
+
await this.extensions.handleAction({
|
|
448
|
+
extensionId: action.extensionId,
|
|
449
|
+
action: action.action,
|
|
450
|
+
...(action.taskId === undefined ? {} : { taskId: action.taskId }),
|
|
451
|
+
...(action.payload === undefined ? {} : { payload: action.payload }),
|
|
452
|
+
})
|
|
453
|
+
return this.snapshot()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Current task id -> status map, for change detection around a mutation. */
|
|
457
|
+
private statusMap(): Map<string, TaskStatus> {
|
|
458
|
+
return new Map(this.ledger.allTasks().map(task => [task.id, task.status]))
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Emit a status change for every task whose column moved, isolated per callback. */
|
|
462
|
+
private emitStatusChanges(before: Map<string, TaskStatus>, initiator?: string): void {
|
|
463
|
+
for (const task of this.ledger.allTasks()) {
|
|
464
|
+
const previous = before.get(task.id)
|
|
465
|
+
if (previous === undefined || previous === task.status) continue
|
|
466
|
+
this.extensions.emitStatusChanged({
|
|
467
|
+
taskId: task.id,
|
|
468
|
+
status: task.status,
|
|
469
|
+
previous,
|
|
470
|
+
...(initiator === undefined || initiator === '' ? {} : { initiator }),
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
258
475
|
dispose(): void {
|
|
259
476
|
this.disposed = true
|
|
477
|
+
this.extensions.dispose()
|
|
260
478
|
this.clearScheduleTimer()
|
|
479
|
+
this.clearPollBackoff()
|
|
261
480
|
this.pollTimer?.()
|
|
262
481
|
this.pollTimer = undefined
|
|
263
482
|
this.power.dispose()
|
|
@@ -266,12 +485,14 @@ export class TaskBoardHostService {
|
|
|
266
485
|
}
|
|
267
486
|
|
|
268
487
|
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
|
|
488
|
+
/** The session this launch attached to the execution, once it exists. */
|
|
489
|
+
let attached: string | undefined
|
|
269
490
|
try {
|
|
270
491
|
// A team run always mints a fresh Lead session: teammates are immutable
|
|
271
492
|
// children of that session, so reusing an older one would collide on
|
|
272
493
|
// their names and orphan the previous team.
|
|
273
494
|
const team = opened.task.teamRun === true
|
|
274
|
-
const reuseSessionId = team ? undefined :
|
|
495
|
+
const reuseSessionId = team ? undefined : await this.reuseSessionFor(opened.task)
|
|
275
496
|
// Both modes tell the launched agent what else this run opens; only a team
|
|
276
497
|
// run names teammates, because only then does this session own them.
|
|
277
498
|
const peers = others.length === 0 ? undefined : others.map(other => ({
|
|
@@ -286,17 +507,75 @@ export class TaskBoardHostService {
|
|
|
286
507
|
...(team ? { team: true } : {}),
|
|
287
508
|
...(schedule === undefined ? {} : { schedule }),
|
|
288
509
|
}
|
|
510
|
+
// Freeze this execution's acceptance contract BEFORE the session is
|
|
511
|
+
// prompted: a settings change made while the run is in flight must not
|
|
512
|
+
// retrofit the rule that will judge it. A card that opted out resolves the
|
|
513
|
+
// same contract from an OFF switch: the board-wide switch decides the
|
|
514
|
+
// default, the card decides this execution, and both are read here.
|
|
515
|
+
const settings = this.verificationSettings()
|
|
516
|
+
// The GLOBAL native-/goal switch is read ONCE here and carried by value
|
|
517
|
+
// for the whole execution: a settings edit after this point can only
|
|
518
|
+
// affect executions that start later.
|
|
519
|
+
const globalGoalEnabled = this.goalRunEnabled() === true
|
|
520
|
+
// The card must ASK for the goal run: the option is off by default, so an
|
|
521
|
+
// absent value is a plain turn exactly like an explicit false.
|
|
522
|
+
const goalEnabled = globalGoalEnabled && opened.task.goalRun === true
|
|
523
|
+
// The card's opt-out is a DISTINCT reason, not the board switch: the
|
|
524
|
+
// report must be able to say which of the two turned the gate off.
|
|
525
|
+
const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
|
|
526
|
+
const contract = resolveContract(
|
|
527
|
+
{ ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
|
|
528
|
+
await this.verificationCatalog(),
|
|
529
|
+
)
|
|
530
|
+
// An execution that will not become a goal run can never fire the
|
|
531
|
+
// completion gate, so it is marked with the reason that actually applied:
|
|
532
|
+
// the global switch being off is its OWN reason, distinct from this run
|
|
533
|
+
// simply not becoming a goal run (the card never opted in, or a refused or
|
|
534
|
+
// unavailable /goal command), and it must never read as an accepted run.
|
|
535
|
+
// The reason names which switch withheld the goal: the GLOBAL master
|
|
536
|
+
// switch (this board never arms /goal) is a different fact from the card's
|
|
537
|
+
// own goal option staying off — which is the default, so it is what an
|
|
538
|
+
// untouched card gets.
|
|
539
|
+
const notArmed: ExecutionVerification['applicability'] = globalGoalEnabled ? 'goal-unavailable' : 'goal-disabled'
|
|
540
|
+
const initial: ExecutionVerification = {
|
|
541
|
+
contract,
|
|
542
|
+
attempts: [],
|
|
543
|
+
applicability: team ? 'team-member' : !contract.enabled ? skippedBy : notArmed,
|
|
544
|
+
}
|
|
545
|
+
this.ledger.setVerification(opened.task.id, opened.execution.id, initial)
|
|
289
546
|
const sessionId = await this.runner.launch(opened.task, {
|
|
290
547
|
...(reuseSessionId === undefined ? {} : { reuseSessionId }),
|
|
291
548
|
...(promptContext === undefined ? {} : { promptContext }),
|
|
549
|
+
// The frozen global switch travels with the launch, so the runner's
|
|
550
|
+
// arming decision is exactly the one made above.
|
|
551
|
+
goalEnabled,
|
|
552
|
+
// Bind the session to the execution before the prompt is queued, so the
|
|
553
|
+
// completion gate can never observe the agent without its binding.
|
|
554
|
+
onSession: (id) => {
|
|
555
|
+
attached = id
|
|
556
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, id)
|
|
557
|
+
},
|
|
558
|
+
onGoalArmed: (armed) => {
|
|
559
|
+
this.setApplicability(opened.task.id, opened.execution.id, team
|
|
560
|
+
? 'team-member'
|
|
561
|
+
: !contract.enabled ? skippedBy : armed ? 'enforced' : notArmed)
|
|
562
|
+
},
|
|
292
563
|
})
|
|
293
|
-
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
564
|
+
if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
294
565
|
if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
|
|
295
566
|
} catch (error) {
|
|
296
|
-
|
|
297
|
-
|
|
567
|
+
const launchedSession = error instanceof SessionLaunchError ? error.sessionId : undefined
|
|
568
|
+
if (launchedSession !== undefined) {
|
|
569
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, launchedSession)
|
|
570
|
+
}
|
|
571
|
+
// A scheduled occurrence that failed before any session existed created
|
|
572
|
+
// no real execution: refund the run it consumed and record why, so a
|
|
573
|
+
// failed launch can never silently spend the user's run budget. A manual
|
|
574
|
+
// run, and a launch that DID reach a session, are never refunded.
|
|
575
|
+
if (schedule !== undefined && attached === undefined && launchedSession === undefined) {
|
|
576
|
+
this.ledger.refundScheduledOccurrence(opened.task.id, this.now())
|
|
298
577
|
}
|
|
299
|
-
this.
|
|
578
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
300
579
|
}
|
|
301
580
|
}
|
|
302
581
|
|
|
@@ -316,7 +595,7 @@ export class TaskBoardHostService {
|
|
|
316
595
|
private async spawnTeammate(opened: OpenedRun, leadSessionId: string): Promise<void> {
|
|
317
596
|
const team = this.team
|
|
318
597
|
if (team === undefined) {
|
|
319
|
-
this.
|
|
598
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
|
|
320
599
|
return
|
|
321
600
|
}
|
|
322
601
|
try {
|
|
@@ -327,45 +606,97 @@ export class TaskBoardHostService {
|
|
|
327
606
|
prompt: promptText(opened.task),
|
|
328
607
|
})
|
|
329
608
|
if (member.sessionId === undefined || member.sessionId === '') {
|
|
330
|
-
this.
|
|
609
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
|
|
331
610
|
return
|
|
332
611
|
}
|
|
333
612
|
this.ledger.attachSession(opened.task.id, opened.execution.id, member.sessionId)
|
|
334
613
|
} catch (error) {
|
|
335
|
-
this.
|
|
614
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
336
615
|
}
|
|
337
616
|
}
|
|
338
617
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
618
|
+
/**
|
|
619
|
+
* One roster pass, and whether it could read the session tree.
|
|
620
|
+
*
|
|
621
|
+
* The board reads the DSH session roster only while it has something the
|
|
622
|
+
* roster can decide: an execution to inspect, or a card parked in the running
|
|
623
|
+
* column whose verdict may arrive from a settle this process never saw. That
|
|
624
|
+
* gate is what removes the cost the reporter measured — `session/list`
|
|
625
|
+
* rebuilds every persisted session row (string conversion, object allocation,
|
|
626
|
+
* a stat per record), and an idle board with no tasks was paying it every few
|
|
627
|
+
* seconds forever.
|
|
628
|
+
* @returns whether the roster was readable (an idle pass counts as readable).
|
|
629
|
+
*/
|
|
630
|
+
private async pollSessions(): Promise<boolean> {
|
|
631
|
+
if (this.disposed) return true
|
|
632
|
+
const runtime = this.ledger.runtimeView()
|
|
633
|
+
if (!this.active) {
|
|
634
|
+
// A disabled board still settles the runs it opened before it was
|
|
635
|
+
// switched off, and re-reads the roster while a card sits in the running
|
|
636
|
+
// column. With neither, the poll is a no-op.
|
|
637
|
+
if (runtime.openExecutions.length === 0 && runtime.armedSchedules === 0) return true
|
|
638
|
+
} else if (!runtime.needsSessionState) {
|
|
639
|
+
// Idle board: nothing to reconcile and nothing to count. Keep the last
|
|
640
|
+
// power reading (a stale count only delays the release of the idle-sleep
|
|
641
|
+
// assertion, never starts one) and stand down.
|
|
642
|
+
return true
|
|
643
|
+
}
|
|
342
644
|
const running = await this.runner.listRunning()
|
|
343
645
|
const previous = this.power.snapshot()
|
|
344
646
|
if (!running.known) {
|
|
345
|
-
this.idleSessionIds = undefined
|
|
346
647
|
this.power.updateReasons({
|
|
347
648
|
runningSessions: previous.runningSessions,
|
|
348
|
-
armedSchedules:
|
|
649
|
+
armedSchedules: runtime.armedSchedules,
|
|
349
650
|
sessionStateKnown: false,
|
|
350
651
|
})
|
|
351
|
-
return
|
|
652
|
+
return false
|
|
352
653
|
}
|
|
353
|
-
this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
|
|
354
654
|
// Fold whatever the board can already decide before spending inspection
|
|
355
655
|
// RPCs: a team run whose Lead recorded its verdict, and any lineage whose
|
|
356
656
|
// members are all settled. Idempotent, so an already folded board is free.
|
|
657
|
+
const foldedBefore = this.statusMap()
|
|
357
658
|
this.ledger.finalizeReadyRuns()
|
|
659
|
+
this.emitStatusChanges(foldedBefore)
|
|
358
660
|
// Read after the RPC so executions attached while it was in flight are
|
|
359
661
|
// included in this pass, matching the former full-state snapshot timing.
|
|
360
|
-
const
|
|
662
|
+
const current = this.ledger.runtimeView()
|
|
361
663
|
this.power.updateReasons({
|
|
362
664
|
runningSessions: running.count,
|
|
363
|
-
armedSchedules:
|
|
665
|
+
armedSchedules: current.armedSchedules,
|
|
364
666
|
sessionStateKnown: true,
|
|
365
667
|
})
|
|
366
668
|
// No unconditional emit here: real changes already emit through the
|
|
367
669
|
// ledger subscription (settles) and the gated power listener above.
|
|
368
|
-
await this.reconcileExecutions(running.items,
|
|
670
|
+
await this.reconcileExecutions(running.items, current.openExecutions)
|
|
671
|
+
return true
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The session a run may continue in, for a card that opted into reuse
|
|
676
|
+
* (issue #1419). Reuse requires positive evidence that the previous session
|
|
677
|
+
* is present and idle, and that evidence must be read for THIS launch: an
|
|
678
|
+
* idle board no longer polls, so a roster cached from an earlier pass could
|
|
679
|
+
* be hours old — either refusing a session that has been idle all along, or
|
|
680
|
+
* prompting into one that started running since. A card that did not opt in
|
|
681
|
+
* reads no roster at all, and a read that fails mints a fresh conversation
|
|
682
|
+
* exactly as an unknown roster always did.
|
|
683
|
+
* @param task - the task about to run.
|
|
684
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
685
|
+
*/
|
|
686
|
+
private async reuseSessionFor(task: TaskRecord): Promise<string | undefined> {
|
|
687
|
+
if (task.reuseSession !== true) return undefined
|
|
688
|
+
let running: Awaited<ReturnType<HostExecutionRunner['listRunning']>>
|
|
689
|
+
try {
|
|
690
|
+
running = await this.runner.listRunning()
|
|
691
|
+
} catch (error) {
|
|
692
|
+
// A read that failed is an unknown roster, and an unknown roster never
|
|
693
|
+
// reuses; the launch itself must not fail over the reuse probe.
|
|
694
|
+
this.notePollFailure()
|
|
695
|
+
safeConsoleError('[dsh-task-board] session roster read for session reuse failed; starting a fresh session', error)
|
|
696
|
+
return undefined
|
|
697
|
+
}
|
|
698
|
+
if (!running.known) return undefined
|
|
699
|
+
return reusableSessionId(task, new Set(running.items.filter(item => !item.running).map(item => item.sessionId)))
|
|
369
700
|
}
|
|
370
701
|
|
|
371
702
|
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
@@ -375,6 +706,16 @@ export class TaskBoardHostService {
|
|
|
375
706
|
): Promise<void> {
|
|
376
707
|
for (const execution of executions) {
|
|
377
708
|
if (execution.sessionId === undefined) continue
|
|
709
|
+
const record = this.ledger.getTask(execution.taskId)?.executions.find(entry => entry.id === execution.executionId)
|
|
710
|
+
const verification = record?.verification
|
|
711
|
+
// A cycle the gate already closed is decided here, without another
|
|
712
|
+
// inspection: the goal may still be active (or un-blockable) exactly
|
|
713
|
+
// because the acceptance failed, and the card must not stay in the
|
|
714
|
+
// running column waiting for a verdict that is already recorded.
|
|
715
|
+
if (verification?.failedReason !== undefined) {
|
|
716
|
+
this.settleAndNotify(execution.taskId, execution.executionId, 'failed', verification.failedReason)
|
|
717
|
+
continue
|
|
718
|
+
}
|
|
378
719
|
try {
|
|
379
720
|
// A team member's turn is read even while the roster calls its session
|
|
380
721
|
// running: a durable teammate never goes idle for good.
|
|
@@ -387,7 +728,29 @@ export class TaskBoardHostService {
|
|
|
387
728
|
continue
|
|
388
729
|
}
|
|
389
730
|
this.unreadablePolls.delete(execution.executionId)
|
|
390
|
-
|
|
731
|
+
// Forced acceptance: the old fallback paths — a completed turn, a
|
|
732
|
+
// paused goal, an unreadable projection, a manual settle — must never
|
|
733
|
+
// be mistaken for a verified success. Only a matching pass record
|
|
734
|
+
// settles this execution as succeeded.
|
|
735
|
+
if (result.outcome === 'succeeded' && verificationRequired(verification) && passedAttempt(verification) === undefined) {
|
|
736
|
+
// Two different failures share this branch, and they send the reader
|
|
737
|
+
// in opposite directions: attempts on record mean the judge ran and
|
|
738
|
+
// the work did not pass, while zero attempts mean the gate never
|
|
739
|
+
// opened at all (issue #1837) — the session narrated completion
|
|
740
|
+
// instead of calling `update_goal(action: complete)`.
|
|
741
|
+
this.settleAndNotify(
|
|
742
|
+
execution.taskId,
|
|
743
|
+
execution.executionId,
|
|
744
|
+
'failed',
|
|
745
|
+
verificationNeverInvoked(verification)
|
|
746
|
+
? NEVER_INVOKED_VERIFICATION_REASON
|
|
747
|
+
: verificationOnlyInvalid(verification)
|
|
748
|
+
? INVALID_ONLY_VERIFICATION_REASON
|
|
749
|
+
: NO_MATCHING_PASS_VERIFICATION_REASON,
|
|
750
|
+
)
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
753
|
+
this.settleAndNotify(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
|
|
391
754
|
} catch {
|
|
392
755
|
// A transient inspection failure never settles a running execution.
|
|
393
756
|
}
|
|
@@ -407,6 +770,95 @@ export class TaskBoardHostService {
|
|
|
407
770
|
* ancestor of it — in the running column with no way out. The first poll of
|
|
408
771
|
* each streak is logged, so the Host log names the session.
|
|
409
772
|
*/
|
|
773
|
+
private settleAndNotify(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
|
|
774
|
+
const before = this.ledger.getTask(taskId)
|
|
775
|
+
if (before?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) {
|
|
776
|
+
// Already settled: the ledger would no-op, and re-emitting would report
|
|
777
|
+
// the same settlement twice.
|
|
778
|
+
this.ledger.settle(taskId, executionId, outcome, error)
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
const previousStatus = before?.status
|
|
782
|
+
this.ledger.settle(taskId, executionId, outcome, error)
|
|
783
|
+
const task = this.ledger.getTask(taskId)
|
|
784
|
+
if (task !== undefined && previousStatus !== undefined && previousStatus !== task.status) {
|
|
785
|
+
this.extensions.emitStatusChanged({ taskId, status: task.status, previous: previousStatus })
|
|
786
|
+
}
|
|
787
|
+
this.extensions.emitExecutionSettled({
|
|
788
|
+
taskId,
|
|
789
|
+
executionId,
|
|
790
|
+
outcome,
|
|
791
|
+
...(error === undefined ? {} : { error }),
|
|
792
|
+
})
|
|
793
|
+
// The pass record and the settlement are DURABLE by this point (both are in
|
|
794
|
+
// the ledger above), so the acceptance-only detail may now be cleaned. A
|
|
795
|
+
// cleanup failure is recorded against the execution and retried on the next
|
|
796
|
+
// Host start; it never revokes the pass this settlement just wrote.
|
|
797
|
+
if (outcome === 'succeeded') this.cleanupAcceptanceDetail(taskId, executionId)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Remove one execution's acceptance-only detail after it PASSED and settled.
|
|
802
|
+
*
|
|
803
|
+
* Deliberately narrow: only the acceptance mechanism's own per-attempt
|
|
804
|
+
* material (findings, invalid diagnostics, the bulky judge side) is removed,
|
|
805
|
+
* and only for THIS execution, keyed by its own id in the ledger it belongs
|
|
806
|
+
* to. No user file, task artifact, raw session history or other execution's
|
|
807
|
+
* record is read or written. Idempotent by the record's own state, and it
|
|
808
|
+
* clearly never touches a run that did not pass (the caller's guard) — a
|
|
809
|
+
* failed or invalid execution keeps everything a repair or a review needs.
|
|
810
|
+
* @param taskId - the task whose execution settled.
|
|
811
|
+
* @param executionId - the settled execution.
|
|
812
|
+
*/
|
|
813
|
+
private cleanupAcceptanceDetail(taskId: string, executionId: string): void {
|
|
814
|
+
const execution = this.ledger.getTask(taskId)?.executions.find(entry => entry.id === executionId)
|
|
815
|
+
const verification = execution?.verification
|
|
816
|
+
// Only an execution with a REAL pass record is cleaned. An execution whose
|
|
817
|
+
// acceptance never ran (no block) or was disabled/skipped has nothing this
|
|
818
|
+
// mechanism created, and a failed one must keep its evidence.
|
|
819
|
+
if (verification === undefined || passedAttempt(verification) === undefined) return
|
|
820
|
+
if (verification.cleanup?.state === 'cleaned') return
|
|
821
|
+
try {
|
|
822
|
+
const cleaned = withAcceptanceDetailCleared(verification, this.now())
|
|
823
|
+
if (cleaned === undefined) return
|
|
824
|
+
this.ledger.setVerification(taskId, executionId, cleaned)
|
|
825
|
+
} catch (error) {
|
|
826
|
+
// Record the failure so the UI can say the cleanup is pending and the
|
|
827
|
+
// Host retries it at start; the pass itself is untouched.
|
|
828
|
+
const attempts = (verification.cleanup?.attempts ?? 0) + 1
|
|
829
|
+
this.ledger.setVerification(taskId, executionId, {
|
|
830
|
+
...verification,
|
|
831
|
+
cleanup: {
|
|
832
|
+
state: 'failed',
|
|
833
|
+
attempts,
|
|
834
|
+
startedAt: verification.cleanup?.startedAt ?? this.now(),
|
|
835
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
836
|
+
audit: verification.cleanup?.audit ?? [],
|
|
837
|
+
},
|
|
838
|
+
})
|
|
839
|
+
safeConsoleError('[dsh-task-board] acceptance-detail cleanup failed; it is retried on the next Host start', error)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Catch-up pass: clean the acceptance detail of every execution that PASSED
|
|
845
|
+
* and settled but whose cleanup did not finish (a crash, a failed write, a
|
|
846
|
+
* Host killed mid-cleanup).
|
|
847
|
+
*
|
|
848
|
+
* The same bounds as any other cleanup apply — only a real pass record on a
|
|
849
|
+
* settled execution is touched — so this can never delete material belonging
|
|
850
|
+
* to an execution that did not pass, and never a user file.
|
|
851
|
+
*/
|
|
852
|
+
private cleanupPendingAcceptanceDetail(): void {
|
|
853
|
+
for (const task of this.ledger.allTasks()) {
|
|
854
|
+
for (const execution of task.executions) {
|
|
855
|
+
if (execution.endedAt === undefined) continue
|
|
856
|
+
if (execution.result !== 'succeeded') continue
|
|
857
|
+
this.cleanupAcceptanceDetail(task.id, execution.id)
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
410
862
|
private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
|
|
411
863
|
const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1
|
|
412
864
|
this.unreadablePolls.set(execution.executionId, polls)
|
|
@@ -417,7 +869,7 @@ export class TaskBoardHostService {
|
|
|
417
869
|
}
|
|
418
870
|
if (polls < UNREADABLE_SETTLE_POLLS) return
|
|
419
871
|
this.unreadablePolls.delete(execution.executionId)
|
|
420
|
-
this.
|
|
872
|
+
this.settleAndNotify(
|
|
421
873
|
execution.taskId,
|
|
422
874
|
execution.executionId,
|
|
423
875
|
'failed',
|
|
@@ -484,13 +936,21 @@ export class TaskBoardHostService {
|
|
|
484
936
|
this.recoverSchedule()
|
|
485
937
|
return
|
|
486
938
|
}
|
|
939
|
+
const before = this.statusMap()
|
|
487
940
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
488
|
-
|
|
941
|
+
// The ledger owns both the budget gate and the next target: it refuses a
|
|
942
|
+
// rule that already spent its runs and stops a one-shot as it fires.
|
|
489
943
|
this.dispatchRuns(
|
|
490
|
-
this.ledger.openScheduled(schedule.taskId,
|
|
491
|
-
{
|
|
944
|
+
this.ledger.openScheduled(schedule.taskId, now),
|
|
945
|
+
{
|
|
946
|
+
triggeredAt: now,
|
|
947
|
+
timeZone: schedule.timeZone,
|
|
948
|
+
mode: schedule.mode,
|
|
949
|
+
...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
|
|
950
|
+
},
|
|
492
951
|
)
|
|
493
952
|
}
|
|
953
|
+
this.emitStatusChanges(before)
|
|
494
954
|
// The launched run (or the rolled-forward target) moved every due schedule,
|
|
495
955
|
// so the next nearest target has to be recomputed from the ledger.
|
|
496
956
|
this.armSchedule()
|
|
@@ -517,6 +977,16 @@ export class TaskBoardHostService {
|
|
|
517
977
|
}
|
|
518
978
|
}
|
|
519
979
|
|
|
980
|
+
/**
|
|
981
|
+
* Move one execution's applicability without touching its frozen contract or
|
|
982
|
+
* its recorded attempts (used when the run's goal form becomes known).
|
|
983
|
+
*/
|
|
984
|
+
private setApplicability(taskId: string, executionId: string, applicability: ExecutionVerification['applicability']): void {
|
|
985
|
+
const verification = this.ledger.getTask(taskId)?.executions.find(entry => entry.id === executionId)?.verification
|
|
986
|
+
if (verification === undefined || verification.applicability === applicability) return
|
|
987
|
+
this.ledger.setVerification(taskId, executionId, { ...verification, applicability })
|
|
988
|
+
}
|
|
989
|
+
|
|
520
990
|
private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
|
|
521
991
|
void this.launch(opened, others, schedule).catch(error => {
|
|
522
992
|
safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
|
|
@@ -526,11 +996,47 @@ export class TaskBoardHostService {
|
|
|
526
996
|
private schedulePoll(): void {
|
|
527
997
|
if (this.pollInFlight || this.disposed) return
|
|
528
998
|
this.pollInFlight = true
|
|
529
|
-
void this.pollSessions().
|
|
999
|
+
void this.pollSessions().then((rosterReadable) => {
|
|
1000
|
+
// A pass that could not read the roster is a failure even though it
|
|
1001
|
+
// resolved: the board knows no more about its sessions than before.
|
|
1002
|
+
if (rosterReadable) this.consecutivePollFailures = 0
|
|
1003
|
+
else this.notePollFailure()
|
|
1004
|
+
}).catch(error => {
|
|
1005
|
+
this.notePollFailure()
|
|
530
1006
|
safeConsoleError('[dsh-task-board] session polling failed', error)
|
|
531
1007
|
}).finally(() => { this.pollInFlight = false })
|
|
532
1008
|
}
|
|
533
1009
|
|
|
1010
|
+
/**
|
|
1011
|
+
* Count one failed pass and arm the retry that follows it.
|
|
1012
|
+
*
|
|
1013
|
+
* A session tree that is down is retried on a doubling delay instead of
|
|
1014
|
+
* hammering it on the fixed cadence: the heartbeat would otherwise repeat
|
|
1015
|
+
* every failure — and every retry inside {@link HostExecutionRunner.listRunning}
|
|
1016
|
+
* — at exactly the interval the reporter measured.
|
|
1017
|
+
*/
|
|
1018
|
+
private notePollFailure(): void {
|
|
1019
|
+
this.consecutivePollFailures += 1
|
|
1020
|
+
this.armPollBackoff()
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Arm the retry that follows a failed pass, doubling up to the ceiling. */
|
|
1024
|
+
private armPollBackoff(): void {
|
|
1025
|
+
if (this.disposed) return
|
|
1026
|
+
this.pollBackoffTimer?.()
|
|
1027
|
+
const delay = Math.min(sessionPollMs(this.sessionPollSeconds) * 2 ** Math.min(this.consecutivePollFailures, 8), POLL_FAILURE_MAX_BACKOFF_MS)
|
|
1028
|
+
this.pollBackoffTimer = this.timers.timeout(() => {
|
|
1029
|
+
this.pollBackoffTimer = undefined
|
|
1030
|
+
this.schedulePoll()
|
|
1031
|
+
}, delay)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Drop a pending retry; the next regular tick takes over. */
|
|
1035
|
+
private clearPollBackoff(): void {
|
|
1036
|
+
this.pollBackoffTimer?.()
|
|
1037
|
+
this.pollBackoffTimer = undefined
|
|
1038
|
+
}
|
|
1039
|
+
|
|
534
1040
|
/**
|
|
535
1041
|
* Re-arm from the ledger's current targets. Callers that just changed a
|
|
536
1042
|
* schedule (the host routes, the agent tools) invoke this after the write
|