@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6

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