@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.
Files changed (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -18
  3. package/README.zh.md +34 -18
  4. package/lib/client.js +2804 -1024
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2576 -416
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/index.d.ts +4 -5
  15. package/lib/types/client/locales.d.ts +81 -0
  16. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  17. package/lib/types/client/schedule-zone.d.ts +50 -0
  18. package/lib/types/core/card-defaults.d.ts +78 -0
  19. package/lib/types/core/controller.d.ts +41 -17
  20. package/lib/types/core/launch-width.d.ts +31 -0
  21. package/lib/types/core/poll-cadence.d.ts +31 -0
  22. package/lib/types/core/reasoning-effort.d.ts +57 -0
  23. package/lib/types/core/subtask.d.ts +30 -8
  24. package/lib/types/core/tasks.d.ts +163 -14
  25. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  26. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  27. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  28. package/lib/types/core/verification-budget.d.ts +52 -0
  29. package/lib/types/core/verification.d.ts +376 -9
  30. package/lib/types/host/launch-admission.d.ts +72 -0
  31. package/lib/types/host/verification-gate.d.ts +24 -0
  32. package/lib/types/host/verification-runner.d.ts +38 -7
  33. package/lib/types/host-ledger.d.ts +91 -11
  34. package/lib/types/host-runner.d.ts +111 -13
  35. package/lib/types/host-service.d.ts +199 -8
  36. package/lib/types/index.d.ts +141 -0
  37. package/lib/types/protocol.d.ts +63 -12
  38. package/package.json +29 -33
  39. package/src/client/CardDefaultPresetField.tsx +101 -0
  40. package/src/client/TaskBoardSettingsCard.tsx +288 -17
  41. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  42. package/src/client/board/NewTaskModal.tsx +237 -60
  43. package/src/client/board/ReasoningEffortField.tsx +50 -0
  44. package/src/client/board/TaskCard.tsx +3 -3
  45. package/src/client/board/TaskDetail.tsx +261 -24
  46. package/src/client/board/VerificationReport.tsx +132 -13
  47. package/src/client/board/model-options.ts +178 -0
  48. package/src/client/board/preset-label.ts +32 -0
  49. package/src/client/board/preset-roster.ts +58 -0
  50. package/src/client/board/status-key.ts +3 -0
  51. package/src/client/board/team-run-promotion.ts +39 -0
  52. package/src/client/index.ts +105 -108
  53. package/src/client/locales.ts +171 -6
  54. package/src/client/plugin-card-seat.ts +7 -0
  55. package/src/client/schedule-zone.ts +100 -2
  56. package/src/client/settings-entry-form.ts +0 -1
  57. package/src/core/card-defaults.ts +132 -0
  58. package/src/core/controller.ts +55 -16
  59. package/src/core/launch-width.ts +38 -0
  60. package/src/core/poll-cadence.ts +41 -0
  61. package/src/core/reasoning-effort.ts +92 -0
  62. package/src/core/store.ts +69 -15
  63. package/src/core/subtask.ts +48 -13
  64. package/src/core/tasks.ts +213 -39
  65. package/src/core/use-cases/task-create.ts +57 -25
  66. package/src/core/use-cases/task-schedule.ts +190 -25
  67. package/src/core/use-cases/task-update.ts +9 -4
  68. package/src/core/verification-budget.ts +115 -0
  69. package/src/core/verification.ts +785 -17
  70. package/src/host/agent-tools.ts +127 -29
  71. package/src/host/launch-admission.ts +140 -0
  72. package/src/host/verification-gate.ts +172 -16
  73. package/src/host/verification-runner.ts +207 -40
  74. package/src/host-ledger.ts +346 -48
  75. package/src/host-runner.ts +234 -35
  76. package/src/host-service.ts +539 -52
  77. package/src/index.ts +250 -7
  78. package/src/protocol.ts +160 -32
  79. package/lib/invariant.js +0 -6
  80. package/lib/types/client/board-card-seat.d.ts +0 -34
  81. package/lib/types/invariant.d.ts +0 -3
  82. package/src/client/board-card-seat.ts +0 -102
  83. package/src/invariant.ts +0 -4
@@ -1,16 +1,19 @@
1
1
  import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
2
- import { nextRunAtMs } from './core/schedule.ts'
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 { ExecutionOutcome, TaskStatus } from './core/tasks.ts'
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
- /** Session-roster poll cadence — the one recurring Host timer this service still holds. */
47
- const SESSION_POLL_MS = 5_000
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
- cron: string
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 move an armed trigger. Only these re-arm the native timer;
81
- * an unrelated card edit leaves the pending fire untouched.
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
- * Ids the last roster poll saw as present and idle; undefined while the
125
- * roster is unknown. Session reuse (issue #1419) requires this positive
126
- * evidence, so a launch before the first successful poll mints a fresh
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 idleSessionIds: ReadonlySet<string> | undefined
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() }, SESSION_POLL_MS)
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
- setConfiguration(active: boolean, preventIdleSleep: boolean): void {
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 schedule write (create / update / toggle / delete) moves the
366
- // nearest trigger; re-arm on every action so a newly enabled schedule fires
367
- // at its own instant without waiting for the previous target to elapse.
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 : reusableSessionId(opened.task, this.idleSessionIds)
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
- const settings = this.verificationSettings()
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
- await this.verificationCatalog(),
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 : 'goal-unavailable',
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' : 'goal-unavailable')
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
- if (error instanceof SessionLaunchError) {
481
- this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
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
- void this.spawnTeammate(opened, leadSessionId).catch(error => {
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
- private async pollSessions(): Promise<void> {
524
- if (this.disposed) return
525
- if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return
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: this.ledger.armedScheduleCount(),
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 runtime = this.ledger.runtimeView()
830
+ const current = this.ledger.runtimeView()
547
831
  this.power.updateReasons({
548
832
  runningSessions: running.count,
549
- armedSchedules: runtime.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, runtime.openExecutions)
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
- 'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。',
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
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone)
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, next, now),
723
- { triggeredAt: now, timeZone: schedule.timeZone, cron: schedule.cron },
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.scheduleLaunch(root, others, schedule)
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
- void this.launch(opened, others, schedule).catch(error => {
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().catch(error => {
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