@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.7
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 +34 -18
- package/README.zh.md +34 -18
- package/lib/client.js +2804 -1024
- package/lib/client.js.map +1 -1
- package/lib/index.js +2576 -416
- package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
- package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
- package/lib/types/client/board/model-options.d.ts +67 -0
- package/lib/types/client/board/preset-label.d.ts +21 -0
- package/lib/types/client/board/preset-roster.d.ts +45 -0
- package/lib/types/client/board/team-run-promotion.d.ts +26 -0
- package/lib/types/client/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +81 -0
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/core/card-defaults.d.ts +78 -0
- package/lib/types/core/controller.d.ts +41 -17
- package/lib/types/core/launch-width.d.ts +31 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/reasoning-effort.d.ts +57 -0
- package/lib/types/core/subtask.d.ts +30 -8
- package/lib/types/core/tasks.d.ts +163 -14
- package/lib/types/core/use-cases/task-create.d.ts +10 -5
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- package/lib/types/host/launch-admission.d.ts +72 -0
- package/lib/types/host/verification-gate.d.ts +24 -0
- package/lib/types/host/verification-runner.d.ts +38 -7
- package/lib/types/host-ledger.d.ts +91 -11
- package/lib/types/host-runner.d.ts +111 -13
- package/lib/types/host-service.d.ts +199 -8
- package/lib/types/index.d.ts +141 -0
- package/lib/types/protocol.d.ts +63 -12
- package/package.json +29 -33
- package/src/client/CardDefaultPresetField.tsx +101 -0
- package/src/client/TaskBoardSettingsCard.tsx +288 -17
- package/src/client/board/LinkSubtaskModal.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +237 -60
- package/src/client/board/ReasoningEffortField.tsx +50 -0
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +261 -24
- package/src/client/board/VerificationReport.tsx +132 -13
- package/src/client/board/model-options.ts +178 -0
- package/src/client/board/preset-label.ts +32 -0
- package/src/client/board/preset-roster.ts +58 -0
- package/src/client/board/status-key.ts +3 -0
- package/src/client/board/team-run-promotion.ts +39 -0
- package/src/client/index.ts +105 -108
- package/src/client/locales.ts +171 -6
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/settings-entry-form.ts +0 -1
- package/src/core/card-defaults.ts +132 -0
- package/src/core/controller.ts +55 -16
- package/src/core/launch-width.ts +38 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/reasoning-effort.ts +92 -0
- package/src/core/store.ts +69 -15
- package/src/core/subtask.ts +48 -13
- package/src/core/tasks.ts +213 -39
- package/src/core/use-cases/task-create.ts +57 -25
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +9 -4
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +127 -29
- package/src/host/launch-admission.ts +140 -0
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +346 -48
- package/src/host-runner.ts +234 -35
- package/src/host-service.ts +539 -52
- package/src/index.ts +250 -7
- package/src/protocol.ts +160 -32
- package/lib/invariant.js +0 -6
- package/lib/types/client/board-card-seat.d.ts +0 -34
- package/lib/types/invariant.d.ts +0 -3
- package/src/client/board-card-seat.ts +0 -102
- package/src/invariant.ts +0 -4
package/src/host-service.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
|
2
|
-
import {
|
|
2
|
+
import { DEFAULT_RUN_LAUNCH_WIDTH, normalizeRunLaunchWidth } from './core/launch-width.ts'
|
|
3
|
+
import { DEFAULT_SESSION_POLL_SECONDS, normalizeSessionPollSeconds, sessionPollMs } from './core/poll-cadence.ts'
|
|
3
4
|
import { reusableSessionId } from './core/session-reuse.ts'
|
|
5
|
+
import { LaunchAdmissionQueue, type QueuedLaunch } from './host/launch-admission.ts'
|
|
4
6
|
import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
|
|
5
|
-
import { HostExecutionRunner, SessionLaunchError, promptText, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
|
|
7
|
+
import { HostExecutionRunner, SessionLaunchError, promptText, type GatewayPeer, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
|
|
6
8
|
import { teammateName } from './core/subtask.ts'
|
|
7
9
|
import { workspaceOwningSession } from './core/workspace-target.ts'
|
|
8
10
|
import { PowerInhibitor } from './power-inhibitor.ts'
|
|
9
11
|
import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
|
|
12
|
+
import type { CardDefaults } from './core/card-defaults.ts'
|
|
10
13
|
import { TaskBoardExtensionRegistry } from './host/extension-registry.ts'
|
|
11
14
|
import type { TaskBoardExtension } from './core/extension.ts'
|
|
12
|
-
import type
|
|
13
|
-
import { passedAttempt, resolveContract, verificationRequired, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
|
|
15
|
+
import { isTaskPermission, normalizeTargetId, type ExecutionOutcome, type ScheduleMode, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
16
|
+
import { passedAttempt, resolveContract, verificationNeverInvoked, verificationOnlyInvalid, verificationRequired, withAcceptanceDetailCleared, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
|
|
14
17
|
import type { TaskPermission } from './core/handover.ts'
|
|
15
18
|
|
|
16
19
|
/** One teammate the Host asks the Agent Teams service to spawn for a team run. */
|
|
@@ -43,8 +46,8 @@ export interface TaskBoardTeamDispatcher {
|
|
|
43
46
|
spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
/**
|
|
47
|
-
const
|
|
49
|
+
/** Ceiling for the roster-poll failure backoff: a broken session tree is retried at most once a minute. */
|
|
50
|
+
const POLL_FAILURE_MAX_BACKOFF_MS = 60_000
|
|
48
51
|
/**
|
|
49
52
|
* How late an armed schedule fire may be before it counts as a resume rather
|
|
50
53
|
* than a normal occurrence. The schedule timer is armed AT the next due
|
|
@@ -65,6 +68,60 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
|
65
68
|
*/
|
|
66
69
|
const UNREADABLE_SETTLE_POLLS = 24
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Why a member of a run never started: the Host stopped while its launch was
|
|
73
|
+
* still waiting for an admission slot. Recorded rather than left open, so the
|
|
74
|
+
* card leaves the running column instead of waiting for the next Host start's
|
|
75
|
+
* interrupted-start recovery.
|
|
76
|
+
*/
|
|
77
|
+
const ABANDONED_LAUNCH_REASON = 'host stopped before this execution started'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The acceptance contract one run group freezes, read once for the whole group.
|
|
81
|
+
*
|
|
82
|
+
* The board freezes an execution's acceptance contract BEFORE its session is
|
|
83
|
+
* prompted. Admission delays the launch, so reading these per execution would
|
|
84
|
+
* let a settings edit made while later members wait reach some members of one
|
|
85
|
+
* run and not others. Reading them once per run group — at the group's first
|
|
86
|
+
* admission, which is its root's — keeps the whole group on one contract and
|
|
87
|
+
* still freezes it before any member's session is prompted.
|
|
88
|
+
*/
|
|
89
|
+
interface FrozenRunContract {
|
|
90
|
+
settings: VerificationSettings
|
|
91
|
+
/** The GLOBAL native-/goal switch, resolved exactly once for the run group. */
|
|
92
|
+
globalGoalEnabled: boolean
|
|
93
|
+
/** The host model catalog the group's contracts resolve their route from. */
|
|
94
|
+
catalog: ModelCatalogView | undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Reads (or returns the already-read) contract of one run group. */
|
|
98
|
+
type RunContractFreezer = () => Promise<FrozenRunContract>
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why an enforced goal execution settled failed while its acceptance DID run:
|
|
102
|
+
* attempts were recorded and none of them passed. The judge answered about the
|
|
103
|
+
* work and the work did not pass.
|
|
104
|
+
*/
|
|
105
|
+
export const NO_MATCHING_PASS_VERIFICATION_REASON = 'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。'
|
|
106
|
+
/**
|
|
107
|
+
* Why an enforced goal execution settled failed with ZERO acceptance records:
|
|
108
|
+
* the gate was never opened, so the judge never ran and nothing was ever judged
|
|
109
|
+
* (issue #1837). The root cause is a session that declared completion in prose
|
|
110
|
+
* instead of calling `update_goal(action: complete)` — a tool-adherence problem,
|
|
111
|
+
* NOT a quality verdict on the delivery — so this reason says so and keeps the
|
|
112
|
+
* two investigation directions apart.
|
|
113
|
+
*/
|
|
114
|
+
export const NEVER_INVOKED_VERIFICATION_REASON = 'goal 验收未触发:本次执行没有任何验收记录,验收门从未打开——执行会话没有调用 update_goal(action: complete) 来完成目标(常见于只在回复里宣告完成)。这不是质量判负:交付从未被裁判评估。'
|
|
115
|
+
/**
|
|
116
|
+
* Why an enforced goal execution settled failed while its ONLY acceptance
|
|
117
|
+
* records are INVALID: the judge answered but could not locate its own
|
|
118
|
+
* rejection, so the board stopped calling it. This is NOT an evidence-backed
|
|
119
|
+
* quality veto — the requirement is explicit that such a run must not be
|
|
120
|
+
* described as one — but the execution still has no matching pass, so it ends
|
|
121
|
+
* for lack of one, and the reason names that precisely.
|
|
122
|
+
*/
|
|
123
|
+
export const INVALID_ONLY_VERIFICATION_REASON = 'goal 验收无效:本次执行只记录了无效验收(裁判未能给出与任务要求对应、且可定位到本次证据的否决依据),看板已停止调用裁判。这**不是**有证据的质量判负:验收本身不可用,本次运行因没有匹配的通过记录而结束。请检查验收环境与证据可见性,或用 task_board_manage(action=reset-verification) 清除记录的无效验收后重新完成目标。'
|
|
124
|
+
|
|
68
125
|
/**
|
|
69
126
|
* Provenance of one cron-triggered cascade: when the rule fired and the zone
|
|
70
127
|
* its wall clock was read in. Passed into every launched prompt of that run so
|
|
@@ -73,14 +130,25 @@ const UNREADABLE_SETTLE_POLLS = 24
|
|
|
73
130
|
export interface ScheduledRunContext {
|
|
74
131
|
triggeredAt: number
|
|
75
132
|
timeZone: string
|
|
76
|
-
|
|
133
|
+
/** Whether this fire came from a recurring rule or a single planned instant. */
|
|
134
|
+
mode: ScheduleMode
|
|
135
|
+
/** Cron expression of a recurring rule; absent on a one-shot. */
|
|
136
|
+
cron?: string
|
|
77
137
|
}
|
|
78
138
|
|
|
79
139
|
/**
|
|
80
|
-
* Actions that can
|
|
81
|
-
* an unrelated card edit leaves the pending fire
|
|
140
|
+
* Actions that can change the instant the schedule timer is armed at. Only
|
|
141
|
+
* these re-arm the native timer; an unrelated card edit leaves the pending fire
|
|
142
|
+
* untouched.
|
|
143
|
+
*
|
|
144
|
+
* The set is the write surface of a rule itself rather than a guess at the UI
|
|
145
|
+
* flows that reach it: `create` and `import` land a card that already carries
|
|
146
|
+
* an enabled rule (the new-task dialog and the one-time browser import),
|
|
147
|
+
* `set-schedule` edits one, and `delete`/`archive` drop one. `update` is
|
|
148
|
+
* deliberately absent — the update patch cannot carry a schedule — and neither
|
|
149
|
+
* can the run, move, lineage, permission, verification or label actions.
|
|
82
150
|
*/
|
|
83
|
-
const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['set-schedule', 'delete', 'archive'])
|
|
151
|
+
const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['create', 'import', 'set-schedule', 'delete', 'archive'])
|
|
84
152
|
|
|
85
153
|
/**
|
|
86
154
|
* The native timer face the Host arms through. The cordis `timer` service
|
|
@@ -106,6 +174,29 @@ const PROCESS_TIMERS: HostTimerFace = {
|
|
|
106
174
|
},
|
|
107
175
|
}
|
|
108
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The new-card defaults a snapshot publishes, or undefined when this
|
|
179
|
+
* deployment configures none. A target the board configures nothing for is
|
|
180
|
+
* omitted instead of published blank, so the browser can tell "no default
|
|
181
|
+
* model" from "a blank one" (only a hand-edited settings document can express
|
|
182
|
+
* the latter).
|
|
183
|
+
*/
|
|
184
|
+
function cardDefaultsOf(defaults: CardDefaults): TaskBoardSnapshot['cardDefaults'] {
|
|
185
|
+
const mode = normalizeTargetId(defaults.mode)
|
|
186
|
+
const permission = normalizeTargetId(defaults.permission)
|
|
187
|
+
const model = normalizeTargetId(defaults.model)
|
|
188
|
+
const reasoningEffort = normalizeTargetId(defaults.reasoningEffort)
|
|
189
|
+
const published: NonNullable<TaskBoardSnapshot['cardDefaults']> = {
|
|
190
|
+
...(mode === undefined ? {} : { mode }),
|
|
191
|
+
// The snapshot publishes a permission the board's own vocabulary knows; an
|
|
192
|
+
// out-of-domain value is ignored here exactly as the create path ignores it.
|
|
193
|
+
...(isTaskPermission(permission) ? { permission } : {}),
|
|
194
|
+
...(model === undefined ? {} : { model }),
|
|
195
|
+
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
196
|
+
}
|
|
197
|
+
return Object.keys(published).length === 0 ? undefined : published
|
|
198
|
+
}
|
|
199
|
+
|
|
109
200
|
export class TaskBoardHostService {
|
|
110
201
|
readonly ledger: HostTaskLedger
|
|
111
202
|
readonly runner: HostExecutionRunner
|
|
@@ -113,6 +204,12 @@ export class TaskBoardHostService {
|
|
|
113
204
|
private readonly listeners = new Set<() => void>()
|
|
114
205
|
/** The one recurring timer: the session-roster poll. */
|
|
115
206
|
private pollTimer: (() => void) | undefined
|
|
207
|
+
/** Cadence of the roster poll, in seconds (a settings field; see the config schema). */
|
|
208
|
+
private sessionPollSeconds = DEFAULT_SESSION_POLL_SECONDS
|
|
209
|
+
/** Pending delay that follows a failed poll pass, if one is armed. */
|
|
210
|
+
private pollBackoffTimer: (() => void) | undefined
|
|
211
|
+
/** Consecutive failed poll passes; doubles the next retry delay. */
|
|
212
|
+
private consecutivePollFailures = 0
|
|
116
213
|
/** The armed schedule timer, if a trigger is pending. */
|
|
117
214
|
private scheduleTimer: (() => void) | undefined
|
|
118
215
|
/** The instant the armed schedule timer targets (ms epoch), for resume detection. */
|
|
@@ -121,12 +218,11 @@ export class TaskBoardHostService {
|
|
|
121
218
|
private pollInFlight = false
|
|
122
219
|
private active = true
|
|
123
220
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* conversation instead of prompting into a session it cannot see.
|
|
221
|
+
* Admission control for the session-creating half of a run: one board action
|
|
222
|
+
* opens a whole subtree, and this is what keeps the Host from asking DSH for
|
|
223
|
+
* every member's session at once (see {@link LaunchAdmissionQueue}).
|
|
128
224
|
*/
|
|
129
|
-
private
|
|
225
|
+
private readonly launches: LaunchAdmissionQueue
|
|
130
226
|
/**
|
|
131
227
|
* Consecutive unreadable-history polls per open execution (see
|
|
132
228
|
* {@link noteUnreadableInspection}). Cleared as soon as an inspection
|
|
@@ -138,6 +234,16 @@ export class TaskBoardHostService {
|
|
|
138
234
|
private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
|
|
139
235
|
private readonly team: TaskBoardTeamDispatcher | undefined
|
|
140
236
|
private readonly timers: HostTimerFace
|
|
237
|
+
/** Live reader of the global native-/goal switch; frozen per execution. */
|
|
238
|
+
private readonly goalRunEnabled: () => boolean
|
|
239
|
+
/** Live reader of the deployment's new-card defaults (published and applied per create). */
|
|
240
|
+
private readonly cardDefaults: () => CardDefaults
|
|
241
|
+
/**
|
|
242
|
+
* Live reader of the run-launch admission width: how many launches may be in
|
|
243
|
+
* flight at once. Read at every admission, so a settings edit bounds the next
|
|
244
|
+
* launch without remounting the row. Absent means the default.
|
|
245
|
+
*/
|
|
246
|
+
private readonly runLaunchWidth: () => number
|
|
141
247
|
private lastPowerJson = ''
|
|
142
248
|
private readonly now: () => number
|
|
143
249
|
/** The deployment's workspaces, consulted to resolve a card that pins none. */
|
|
@@ -155,14 +261,36 @@ export class TaskBoardHostService {
|
|
|
155
261
|
now?: () => number
|
|
156
262
|
commandDispatcher?: SessionCommandDispatcher
|
|
157
263
|
workspaceRegistry?: TaskBoardWorkspaceRegistry
|
|
264
|
+
/**
|
|
265
|
+
* The owner peer every gateway request the runner makes speaks for.
|
|
266
|
+
* Production passes the peer bound to the plugin fiber (see
|
|
267
|
+
* {@link createOwnerPeer}); absent is fine for tests and bare mounts,
|
|
268
|
+
* where the runner binds one to a fresh root context.
|
|
269
|
+
*/
|
|
270
|
+
ownerPeer?: GatewayPeer
|
|
158
271
|
/**
|
|
159
272
|
* Permission baseline of the confirmation gate: a fixed value, or a live
|
|
160
273
|
* resolver the board re-reads so a Host Settings change needs no remount.
|
|
161
274
|
*/
|
|
162
275
|
sessionDefaultPermission?: TaskPermission | (() => TaskPermission)
|
|
163
276
|
maxSubtaskDepth?: number
|
|
277
|
+
/**
|
|
278
|
+
* Live reader of the deployment's new-card defaults (volatile config reads).
|
|
279
|
+
* The board publishes them for the create dialog and re-reads them for every
|
|
280
|
+
* create that opts in, so a settings edit reaches the next created card
|
|
281
|
+
* without remounting the row. Absent means the deployment configures none.
|
|
282
|
+
*/
|
|
283
|
+
cardDefaults?: () => CardDefaults
|
|
164
284
|
team?: TaskBoardTeamDispatcher
|
|
165
285
|
timers?: HostTimerFace
|
|
286
|
+
/**
|
|
287
|
+
* Live GLOBAL native /goal switch (a volatile config read). Read ONCE per
|
|
288
|
+
* execution, at launch, and carried by value from then on: a settings edit
|
|
289
|
+
* made while a run is in flight must not arm, disarm or otherwise change the
|
|
290
|
+
* goal/acceptance contract of that run. Absent means OFF, which is the
|
|
291
|
+
* default for every deployment that never configured it.
|
|
292
|
+
*/
|
|
293
|
+
goalRunEnabled?: () => boolean
|
|
166
294
|
/**
|
|
167
295
|
* Live acceptance settings (volatile config reads). Absent keeps goal
|
|
168
296
|
* acceptance OFF for every execution this service opens, which is what a
|
|
@@ -171,17 +299,29 @@ export class TaskBoardHostService {
|
|
|
171
299
|
verificationSettings?: () => VerificationSettings
|
|
172
300
|
/** The host model catalog the acceptance contract resolves its route from. */
|
|
173
301
|
verificationCatalog?: () => Promise<ModelCatalogView | undefined>
|
|
302
|
+
/**
|
|
303
|
+
* Live admission width for run launches: the number of launches (one
|
|
304
|
+
* session creation and its prompt each) that may be in flight at once. Read
|
|
305
|
+
* per admission, so editing the setting bounds the next launch of a run that
|
|
306
|
+
* is still queueing. Absent means the default width.
|
|
307
|
+
*/
|
|
308
|
+
runLaunchWidth?: () => number
|
|
174
309
|
} = {}) {
|
|
175
310
|
this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
|
|
176
311
|
sessionDefaultPermission: options.sessionDefaultPermission,
|
|
177
312
|
maxSubtaskDepth: options.maxSubtaskDepth,
|
|
313
|
+
cardDefaults: options.cardDefaults,
|
|
178
314
|
})
|
|
179
|
-
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
|
|
315
|
+
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry, undefined, options.ownerPeer)
|
|
180
316
|
this.workspaceRegistry = options.workspaceRegistry
|
|
181
317
|
this.team = options.team
|
|
182
318
|
this.timers = options.timers ?? PROCESS_TIMERS
|
|
183
319
|
this.power = options.power ?? new PowerInhibitor()
|
|
184
320
|
this.now = options.now ?? Date.now
|
|
321
|
+
this.goalRunEnabled = options.goalRunEnabled ?? (() => false)
|
|
322
|
+
this.cardDefaults = options.cardDefaults ?? (() => ({}))
|
|
323
|
+
this.runLaunchWidth = options.runLaunchWidth ?? (() => DEFAULT_RUN_LAUNCH_WIDTH)
|
|
324
|
+
this.launches = new LaunchAdmissionQueue(() => normalizeRunLaunchWidth(this.runLaunchWidth()))
|
|
185
325
|
this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
|
|
186
326
|
this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
|
|
187
327
|
this.extensions = new TaskBoardExtensionRegistry({
|
|
@@ -210,8 +350,11 @@ export class TaskBoardHostService {
|
|
|
210
350
|
start(): void {
|
|
211
351
|
if (this.disposed || this.pollTimer !== undefined) return
|
|
212
352
|
this.syncPowerReasons()
|
|
213
|
-
this.pollTimer = this.timers.interval(() => { this.schedulePoll() },
|
|
353
|
+
this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, sessionPollMs(this.sessionPollSeconds))
|
|
214
354
|
this.schedulePoll()
|
|
355
|
+
// Boot is also the cleanup catch-up point: an execution that passed before
|
|
356
|
+
// the Host stopped may still hold its detail (a crash, a failed write).
|
|
357
|
+
this.cleanupPendingAcceptanceDetail()
|
|
215
358
|
// Boot is a recovery point: an occurrence armed while the Host was down is
|
|
216
359
|
// not replayed, and each schedule rolls to its next future target. A
|
|
217
360
|
// schedule the Board should have served while running is then armed
|
|
@@ -219,10 +362,30 @@ export class TaskBoardHostService {
|
|
|
219
362
|
this.recoverSchedule()
|
|
220
363
|
}
|
|
221
364
|
|
|
222
|
-
|
|
365
|
+
/**
|
|
366
|
+
* Apply the live configuration. The roster cadence is re-read on every
|
|
367
|
+
* commit, so a settings edit takes effect without a restart; the poll is
|
|
368
|
+
* scheduled at the new cadence immediately rather than waiting out the old
|
|
369
|
+
* interval.
|
|
370
|
+
* @param active - the board's master switch.
|
|
371
|
+
* @param preventIdleSleep - whether the board holds an idle-sleep assertion.
|
|
372
|
+
* @param sessionPollSeconds - roster-poll cadence in seconds (clamped).
|
|
373
|
+
*/
|
|
374
|
+
setConfiguration(active: boolean, preventIdleSleep: boolean, sessionPollSeconds?: number): void {
|
|
223
375
|
const resumed = !this.active && active
|
|
224
376
|
this.active = active
|
|
225
377
|
this.preventIdleSleep = preventIdleSleep
|
|
378
|
+
const cadence = normalizeSessionPollSeconds(sessionPollSeconds)
|
|
379
|
+
const retimed = this.pollTimer !== undefined && cadence !== this.sessionPollSeconds
|
|
380
|
+
this.sessionPollSeconds = cadence
|
|
381
|
+
if (retimed) {
|
|
382
|
+
// The recurring timer was armed at the old cadence: replace it and take
|
|
383
|
+
// the next pass now, so the new interval starts from this commit.
|
|
384
|
+
this.pollTimer?.()
|
|
385
|
+
this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, sessionPollMs(this.sessionPollSeconds))
|
|
386
|
+
this.clearPollBackoff()
|
|
387
|
+
this.schedulePoll()
|
|
388
|
+
}
|
|
226
389
|
if (resumed) {
|
|
227
390
|
const current = this.power.snapshot()
|
|
228
391
|
this.power.updateReasons({
|
|
@@ -249,6 +412,7 @@ export class TaskBoardHostService {
|
|
|
249
412
|
snapshot(): TaskBoardSnapshot {
|
|
250
413
|
const state = this.ledger.state()
|
|
251
414
|
const published = this.extensions.published()
|
|
415
|
+
const cardDefaults = cardDefaultsOf(this.cardDefaults())
|
|
252
416
|
return {
|
|
253
417
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
254
418
|
revision: state.revision,
|
|
@@ -258,6 +422,14 @@ export class TaskBoardHostService {
|
|
|
258
422
|
sessionDefaultPermission: this.ledger.sessionDefaultPermission,
|
|
259
423
|
maxSubtaskDepth: this.ledger.maxSubtaskDepth,
|
|
260
424
|
teamRunAvailable: this.team !== undefined,
|
|
425
|
+
// The GLOBAL native-/goal switch is published live for the UI, but the
|
|
426
|
+
// service freezes its own read at launch: this mirror is for the FORM
|
|
427
|
+
// (enable/disable + explanation), never for a run already in flight.
|
|
428
|
+
goalRunEnabled: this.goalRunEnabled(),
|
|
429
|
+
// The new-card defaults are published live for the same reason the switch
|
|
430
|
+
// is: the create dialog prefills from them, and each creating session
|
|
431
|
+
// re-reads them for the create it submits.
|
|
432
|
+
...(cardDefaults === undefined ? {} : { cardDefaults }),
|
|
261
433
|
...(Object.keys(published).length === 0 ? {} : { extensions: published }),
|
|
262
434
|
}
|
|
263
435
|
}
|
|
@@ -362,9 +534,11 @@ export class TaskBoardHostService {
|
|
|
362
534
|
}
|
|
363
535
|
const result = this.ledger.applyRequest(requestId, this.withInheritedWorkspace(action, initiator), initiator)
|
|
364
536
|
if (result.runs !== undefined) this.dispatchRuns(result.runs)
|
|
365
|
-
// A committed
|
|
366
|
-
//
|
|
367
|
-
//
|
|
537
|
+
// A committed write that can move a rule moves the nearest trigger;
|
|
538
|
+
// re-arm from the ledger so a created or edited schedule fires at its own
|
|
539
|
+
// instant instead of waiting out whatever target was armed before it. The
|
|
540
|
+
// re-arm is derived from the ledger, so a write that moved nothing leaves
|
|
541
|
+
// the pending fire exactly as it was.
|
|
368
542
|
if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
|
|
369
543
|
this.emitStatusChanges(before, initiator)
|
|
370
544
|
if (action.kind === 'delete') this.extensions.emitTaskDeleted({ taskId: action.taskId })
|
|
@@ -409,8 +583,17 @@ export class TaskBoardHostService {
|
|
|
409
583
|
|
|
410
584
|
dispose(): void {
|
|
411
585
|
this.disposed = true
|
|
586
|
+
// Launches still waiting for a slot can never start now: settle them so
|
|
587
|
+
// their cards leave the running column here, instead of waiting for the
|
|
588
|
+
// next Host start's interrupted-start recovery. Launches already in flight
|
|
589
|
+
// are left to finish — their session may already exist.
|
|
590
|
+
const abandoned = this.launches.drain()
|
|
591
|
+
if (abandoned.length > 0) {
|
|
592
|
+
safeConsoleError(`[dsh-task-board] ${abandoned.length} run member(s) were still waiting for a launch slot when the Host stopped; each is settled as cancelled`)
|
|
593
|
+
}
|
|
412
594
|
this.extensions.dispose()
|
|
413
595
|
this.clearScheduleTimer()
|
|
596
|
+
this.clearPollBackoff()
|
|
414
597
|
this.pollTimer?.()
|
|
415
598
|
this.pollTimer = undefined
|
|
416
599
|
this.power.dispose()
|
|
@@ -418,13 +601,15 @@ export class TaskBoardHostService {
|
|
|
418
601
|
this.listeners.clear()
|
|
419
602
|
}
|
|
420
603
|
|
|
421
|
-
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
|
|
604
|
+
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext, freezeContract?: RunContractFreezer): Promise<void> {
|
|
605
|
+
/** The session this launch attached to the execution, once it exists. */
|
|
606
|
+
let attached: string | undefined
|
|
422
607
|
try {
|
|
423
608
|
// A team run always mints a fresh Lead session: teammates are immutable
|
|
424
609
|
// children of that session, so reusing an older one would collide on
|
|
425
610
|
// their names and orphan the previous team.
|
|
426
611
|
const team = opened.task.teamRun === true
|
|
427
|
-
const reuseSessionId = team ? undefined :
|
|
612
|
+
const reuseSessionId = team ? undefined : await this.reuseSessionFor(opened.task)
|
|
428
613
|
// Both modes tell the launched agent what else this run opens; only a team
|
|
429
614
|
// run names teammates, because only then does this session own them.
|
|
430
615
|
const peers = others.length === 0 ? undefined : others.map(other => ({
|
|
@@ -444,24 +629,50 @@ export class TaskBoardHostService {
|
|
|
444
629
|
// retrofit the rule that will judge it. A card that opted out resolves the
|
|
445
630
|
// same contract from an OFF switch: the board-wide switch decides the
|
|
446
631
|
// default, the card decides this execution, and both are read here.
|
|
447
|
-
|
|
632
|
+
//
|
|
633
|
+
// The read is the run GROUP's when the queue handed one down: admission
|
|
634
|
+
// delays a member's launch, so a per-execution read would let a settings
|
|
635
|
+
// edit made while later members wait reach some members of one run and not
|
|
636
|
+
// others. One group, one contract — and still frozen before the group's
|
|
637
|
+
// first session is prompted.
|
|
638
|
+
const { settings, globalGoalEnabled, catalog } = await (freezeContract ?? (() => this.freezeRunContract()))()
|
|
639
|
+
// The GLOBAL native-/goal switch is read ONCE per run group and carried by
|
|
640
|
+
// value for the whole execution: a settings edit after this point can only
|
|
641
|
+
// affect executions that start later.
|
|
642
|
+
// The card must ASK for the goal run: the option is off by default, so an
|
|
643
|
+
// absent value is a plain turn exactly like an explicit false.
|
|
644
|
+
const goalEnabled = globalGoalEnabled && opened.task.goalRun === true
|
|
448
645
|
// The card's opt-out is a DISTINCT reason, not the board switch: the
|
|
449
646
|
// report must be able to say which of the two turned the gate off.
|
|
450
647
|
const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
|
|
451
648
|
const contract = resolveContract(
|
|
452
649
|
{ ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
|
|
453
|
-
|
|
650
|
+
catalog,
|
|
454
651
|
)
|
|
652
|
+
// An execution that will not become a goal run can never fire the
|
|
653
|
+
// completion gate, so it is marked with the reason that actually applied:
|
|
654
|
+
// the global switch being off is its OWN reason, distinct from this run
|
|
655
|
+
// simply not becoming a goal run (the card never opted in, or a refused or
|
|
656
|
+
// unavailable /goal command), and it must never read as an accepted run.
|
|
657
|
+
// The reason names which switch withheld the goal: the GLOBAL master
|
|
658
|
+
// switch (this board never arms /goal) is a different fact from the card's
|
|
659
|
+
// own goal option staying off — which is the default, so it is what an
|
|
660
|
+
// untouched card gets.
|
|
661
|
+
const notArmed: ExecutionVerification['applicability'] = globalGoalEnabled ? 'goal-unavailable' : 'goal-disabled'
|
|
455
662
|
const initial: ExecutionVerification = {
|
|
456
663
|
contract,
|
|
457
664
|
attempts: [],
|
|
458
|
-
applicability: team ? 'team-member' : !contract.enabled ? skippedBy :
|
|
665
|
+
applicability: team ? 'team-member' : !contract.enabled ? skippedBy : notArmed,
|
|
459
666
|
}
|
|
460
667
|
this.ledger.setVerification(opened.task.id, opened.execution.id, initial)
|
|
461
|
-
let attached: string | undefined
|
|
462
668
|
const sessionId = await this.runner.launch(opened.task, {
|
|
463
669
|
...(reuseSessionId === undefined ? {} : { reuseSessionId }),
|
|
464
670
|
...(promptContext === undefined ? {} : { promptContext }),
|
|
671
|
+
// The frozen global switch travels with the launch, so the runner's
|
|
672
|
+
// arming decision is exactly the one made above.
|
|
673
|
+
goalEnabled,
|
|
674
|
+
// The same catalog snapshot the acceptance contract was frozen from.
|
|
675
|
+
catalog,
|
|
465
676
|
// Bind the session to the execution before the prompt is queued, so the
|
|
466
677
|
// completion gate can never observe the agent without its binding.
|
|
467
678
|
onSession: (id) => {
|
|
@@ -471,23 +682,75 @@ export class TaskBoardHostService {
|
|
|
471
682
|
onGoalArmed: (armed) => {
|
|
472
683
|
this.setApplicability(opened.task.id, opened.execution.id, team
|
|
473
684
|
? 'team-member'
|
|
474
|
-
: !contract.enabled ? skippedBy : armed ? 'enforced' :
|
|
685
|
+
: !contract.enabled ? skippedBy : armed ? 'enforced' : notArmed)
|
|
475
686
|
},
|
|
476
687
|
})
|
|
477
688
|
if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
478
689
|
if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
|
|
479
690
|
} catch (error) {
|
|
480
|
-
|
|
481
|
-
|
|
691
|
+
const launchedSession = error instanceof SessionLaunchError ? error.sessionId : undefined
|
|
692
|
+
if (launchedSession !== undefined) {
|
|
693
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, launchedSession)
|
|
694
|
+
}
|
|
695
|
+
// A scheduled occurrence that failed before any session existed created
|
|
696
|
+
// no real execution: refund the run it consumed and record why, so a
|
|
697
|
+
// failed launch can never silently spend the user's run budget. A manual
|
|
698
|
+
// run, and a launch that DID reach a session, are never refunded.
|
|
699
|
+
if (schedule !== undefined && attached === undefined && launchedSession === undefined) {
|
|
700
|
+
this.refundUnstartedSchedule(opened.task.id)
|
|
482
701
|
}
|
|
483
702
|
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
484
703
|
}
|
|
485
704
|
}
|
|
486
705
|
|
|
706
|
+
/**
|
|
707
|
+
* Read the acceptance contract one run group freezes, in the order the board
|
|
708
|
+
* has always read it: the acceptance settings, then the global native-/goal
|
|
709
|
+
* switch, then the model catalog that both resolves the contract's route and
|
|
710
|
+
* decides the reasoning effort the session may be given.
|
|
711
|
+
*/
|
|
712
|
+
private async freezeRunContract(): Promise<FrozenRunContract> {
|
|
713
|
+
const settings = this.verificationSettings()
|
|
714
|
+
const globalGoalEnabled = this.goalRunEnabled() === true
|
|
715
|
+
const catalog = await this.verificationCatalog()
|
|
716
|
+
return { settings, globalGoalEnabled, catalog }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* A {@link RunContractFreezer} that reads once and hands the SAME value to
|
|
721
|
+
* every later caller of one run group. A read that fails is not cached: the
|
|
722
|
+
* next member reads again, exactly as a per-execution read would have.
|
|
723
|
+
*/
|
|
724
|
+
private contractFreezerForRunGroup(): RunContractFreezer {
|
|
725
|
+
let pending: Promise<FrozenRunContract> | undefined
|
|
726
|
+
return () => {
|
|
727
|
+
pending ??= this.freezeRunContract().catch((error: unknown) => {
|
|
728
|
+
pending = undefined
|
|
729
|
+
throw error
|
|
730
|
+
})
|
|
731
|
+
return pending
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Give back the occurrence a launch consumed, for a launch that never reached
|
|
737
|
+
* a session: a failed launch, or one the Host abandoned while it was still
|
|
738
|
+
* waiting for an admission slot. A manual run, and a launch that DID reach a
|
|
739
|
+
* session, are never refunded.
|
|
740
|
+
*/
|
|
741
|
+
private refundUnstartedSchedule(taskId: string): void {
|
|
742
|
+
this.ledger.refundScheduledOccurrence(taskId, this.now())
|
|
743
|
+
// The refund runs after the fire already armed the next target, and a
|
|
744
|
+
// refunded occurrence can restore one the fire had just cleared (a rule
|
|
745
|
+
// that spent its last run). Re-arm so the restored occurrence is served
|
|
746
|
+
// at its own instant rather than lost until some unrelated write.
|
|
747
|
+
this.refreshSchedule()
|
|
748
|
+
}
|
|
749
|
+
|
|
487
750
|
private scheduleTeammate(opened: OpenedRun, leadSessionId: string): void {
|
|
488
|
-
|
|
751
|
+
this.enqueueLaunch(opened, () => this.spawnTeammate(opened, leadSessionId).catch(error => {
|
|
489
752
|
safeConsoleError('[dsh-task-board] teammate spawn settlement failed', error)
|
|
490
|
-
})
|
|
753
|
+
}))
|
|
491
754
|
}
|
|
492
755
|
|
|
493
756
|
/**
|
|
@@ -520,21 +783,42 @@ export class TaskBoardHostService {
|
|
|
520
783
|
}
|
|
521
784
|
}
|
|
522
785
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
786
|
+
/**
|
|
787
|
+
* One roster pass, and whether it could read the session tree.
|
|
788
|
+
*
|
|
789
|
+
* The board reads the DSH session roster only while it has something the
|
|
790
|
+
* roster can decide: an execution to inspect, or a card parked in the running
|
|
791
|
+
* column whose verdict may arrive from a settle this process never saw. That
|
|
792
|
+
* gate is what removes the cost the reporter measured — `session/list`
|
|
793
|
+
* rebuilds every persisted session row (string conversion, object allocation,
|
|
794
|
+
* a stat per record), and an idle board with no tasks was paying it every few
|
|
795
|
+
* seconds forever.
|
|
796
|
+
* @returns whether the roster was readable (an idle pass counts as readable).
|
|
797
|
+
*/
|
|
798
|
+
private async pollSessions(): Promise<boolean> {
|
|
799
|
+
if (this.disposed) return true
|
|
800
|
+
const runtime = this.ledger.runtimeView()
|
|
801
|
+
if (!this.active) {
|
|
802
|
+
// A disabled board still settles the runs it opened before it was
|
|
803
|
+
// switched off, and re-reads the roster while a card sits in the running
|
|
804
|
+
// column. With neither, the poll is a no-op.
|
|
805
|
+
if (runtime.openExecutions.length === 0 && runtime.armedSchedules === 0) return true
|
|
806
|
+
} else if (!runtime.needsSessionState) {
|
|
807
|
+
// Idle board: nothing to reconcile and nothing to count. Keep the last
|
|
808
|
+
// power reading (a stale count only delays the release of the idle-sleep
|
|
809
|
+
// assertion, never starts one) and stand down.
|
|
810
|
+
return true
|
|
811
|
+
}
|
|
526
812
|
const running = await this.runner.listRunning()
|
|
527
813
|
const previous = this.power.snapshot()
|
|
528
814
|
if (!running.known) {
|
|
529
|
-
this.idleSessionIds = undefined
|
|
530
815
|
this.power.updateReasons({
|
|
531
816
|
runningSessions: previous.runningSessions,
|
|
532
|
-
armedSchedules:
|
|
817
|
+
armedSchedules: runtime.armedSchedules,
|
|
533
818
|
sessionStateKnown: false,
|
|
534
819
|
})
|
|
535
|
-
return
|
|
820
|
+
return false
|
|
536
821
|
}
|
|
537
|
-
this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
|
|
538
822
|
// Fold whatever the board can already decide before spending inspection
|
|
539
823
|
// RPCs: a team run whose Lead recorded its verdict, and any lineage whose
|
|
540
824
|
// members are all settled. Idempotent, so an already folded board is free.
|
|
@@ -543,15 +827,44 @@ export class TaskBoardHostService {
|
|
|
543
827
|
this.emitStatusChanges(foldedBefore)
|
|
544
828
|
// Read after the RPC so executions attached while it was in flight are
|
|
545
829
|
// included in this pass, matching the former full-state snapshot timing.
|
|
546
|
-
const
|
|
830
|
+
const current = this.ledger.runtimeView()
|
|
547
831
|
this.power.updateReasons({
|
|
548
832
|
runningSessions: running.count,
|
|
549
|
-
armedSchedules:
|
|
833
|
+
armedSchedules: current.armedSchedules,
|
|
550
834
|
sessionStateKnown: true,
|
|
551
835
|
})
|
|
552
836
|
// No unconditional emit here: real changes already emit through the
|
|
553
837
|
// ledger subscription (settles) and the gated power listener above.
|
|
554
|
-
await this.reconcileExecutions(running.items,
|
|
838
|
+
await this.reconcileExecutions(running.items, current.openExecutions)
|
|
839
|
+
return true
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The session a run may continue in, for a card that opted into reuse
|
|
844
|
+
* (issue #1419). Reuse requires positive evidence that the previous session
|
|
845
|
+
* is present and idle, and that evidence must be read for THIS launch: an
|
|
846
|
+
* idle board no longer polls, so a roster cached from an earlier pass could
|
|
847
|
+
* be hours old — either refusing a session that has been idle all along, or
|
|
848
|
+
* prompting into one that started running since. A card that did not opt in
|
|
849
|
+
* reads no roster at all, and a read that fails mints a fresh conversation
|
|
850
|
+
* exactly as an unknown roster always did.
|
|
851
|
+
* @param task - the task about to run.
|
|
852
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
853
|
+
*/
|
|
854
|
+
private async reuseSessionFor(task: TaskRecord): Promise<string | undefined> {
|
|
855
|
+
if (task.reuseSession !== true) return undefined
|
|
856
|
+
let running: Awaited<ReturnType<HostExecutionRunner['listRunning']>>
|
|
857
|
+
try {
|
|
858
|
+
running = await this.runner.listRunning()
|
|
859
|
+
} catch (error) {
|
|
860
|
+
// A read that failed is an unknown roster, and an unknown roster never
|
|
861
|
+
// reuses; the launch itself must not fail over the reuse probe.
|
|
862
|
+
this.notePollFailure()
|
|
863
|
+
safeConsoleError('[dsh-task-board] session roster read for session reuse failed; starting a fresh session', error)
|
|
864
|
+
return undefined
|
|
865
|
+
}
|
|
866
|
+
if (!running.known) return undefined
|
|
867
|
+
return reusableSessionId(task, new Set(running.items.filter(item => !item.running).map(item => item.sessionId)))
|
|
555
868
|
}
|
|
556
869
|
|
|
557
870
|
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
@@ -588,11 +901,20 @@ export class TaskBoardHostService {
|
|
|
588
901
|
// be mistaken for a verified success. Only a matching pass record
|
|
589
902
|
// settles this execution as succeeded.
|
|
590
903
|
if (result.outcome === 'succeeded' && verificationRequired(verification) && passedAttempt(verification) === undefined) {
|
|
904
|
+
// Two different failures share this branch, and they send the reader
|
|
905
|
+
// in opposite directions: attempts on record mean the judge ran and
|
|
906
|
+
// the work did not pass, while zero attempts mean the gate never
|
|
907
|
+
// opened at all (issue #1837) — the session narrated completion
|
|
908
|
+
// instead of calling `update_goal(action: complete)`.
|
|
591
909
|
this.settleAndNotify(
|
|
592
910
|
execution.taskId,
|
|
593
911
|
execution.executionId,
|
|
594
912
|
'failed',
|
|
595
|
-
|
|
913
|
+
verificationNeverInvoked(verification)
|
|
914
|
+
? NEVER_INVOKED_VERIFICATION_REASON
|
|
915
|
+
: verificationOnlyInvalid(verification)
|
|
916
|
+
? INVALID_ONLY_VERIFICATION_REASON
|
|
917
|
+
: NO_MATCHING_PASS_VERIFICATION_REASON,
|
|
596
918
|
)
|
|
597
919
|
continue
|
|
598
920
|
}
|
|
@@ -636,6 +958,73 @@ export class TaskBoardHostService {
|
|
|
636
958
|
outcome,
|
|
637
959
|
...(error === undefined ? {} : { error }),
|
|
638
960
|
})
|
|
961
|
+
// The pass record and the settlement are DURABLE by this point (both are in
|
|
962
|
+
// the ledger above), so the acceptance-only detail may now be cleaned. A
|
|
963
|
+
// cleanup failure is recorded against the execution and retried on the next
|
|
964
|
+
// Host start; it never revokes the pass this settlement just wrote.
|
|
965
|
+
if (outcome === 'succeeded') this.cleanupAcceptanceDetail(taskId, executionId)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Remove one execution's acceptance-only detail after it PASSED and settled.
|
|
970
|
+
*
|
|
971
|
+
* Deliberately narrow: only the acceptance mechanism's own per-attempt
|
|
972
|
+
* material (findings, invalid diagnostics, the bulky judge side) is removed,
|
|
973
|
+
* and only for THIS execution, keyed by its own id in the ledger it belongs
|
|
974
|
+
* to. No user file, task artifact, raw session history or other execution's
|
|
975
|
+
* record is read or written. Idempotent by the record's own state, and it
|
|
976
|
+
* clearly never touches a run that did not pass (the caller's guard) — a
|
|
977
|
+
* failed or invalid execution keeps everything a repair or a review needs.
|
|
978
|
+
* @param taskId - the task whose execution settled.
|
|
979
|
+
* @param executionId - the settled execution.
|
|
980
|
+
*/
|
|
981
|
+
private cleanupAcceptanceDetail(taskId: string, executionId: string): void {
|
|
982
|
+
const execution = this.ledger.getTask(taskId)?.executions.find(entry => entry.id === executionId)
|
|
983
|
+
const verification = execution?.verification
|
|
984
|
+
// Only an execution with a REAL pass record is cleaned. An execution whose
|
|
985
|
+
// acceptance never ran (no block) or was disabled/skipped has nothing this
|
|
986
|
+
// mechanism created, and a failed one must keep its evidence.
|
|
987
|
+
if (verification === undefined || passedAttempt(verification) === undefined) return
|
|
988
|
+
if (verification.cleanup?.state === 'cleaned') return
|
|
989
|
+
try {
|
|
990
|
+
const cleaned = withAcceptanceDetailCleared(verification, this.now())
|
|
991
|
+
if (cleaned === undefined) return
|
|
992
|
+
this.ledger.setVerification(taskId, executionId, cleaned)
|
|
993
|
+
} catch (error) {
|
|
994
|
+
// Record the failure so the UI can say the cleanup is pending and the
|
|
995
|
+
// Host retries it at start; the pass itself is untouched.
|
|
996
|
+
const attempts = (verification.cleanup?.attempts ?? 0) + 1
|
|
997
|
+
this.ledger.setVerification(taskId, executionId, {
|
|
998
|
+
...verification,
|
|
999
|
+
cleanup: {
|
|
1000
|
+
state: 'failed',
|
|
1001
|
+
attempts,
|
|
1002
|
+
startedAt: verification.cleanup?.startedAt ?? this.now(),
|
|
1003
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
1004
|
+
audit: verification.cleanup?.audit ?? [],
|
|
1005
|
+
},
|
|
1006
|
+
})
|
|
1007
|
+
safeConsoleError('[dsh-task-board] acceptance-detail cleanup failed; it is retried on the next Host start', error)
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Catch-up pass: clean the acceptance detail of every execution that PASSED
|
|
1013
|
+
* and settled but whose cleanup did not finish (a crash, a failed write, a
|
|
1014
|
+
* Host killed mid-cleanup).
|
|
1015
|
+
*
|
|
1016
|
+
* The same bounds as any other cleanup apply — only a real pass record on a
|
|
1017
|
+
* settled execution is touched — so this can never delete material belonging
|
|
1018
|
+
* to an execution that did not pass, and never a user file.
|
|
1019
|
+
*/
|
|
1020
|
+
private cleanupPendingAcceptanceDetail(): void {
|
|
1021
|
+
for (const task of this.ledger.allTasks()) {
|
|
1022
|
+
for (const execution of task.executions) {
|
|
1023
|
+
if (execution.endedAt === undefined) continue
|
|
1024
|
+
if (execution.result !== 'succeeded') continue
|
|
1025
|
+
this.cleanupAcceptanceDetail(task.id, execution.id)
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
639
1028
|
}
|
|
640
1029
|
|
|
641
1030
|
private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
|
|
@@ -717,10 +1106,16 @@ export class TaskBoardHostService {
|
|
|
717
1106
|
}
|
|
718
1107
|
const before = this.statusMap()
|
|
719
1108
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
720
|
-
|
|
1109
|
+
// The ledger owns both the budget gate and the next target: it refuses a
|
|
1110
|
+
// rule that already spent its runs and stops a one-shot as it fires.
|
|
721
1111
|
this.dispatchRuns(
|
|
722
|
-
this.ledger.openScheduled(schedule.taskId,
|
|
723
|
-
{
|
|
1112
|
+
this.ledger.openScheduled(schedule.taskId, now),
|
|
1113
|
+
{
|
|
1114
|
+
triggeredAt: now,
|
|
1115
|
+
timeZone: schedule.timeZone,
|
|
1116
|
+
mode: schedule.mode,
|
|
1117
|
+
...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
|
|
1118
|
+
},
|
|
724
1119
|
)
|
|
725
1120
|
}
|
|
726
1121
|
this.emitStatusChanges(before)
|
|
@@ -739,14 +1134,22 @@ export class TaskBoardHostService {
|
|
|
739
1134
|
* member then launches on its own so one refused participant cannot hold the
|
|
740
1135
|
* others back. A team run's members are spawned inside the root's Lead
|
|
741
1136
|
* session instead, once that session exists.
|
|
1137
|
+
*
|
|
1138
|
+
* Every launch of one run set goes through the admission queue, which admits
|
|
1139
|
+
* at most `runLaunchWidth` of them at a time — the whole subtree still opens,
|
|
1140
|
+
* members just enter the running column in batches. Order inside the group is
|
|
1141
|
+
* the order they are enqueued here, so the root's session always exists before
|
|
1142
|
+
* any member's, and one member's launch settling (or failing) is what admits
|
|
1143
|
+
* the next. All members share the one contract this group freezes.
|
|
742
1144
|
*/
|
|
743
1145
|
private dispatchRuns(runs: readonly OpenedRun[], schedule?: ScheduledRunContext): void {
|
|
744
1146
|
if (runs.length === 0) return
|
|
745
1147
|
const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
|
|
746
1148
|
const others = runs.filter(run => run !== root)
|
|
747
|
-
this.
|
|
1149
|
+
const freezeContract = this.contractFreezerForRunGroup()
|
|
1150
|
+
this.scheduleLaunch(root, others, schedule, freezeContract)
|
|
748
1151
|
for (const run of others) {
|
|
749
|
-
if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule)
|
|
1152
|
+
if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule, freezeContract)
|
|
750
1153
|
}
|
|
751
1154
|
}
|
|
752
1155
|
|
|
@@ -760,20 +1163,104 @@ export class TaskBoardHostService {
|
|
|
760
1163
|
this.ledger.setVerification(taskId, executionId, { ...verification, applicability })
|
|
761
1164
|
}
|
|
762
1165
|
|
|
763
|
-
private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
|
|
764
|
-
|
|
1166
|
+
private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext, freezeContract?: RunContractFreezer): void {
|
|
1167
|
+
this.enqueueLaunch(opened, () => this.launch(opened, others, schedule, freezeContract).catch(error => {
|
|
765
1168
|
safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
|
|
766
|
-
})
|
|
1169
|
+
}), schedule)
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Hand one launch to the admission queue, or abandon it on the spot when the
|
|
1174
|
+
* Host is already stopping: a queue entry created after {@link dispose} would
|
|
1175
|
+
* otherwise never be drained, and its card would sit in the running column
|
|
1176
|
+
* until the next Host start recovered it.
|
|
1177
|
+
*/
|
|
1178
|
+
private enqueueLaunch(opened: OpenedRun, start: () => Promise<void>, schedule?: ScheduledRunContext): void {
|
|
1179
|
+
const launch: QueuedLaunch = {
|
|
1180
|
+
groupId: opened.execution.runGroupId ?? opened.task.id,
|
|
1181
|
+
start: async () => {
|
|
1182
|
+
// Waiting for a slot is time the execution can be settled in: a manual
|
|
1183
|
+
// settle, or a team run folded onto its Lead's verdict, can close a
|
|
1184
|
+
// member's execution before it ever started. Starting it then would
|
|
1185
|
+
// create a session no execution is watching, so a launch whose
|
|
1186
|
+
// execution is already closed is dropped instead.
|
|
1187
|
+
if (!this.isExecutionOpen(opened)) return
|
|
1188
|
+
await start()
|
|
1189
|
+
},
|
|
1190
|
+
abandon: () => this.abandonQueuedRun(opened, schedule),
|
|
1191
|
+
}
|
|
1192
|
+
if (this.disposed) {
|
|
1193
|
+
launch.abandon()
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
this.launches.enqueue(launch)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Whether one opened execution is still open in the ledger. */
|
|
1200
|
+
private isExecutionOpen(opened: OpenedRun): boolean {
|
|
1201
|
+
const execution = this.ledger.getTask(opened.task.id)?.executions.find(entry => entry.id === opened.execution.id)
|
|
1202
|
+
return execution !== undefined && execution.endedAt === undefined
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Take one launch that never started out of the running column.
|
|
1207
|
+
*
|
|
1208
|
+
* Settled as CANCELLED, not failed: nothing about the run's work went wrong —
|
|
1209
|
+
* the Host stopped before the launch got a slot, which is exactly what the
|
|
1210
|
+
* next start's interrupted-start recovery records for an execution that never
|
|
1211
|
+
* reached a session. A scheduled occurrence that never reached a session is
|
|
1212
|
+
* refunded on the same terms as any other pre-session failure, so a run the
|
|
1213
|
+
* Host never started cannot silently spend the user's run budget.
|
|
1214
|
+
*/
|
|
1215
|
+
private abandonQueuedRun(opened: OpenedRun, schedule?: ScheduledRunContext): void {
|
|
1216
|
+
if (schedule !== undefined) this.refundUnstartedSchedule(opened.task.id)
|
|
1217
|
+
this.settleAndNotify(opened.task.id, opened.execution.id, 'cancelled', ABANDONED_LAUNCH_REASON)
|
|
767
1218
|
}
|
|
768
1219
|
|
|
769
1220
|
private schedulePoll(): void {
|
|
770
1221
|
if (this.pollInFlight || this.disposed) return
|
|
771
1222
|
this.pollInFlight = true
|
|
772
|
-
void this.pollSessions().
|
|
1223
|
+
void this.pollSessions().then((rosterReadable) => {
|
|
1224
|
+
// A pass that could not read the roster is a failure even though it
|
|
1225
|
+
// resolved: the board knows no more about its sessions than before.
|
|
1226
|
+
if (rosterReadable) this.consecutivePollFailures = 0
|
|
1227
|
+
else this.notePollFailure()
|
|
1228
|
+
}).catch(error => {
|
|
1229
|
+
this.notePollFailure()
|
|
773
1230
|
safeConsoleError('[dsh-task-board] session polling failed', error)
|
|
774
1231
|
}).finally(() => { this.pollInFlight = false })
|
|
775
1232
|
}
|
|
776
1233
|
|
|
1234
|
+
/**
|
|
1235
|
+
* Count one failed pass and arm the retry that follows it.
|
|
1236
|
+
*
|
|
1237
|
+
* A session tree that is down is retried on a doubling delay instead of
|
|
1238
|
+
* hammering it on the fixed cadence: the heartbeat would otherwise repeat
|
|
1239
|
+
* every failure — and every retry inside {@link HostExecutionRunner.listRunning}
|
|
1240
|
+
* — at exactly the interval the reporter measured.
|
|
1241
|
+
*/
|
|
1242
|
+
private notePollFailure(): void {
|
|
1243
|
+
this.consecutivePollFailures += 1
|
|
1244
|
+
this.armPollBackoff()
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Arm the retry that follows a failed pass, doubling up to the ceiling. */
|
|
1248
|
+
private armPollBackoff(): void {
|
|
1249
|
+
if (this.disposed) return
|
|
1250
|
+
this.pollBackoffTimer?.()
|
|
1251
|
+
const delay = Math.min(sessionPollMs(this.sessionPollSeconds) * 2 ** Math.min(this.consecutivePollFailures, 8), POLL_FAILURE_MAX_BACKOFF_MS)
|
|
1252
|
+
this.pollBackoffTimer = this.timers.timeout(() => {
|
|
1253
|
+
this.pollBackoffTimer = undefined
|
|
1254
|
+
this.schedulePoll()
|
|
1255
|
+
}, delay)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Drop a pending retry; the next regular tick takes over. */
|
|
1259
|
+
private clearPollBackoff(): void {
|
|
1260
|
+
this.pollBackoffTimer?.()
|
|
1261
|
+
this.pollBackoffTimer = undefined
|
|
1262
|
+
}
|
|
1263
|
+
|
|
777
1264
|
/**
|
|
778
1265
|
* Re-arm from the ledger's current targets. Callers that just changed a
|
|
779
1266
|
* schedule (the host routes, the agent tools) invoke this after the write
|