@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -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 +23 -2
  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/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -4,8 +4,13 @@ import { reusableSessionId } from './core/session-reuse.ts'
4
4
  import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
5
5
  import { HostExecutionRunner, SessionLaunchError, promptText, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
6
6
  import { teammateName } from './core/subtask.ts'
7
+ import { workspaceOwningSession } from './core/workspace-target.ts'
7
8
  import { PowerInhibitor } from './power-inhibitor.ts'
8
9
  import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
10
+ import { TaskBoardExtensionRegistry } from './host/extension-registry.ts'
11
+ import type { TaskBoardExtension } from './core/extension.ts'
12
+ import type { ExecutionOutcome, TaskStatus } from './core/tasks.ts'
13
+ import { passedAttempt, resolveContract, verificationRequired, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
9
14
  import type { TaskPermission } from './core/handover.ts'
10
15
 
11
16
  /** One teammate the Host asks the Agent Teams service to spawn for a team run. */
@@ -50,6 +55,26 @@ const SESSION_POLL_MS = 5_000
50
55
  const RECOVERY_TOLERANCE_MS = 60_000
51
56
  /** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
52
57
  const MAX_TIMER_DELAY_MS = 2_147_483_647
58
+ /**
59
+ * Consecutive polls that may report one execution's session history as
60
+ * unreadable before it is reported failed. The roster poll runs every 5 s, so
61
+ * this is two minutes of a session that is NOT running: no turn is executing
62
+ * there and its history cannot be read, which means no verdict will ever
63
+ * arrive. Reporting that as a failure keeps a card — and every ancestor of it —
64
+ * out of the running column, which nothing else can rescue.
65
+ */
66
+ const UNREADABLE_SETTLE_POLLS = 24
67
+
68
+ /**
69
+ * Provenance of one cron-triggered cascade: when the rule fired and the zone
70
+ * its wall clock was read in. Passed into every launched prompt of that run so
71
+ * a scheduled job knows its own clock rather than inferring one.
72
+ */
73
+ export interface ScheduledRunContext {
74
+ triggeredAt: number
75
+ timeZone: string
76
+ cron: string
77
+ }
53
78
 
54
79
  /**
55
80
  * Actions that can move an armed trigger. Only these re-arm the native timer;
@@ -102,11 +127,27 @@ export class TaskBoardHostService {
102
127
  * conversation instead of prompting into a session it cannot see.
103
128
  */
104
129
  private idleSessionIds: ReadonlySet<string> | undefined
130
+ /**
131
+ * Consecutive unreadable-history polls per open execution (see
132
+ * {@link noteUnreadableInspection}). Cleared as soon as an inspection
133
+ * resolves, so a transient reader failure never fails a card.
134
+ */
135
+ private readonly unreadablePolls = new Map<string, number>()
105
136
  private preventIdleSleep = false
137
+ private readonly verificationSettings: () => VerificationSettings
138
+ private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
106
139
  private readonly team: TaskBoardTeamDispatcher | undefined
107
140
  private readonly timers: HostTimerFace
108
141
  private lastPowerJson = ''
109
142
  private readonly now: () => number
143
+ /** The deployment's workspaces, consulted to resolve a card that pins none. */
144
+ private readonly workspaceRegistry: TaskBoardWorkspaceRegistry | undefined
145
+ /**
146
+ * External provider extensions. The board owns task lifecycle and execution;
147
+ * a provider observes them through the registry's capability face and never
148
+ * reaches into the ledger itself.
149
+ */
150
+ readonly extensions: TaskBoardExtensionRegistry
110
151
 
111
152
  constructor(gateway: TypertGateway, options: {
112
153
  ledger?: HostTaskLedger
@@ -114,20 +155,42 @@ export class TaskBoardHostService {
114
155
  now?: () => number
115
156
  commandDispatcher?: SessionCommandDispatcher
116
157
  workspaceRegistry?: TaskBoardWorkspaceRegistry
117
- sessionDefaultPermission?: TaskPermission
158
+ /**
159
+ * Permission baseline of the confirmation gate: a fixed value, or a live
160
+ * resolver the board re-reads so a Host Settings change needs no remount.
161
+ */
162
+ sessionDefaultPermission?: TaskPermission | (() => TaskPermission)
118
163
  maxSubtaskDepth?: number
119
164
  team?: TaskBoardTeamDispatcher
120
165
  timers?: HostTimerFace
166
+ /**
167
+ * Live acceptance settings (volatile config reads). Absent keeps goal
168
+ * acceptance OFF for every execution this service opens, which is what a
169
+ * programmatic mount without the settings domain gets.
170
+ */
171
+ verificationSettings?: () => VerificationSettings
172
+ /** The host model catalog the acceptance contract resolves its route from. */
173
+ verificationCatalog?: () => Promise<ModelCatalogView | undefined>
121
174
  } = {}) {
122
175
  this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
123
176
  sessionDefaultPermission: options.sessionDefaultPermission,
124
177
  maxSubtaskDepth: options.maxSubtaskDepth,
125
178
  })
126
179
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
180
+ this.workspaceRegistry = options.workspaceRegistry
127
181
  this.team = options.team
128
182
  this.timers = options.timers ?? PROCESS_TIMERS
129
183
  this.power = options.power ?? new PowerInhibitor()
130
184
  this.now = options.now ?? Date.now
185
+ this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
186
+ this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
187
+ this.extensions = new TaskBoardExtensionRegistry({
188
+ ledger: this.ledger,
189
+ // Capability writes go through the board's own action path, so every
190
+ // board gate (content freeze, running lock, permission gate) still holds.
191
+ apply: (action, initiator) => this.applyBoardAction(crypto.randomUUID(), action, initiator),
192
+ now: this.now,
193
+ })
131
194
  installStreamErrorGuards()
132
195
  this.ledger.subscribe(() => {
133
196
  this.syncPowerReasons()
@@ -169,6 +232,9 @@ export class TaskBoardHostService {
169
232
  })
170
233
  }
171
234
  this.power.setEnabled(active && preventIdleSleep)
235
+ // Providers follow the board's master switch: the registry stops their
236
+ // provider-side work and hides their seats without clearing any data.
237
+ this.extensions.setEnabled(active)
172
238
  if (resumed) {
173
239
  this.schedulePoll()
174
240
  this.recoverSchedule()
@@ -182,6 +248,7 @@ export class TaskBoardHostService {
182
248
 
183
249
  snapshot(): TaskBoardSnapshot {
184
250
  const state = this.ledger.state()
251
+ const published = this.extensions.published()
185
252
  return {
186
253
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
187
254
  revision: state.revision,
@@ -191,6 +258,38 @@ export class TaskBoardHostService {
191
258
  sessionDefaultPermission: this.ledger.sessionDefaultPermission,
192
259
  maxSubtaskDepth: this.ledger.maxSubtaskDepth,
193
260
  teamRunAvailable: this.team !== undefined,
261
+ ...(Object.keys(published).length === 0 ? {} : { extensions: published }),
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Admit one external provider. Idempotent by extension id; the returned
267
+ * disposer releases it (its stored data stays).
268
+ */
269
+ registerExtension(extension: TaskBoardExtension): () => void {
270
+ return this.extensions.registerExtension(extension)
271
+ }
272
+
273
+ /**
274
+ * The acceptance configuration the settings card displays: the live settings,
275
+ * the contract they resolve to, and the host catalog the choices come from.
276
+ *
277
+ * The card shows the RESOLVED route, not the raw configuration, so "inherit
278
+ * host" reads as the concrete model and reasoning level the next execution
279
+ * will actually freeze, including any recorded effort fallback.
280
+ * @returns the resolved acceptance options.
281
+ */
282
+ async verificationOptions(): Promise<{
283
+ settings: VerificationSettings
284
+ contract: VerificationContract
285
+ catalog: ModelCatalogView
286
+ }> {
287
+ const settings = this.verificationSettings()
288
+ const catalog = await this.verificationCatalog()
289
+ return {
290
+ settings,
291
+ contract: resolveContract(settings, catalog),
292
+ catalog: catalog ?? { groups: [] },
194
293
  }
195
294
  }
196
295
 
@@ -205,8 +304,55 @@ export class TaskBoardHostService {
205
304
  return () => { this.listeners.delete(listener) }
206
305
  }
207
306
 
208
- apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot {
307
+ /**
308
+ * Give a root card created without a workspace pin the workspace its creator
309
+ * is in.
310
+ *
311
+ * An agent's `task_board_create` arrives with the calling session, and the
312
+ * GUI submits the session the main view shows, so the card lands where the
313
+ * work was asked for instead of where the Host process happens to run. A card
314
+ * that names a workspace keeps it, and a subtask keeps inheriting its lineage
315
+ * (the runner walks the ancestor chain for that).
316
+ *
317
+ * The initiator is client-asserted, so it only chooses among workspaces the
318
+ * deployment already knows: it can neither register one nor reach outside the
319
+ * list the user sees.
320
+ * @param action - the action about to be applied.
321
+ * @param initiator - the session that issued the action, when one was asserted.
322
+ * @returns the action, with an inherited workspace on a root creation.
323
+ */
324
+ private withInheritedWorkspace(action: TaskBoardAction, initiator?: string): TaskBoardAction {
325
+ if (action.kind !== 'create' || initiator === undefined || initiator === '') return action
326
+ const input = action.input
327
+ // A subtask inherits its lineage instead, and an explicit pin is the user's.
328
+ if ((input.parentId ?? '').trim() !== '') return action
329
+ if ((input.workspaceId ?? '').trim() !== '') return action
330
+ const workspaceId = workspaceOwningSession(this.workspaceRegistry?.list() ?? [], initiator)
331
+ if (workspaceId === undefined) return action
332
+ return { ...action, input: { ...input, workspaceId } }
333
+ }
334
+
335
+ apply(requestId: string, action: Extract<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): Promise<TaskBoardSnapshot>
336
+ apply(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot
337
+ apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot>
338
+ apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot> {
209
339
  if (!this.active) throw new Error('task board is disabled')
340
+ // Provider actions never touch the ledger directly: the registry routes the
341
+ // action to the owning extension, whose own capability calls come back
342
+ // through applyBoardAction and every board gate below.
343
+ if (action.kind === 'extension-action') {
344
+ return this.handleExtensionAction(action)
345
+ }
346
+ return this.applyBoardAction(requestId, action, initiator)
347
+ }
348
+
349
+ /**
350
+ * Apply one board-owned action with every gate intact. Provider capability
351
+ * calls and the same-origin wire both land here, so the two surfaces can
352
+ * never disagree about a card.
353
+ */
354
+ private applyBoardAction(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot {
355
+ const before = this.statusMap()
210
356
  // Fail closed before the ledger opens anything: a card opted into team
211
357
  // execution cannot run in a deployment that serves no Agent Teams service,
212
358
  // and silently degrading it to a plain cascade would misreport the work.
@@ -214,12 +360,14 @@ export class TaskBoardHostService {
214
360
  const task = this.ledger.state().tasks.find(item => item.id === action.taskId)
215
361
  if (task?.teamRun === true) throw new Error('Agent Teams is unavailable in this deployment')
216
362
  }
217
- const result = this.ledger.applyRequest(requestId, action, initiator)
363
+ const result = this.ledger.applyRequest(requestId, this.withInheritedWorkspace(action, initiator), initiator)
218
364
  if (result.runs !== undefined) this.dispatchRuns(result.runs)
219
365
  // A committed schedule write (create / update / toggle / delete) moves the
220
366
  // nearest trigger; re-arm on every action so a newly enabled schedule fires
221
367
  // at its own instant without waiting for the previous target to elapse.
222
368
  if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
369
+ this.emitStatusChanges(before, initiator)
370
+ if (action.kind === 'delete') this.extensions.emitTaskDeleted({ taskId: action.taskId })
223
371
  return {
224
372
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
225
373
  revision: result.state.revision,
@@ -229,8 +377,39 @@ export class TaskBoardHostService {
229
377
  }
230
378
  }
231
379
 
380
+ /** Route one provider action and answer the resulting board snapshot. */
381
+ private async handleExtensionAction(action: Extract<TaskBoardAction, { kind: 'extension-action' }>): Promise<TaskBoardSnapshot> {
382
+ await this.extensions.handleAction({
383
+ extensionId: action.extensionId,
384
+ action: action.action,
385
+ ...(action.taskId === undefined ? {} : { taskId: action.taskId }),
386
+ ...(action.payload === undefined ? {} : { payload: action.payload }),
387
+ })
388
+ return this.snapshot()
389
+ }
390
+
391
+ /** Current task id -> status map, for change detection around a mutation. */
392
+ private statusMap(): Map<string, TaskStatus> {
393
+ return new Map(this.ledger.allTasks().map(task => [task.id, task.status]))
394
+ }
395
+
396
+ /** Emit a status change for every task whose column moved, isolated per callback. */
397
+ private emitStatusChanges(before: Map<string, TaskStatus>, initiator?: string): void {
398
+ for (const task of this.ledger.allTasks()) {
399
+ const previous = before.get(task.id)
400
+ if (previous === undefined || previous === task.status) continue
401
+ this.extensions.emitStatusChanged({
402
+ taskId: task.id,
403
+ status: task.status,
404
+ previous,
405
+ ...(initiator === undefined || initiator === '' ? {} : { initiator }),
406
+ })
407
+ }
408
+ }
409
+
232
410
  dispose(): void {
233
411
  this.disposed = true
412
+ this.extensions.dispose()
234
413
  this.clearScheduleTimer()
235
414
  this.pollTimer?.()
236
415
  this.pollTimer = undefined
@@ -239,7 +418,7 @@ export class TaskBoardHostService {
239
418
  this.listeners.clear()
240
419
  }
241
420
 
242
- private async launch(opened: OpenedRun, others: readonly OpenedRun[] = []): Promise<void> {
421
+ private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
243
422
  try {
244
423
  // A team run always mints a fresh Lead session: teammates are immutable
245
424
  // children of that session, so reusing an older one would collide on
@@ -248,25 +427,60 @@ export class TaskBoardHostService {
248
427
  const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
249
428
  // Both modes tell the launched agent what else this run opens; only a team
250
429
  // run names teammates, because only then does this session own them.
251
- const promptContext = others.length === 0 ? undefined : {
252
- peers: others.map(other => ({
253
- id: other.task.id,
254
- title: other.task.title,
255
- ...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id) } : {}),
256
- })),
430
+ const peers = others.length === 0 ? undefined : others.map(other => ({
431
+ id: other.task.id,
432
+ title: other.task.title,
433
+ ...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id, other.task.id) } : {}),
434
+ }))
435
+ // A cron-triggered run additionally states its own firing instant and
436
+ // rule zone, so a scheduled job can resolve "today" without guessing.
437
+ const promptContext = peers === undefined && schedule === undefined ? undefined : {
438
+ ...(peers === undefined ? {} : { peers }),
257
439
  ...(team ? { team: true } : {}),
440
+ ...(schedule === undefined ? {} : { schedule }),
258
441
  }
442
+ // Freeze this execution's acceptance contract BEFORE the session is
443
+ // prompted: a settings change made while the run is in flight must not
444
+ // retrofit the rule that will judge it. A card that opted out resolves the
445
+ // same contract from an OFF switch: the board-wide switch decides the
446
+ // default, the card decides this execution, and both are read here.
447
+ const settings = this.verificationSettings()
448
+ // The card's opt-out is a DISTINCT reason, not the board switch: the
449
+ // report must be able to say which of the two turned the gate off.
450
+ const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
451
+ const contract = resolveContract(
452
+ { ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
453
+ await this.verificationCatalog(),
454
+ )
455
+ const initial: ExecutionVerification = {
456
+ contract,
457
+ attempts: [],
458
+ applicability: team ? 'team-member' : !contract.enabled ? skippedBy : 'goal-unavailable',
459
+ }
460
+ this.ledger.setVerification(opened.task.id, opened.execution.id, initial)
461
+ let attached: string | undefined
259
462
  const sessionId = await this.runner.launch(opened.task, {
260
463
  ...(reuseSessionId === undefined ? {} : { reuseSessionId }),
261
464
  ...(promptContext === undefined ? {} : { promptContext }),
465
+ // Bind the session to the execution before the prompt is queued, so the
466
+ // completion gate can never observe the agent without its binding.
467
+ onSession: (id) => {
468
+ attached = id
469
+ this.ledger.attachSession(opened.task.id, opened.execution.id, id)
470
+ },
471
+ onGoalArmed: (armed) => {
472
+ this.setApplicability(opened.task.id, opened.execution.id, team
473
+ ? 'team-member'
474
+ : !contract.enabled ? skippedBy : armed ? 'enforced' : 'goal-unavailable')
475
+ },
262
476
  })
263
- this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
477
+ if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
264
478
  if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
265
479
  } catch (error) {
266
480
  if (error instanceof SessionLaunchError) {
267
481
  this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
268
482
  }
269
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
483
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
270
484
  }
271
485
  }
272
486
 
@@ -286,23 +500,23 @@ export class TaskBoardHostService {
286
500
  private async spawnTeammate(opened: OpenedRun, leadSessionId: string): Promise<void> {
287
501
  const team = this.team
288
502
  if (team === undefined) {
289
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
503
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
290
504
  return
291
505
  }
292
506
  try {
293
507
  const member = await team.spawn({
294
508
  leadSessionId,
295
- name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
509
+ name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id, opened.task.id),
296
510
  description: opened.task.title,
297
511
  prompt: promptText(opened.task),
298
512
  })
299
513
  if (member.sessionId === undefined || member.sessionId === '') {
300
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
514
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
301
515
  return
302
516
  }
303
517
  this.ledger.attachSession(opened.task.id, opened.execution.id, member.sessionId)
304
518
  } catch (error) {
305
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
519
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
306
520
  }
307
521
  }
308
522
 
@@ -321,6 +535,12 @@ export class TaskBoardHostService {
321
535
  return
322
536
  }
323
537
  this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
538
+ // Fold whatever the board can already decide before spending inspection
539
+ // RPCs: a team run whose Lead recorded its verdict, and any lineage whose
540
+ // members are all settled. Idempotent, so an already folded board is free.
541
+ const foldedBefore = this.statusMap()
542
+ this.ledger.finalizeReadyRuns()
543
+ this.emitStatusChanges(foldedBefore)
324
544
  // Read after the RPC so executions attached while it was in flight are
325
545
  // included in this pass, matching the former full-state snapshot timing.
326
546
  const runtime = this.ledger.runtimeView()
@@ -341,14 +561,99 @@ export class TaskBoardHostService {
341
561
  ): Promise<void> {
342
562
  for (const execution of executions) {
343
563
  if (execution.sessionId === undefined) continue
564
+ const record = this.ledger.getTask(execution.taskId)?.executions.find(entry => entry.id === execution.executionId)
565
+ const verification = record?.verification
566
+ // A cycle the gate already closed is decided here, without another
567
+ // inspection: the goal may still be active (or un-blockable) exactly
568
+ // because the acceptance failed, and the card must not stay in the
569
+ // running column waiting for a verdict that is already recorded.
570
+ if (verification?.failedReason !== undefined) {
571
+ this.settleAndNotify(execution.taskId, execution.executionId, 'failed', verification.failedReason)
572
+ continue
573
+ }
344
574
  try {
345
- const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
346
- if (result.outcome === 'pending') continue
347
- this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
575
+ // A team member's turn is read even while the roster calls its session
576
+ // running: a durable teammate never goes idle for good.
577
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions, {
578
+ whileRunning: execution.teamMember,
579
+ })
580
+ if (result.outcome === 'pending') {
581
+ if (result.unreadable === true) this.noteUnreadableInspection(execution, result.reason)
582
+ else this.unreadablePolls.delete(execution.executionId)
583
+ continue
584
+ }
585
+ this.unreadablePolls.delete(execution.executionId)
586
+ // Forced acceptance: the old fallback paths — a completed turn, a
587
+ // paused goal, an unreadable projection, a manual settle — must never
588
+ // be mistaken for a verified success. Only a matching pass record
589
+ // settles this execution as succeeded.
590
+ if (result.outcome === 'succeeded' && verificationRequired(verification) && passedAttempt(verification) === undefined) {
591
+ this.settleAndNotify(
592
+ execution.taskId,
593
+ execution.executionId,
594
+ 'failed',
595
+ 'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。',
596
+ )
597
+ continue
598
+ }
599
+ this.settleAndNotify(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
348
600
  } catch {
349
601
  // A transient inspection failure never settles a running execution.
350
602
  }
351
603
  }
604
+ const open = new Set(executions.map(execution => execution.executionId))
605
+ for (const executionId of [...this.unreadablePolls.keys()]) {
606
+ if (!open.has(executionId)) this.unreadablePolls.delete(executionId)
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Count one poll whose session history could not be read. A reader failure is
612
+ * not progress: the session is not running (the runner only reads history for
613
+ * one that is idle), so nothing will ever change that verdict. After
614
+ * {@link UNREADABLE_SETTLE_POLLS} consecutive polls the execution is reported
615
+ * failed with the recorded reason, instead of holding its card — and every
616
+ * ancestor of it — in the running column with no way out. The first poll of
617
+ * each streak is logged, so the Host log names the session.
618
+ */
619
+ private settleAndNotify(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
620
+ const before = this.ledger.getTask(taskId)
621
+ if (before?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) {
622
+ // Already settled: the ledger would no-op, and re-emitting would report
623
+ // the same settlement twice.
624
+ this.ledger.settle(taskId, executionId, outcome, error)
625
+ return
626
+ }
627
+ const previousStatus = before?.status
628
+ this.ledger.settle(taskId, executionId, outcome, error)
629
+ const task = this.ledger.getTask(taskId)
630
+ if (task !== undefined && previousStatus !== undefined && previousStatus !== task.status) {
631
+ this.extensions.emitStatusChanged({ taskId, status: task.status, previous: previousStatus })
632
+ }
633
+ this.extensions.emitExecutionSettled({
634
+ taskId,
635
+ executionId,
636
+ outcome,
637
+ ...(error === undefined ? {} : { error }),
638
+ })
639
+ }
640
+
641
+ private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
642
+ const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1
643
+ this.unreadablePolls.set(execution.executionId, polls)
644
+ const detail = reason ?? 'no reason reported'
645
+ if (polls === 1) {
646
+ safeConsoleError('[dsh-task-board] execution session ' + (execution.sessionId ?? 'unknown')
647
+ + ' history is unreadable; it stays pending for up to ' + UNREADABLE_SETTLE_POLLS + ' polls: ' + detail)
648
+ }
649
+ if (polls < UNREADABLE_SETTLE_POLLS) return
650
+ this.unreadablePolls.delete(execution.executionId)
651
+ this.settleAndNotify(
652
+ execution.taskId,
653
+ execution.executionId,
654
+ 'failed',
655
+ 'execution session history is unreadable (' + UNREADABLE_SETTLE_POLLS + ' consecutive polls); the outcome cannot be determined: ' + detail,
656
+ )
352
657
  }
353
658
 
354
659
  /** Drop the armed schedule timer and forget its target. */
@@ -410,10 +715,15 @@ export class TaskBoardHostService {
410
715
  this.recoverSchedule()
411
716
  return
412
717
  }
718
+ const before = this.statusMap()
413
719
  for (const schedule of this.ledger.dueSchedules(now)) {
414
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
415
- this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now))
720
+ const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone)
721
+ this.dispatchRuns(
722
+ this.ledger.openScheduled(schedule.taskId, next, now),
723
+ { triggeredAt: now, timeZone: schedule.timeZone, cron: schedule.cron },
724
+ )
416
725
  }
726
+ this.emitStatusChanges(before)
417
727
  // The launched run (or the rolled-forward target) moved every due schedule,
418
728
  // so the next nearest target has to be recomputed from the ledger.
419
729
  this.armSchedule()
@@ -430,18 +740,28 @@ export class TaskBoardHostService {
430
740
  * others back. A team run's members are spawned inside the root's Lead
431
741
  * session instead, once that session exists.
432
742
  */
433
- private dispatchRuns(runs: readonly OpenedRun[]): void {
743
+ private dispatchRuns(runs: readonly OpenedRun[], schedule?: ScheduledRunContext): void {
434
744
  if (runs.length === 0) return
435
745
  const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
436
746
  const others = runs.filter(run => run !== root)
437
- this.scheduleLaunch(root, others)
747
+ this.scheduleLaunch(root, others, schedule)
438
748
  for (const run of others) {
439
- if (run.dispatch !== 'teammate') this.scheduleLaunch(run)
749
+ if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule)
440
750
  }
441
751
  }
442
752
 
443
- private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = []): void {
444
- void this.launch(opened, others).catch(error => {
753
+ /**
754
+ * Move one execution's applicability without touching its frozen contract or
755
+ * its recorded attempts (used when the run's goal form becomes known).
756
+ */
757
+ private setApplicability(taskId: string, executionId: string, applicability: ExecutionVerification['applicability']): void {
758
+ const verification = this.ledger.getTask(taskId)?.executions.find(entry => entry.id === executionId)?.verification
759
+ if (verification === undefined || verification.applicability === applicability) return
760
+ this.ledger.setVerification(taskId, executionId, { ...verification, applicability })
761
+ }
762
+
763
+ private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
764
+ void this.launch(opened, others, schedule).catch(error => {
445
765
  safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
446
766
  })
447
767
  }