@linxin666/dsh-client-ui-task-board 0.4.5 → 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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +20 -12
  3. package/README.zh.md +20 -12
  4. package/lib/client.js +1327 -229
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1909 -348
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
  8. package/lib/types/client/index.d.ts +4 -5
  9. package/lib/types/client/locales.d.ts +65 -0
  10. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  11. package/lib/types/client/schedule-zone.d.ts +50 -0
  12. package/lib/types/core/controller.d.ts +10 -11
  13. package/lib/types/core/poll-cadence.d.ts +31 -0
  14. package/lib/types/core/tasks.d.ts +144 -14
  15. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  16. package/lib/types/core/verification-budget.d.ts +52 -0
  17. package/lib/types/core/verification.d.ts +376 -9
  18. package/lib/types/host/verification-gate.d.ts +24 -0
  19. package/lib/types/host/verification-runner.d.ts +38 -7
  20. package/lib/types/host-ledger.d.ts +82 -7
  21. package/lib/types/host-runner.d.ts +32 -9
  22. package/lib/types/host-service.d.ts +117 -9
  23. package/lib/types/index.d.ts +54 -0
  24. package/lib/types/protocol.d.ts +50 -12
  25. package/package.json +2 -6
  26. package/src/client/TaskBoardSettingsCard.tsx +74 -1
  27. package/src/client/board/NewTaskModal.tsx +151 -20
  28. package/src/client/board/TaskCard.tsx +3 -3
  29. package/src/client/board/TaskDetail.tsx +219 -18
  30. package/src/client/board/VerificationReport.tsx +132 -13
  31. package/src/client/board/status-key.ts +3 -0
  32. package/src/client/index.ts +43 -50
  33. package/src/client/locales.ts +137 -4
  34. package/src/client/plugin-card-seat.ts +7 -0
  35. package/src/client/schedule-zone.ts +100 -2
  36. package/src/core/controller.ts +12 -8
  37. package/src/core/poll-cadence.ts +41 -0
  38. package/src/core/store.ts +64 -15
  39. package/src/core/tasks.ts +193 -39
  40. package/src/core/use-cases/task-create.ts +30 -9
  41. package/src/core/use-cases/task-schedule.ts +190 -25
  42. package/src/core/use-cases/task-update.ts +3 -3
  43. package/src/core/verification-budget.ts +115 -0
  44. package/src/core/verification.ts +785 -17
  45. package/src/host/agent-tools.ts +106 -21
  46. package/src/host/verification-gate.ts +172 -16
  47. package/src/host/verification-runner.ts +207 -40
  48. package/src/host-ledger.ts +328 -42
  49. package/src/host-runner.ts +52 -19
  50. package/src/host-service.ts +299 -36
  51. package/src/index.ts +101 -5
  52. package/src/protocol.ts +132 -28
  53. package/lib/invariant.js +0 -6
  54. package/lib/types/client/board-card-seat.d.ts +0 -34
  55. package/lib/types/invariant.d.ts +0 -3
  56. package/src/client/board-card-seat.ts +0 -102
  57. package/src/invariant.ts +0 -4
@@ -1,5 +1,5 @@
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'
@@ -9,8 +9,8 @@ import { PowerInhibitor } from './power-inhibitor.ts'
9
9
  import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
10
10
  import { TaskBoardExtensionRegistry } from './host/extension-registry.ts'
11
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'
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'
14
14
  import type { TaskPermission } from './core/handover.ts'
15
15
 
16
16
  /** One teammate the Host asks the Agent Teams service to spawn for a team run. */
@@ -43,8 +43,8 @@ export interface TaskBoardTeamDispatcher {
43
43
  spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>
44
44
  }
45
45
 
46
- /** Session-roster poll cadence — the one recurring Host timer this service still holds. */
47
- 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
48
48
  /**
49
49
  * How late an armed schedule fire may be before it counts as a resume rather
50
50
  * than a normal occurrence. The schedule timer is armed AT the next due
@@ -65,6 +65,31 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647
65
65
  */
66
66
  const UNREADABLE_SETTLE_POLLS = 24
67
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
+
68
93
  /**
69
94
  * Provenance of one cron-triggered cascade: when the rule fired and the zone
70
95
  * its wall clock was read in. Passed into every launched prompt of that run so
@@ -73,7 +98,10 @@ const UNREADABLE_SETTLE_POLLS = 24
73
98
  export interface ScheduledRunContext {
74
99
  triggeredAt: number
75
100
  timeZone: string
76
- 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
77
105
  }
78
106
 
79
107
  /**
@@ -113,6 +141,12 @@ export class TaskBoardHostService {
113
141
  private readonly listeners = new Set<() => void>()
114
142
  /** The one recurring timer: the session-roster poll. */
115
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
116
150
  /** The armed schedule timer, if a trigger is pending. */
117
151
  private scheduleTimer: (() => void) | undefined
118
152
  /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
@@ -120,13 +154,6 @@ export class TaskBoardHostService {
120
154
  private disposed = false
121
155
  private pollInFlight = false
122
156
  private active = true
123
- /**
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.
128
- */
129
- private idleSessionIds: ReadonlySet<string> | undefined
130
157
  /**
131
158
  * Consecutive unreadable-history polls per open execution (see
132
159
  * {@link noteUnreadableInspection}). Cleared as soon as an inspection
@@ -138,6 +165,8 @@ export class TaskBoardHostService {
138
165
  private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
139
166
  private readonly team: TaskBoardTeamDispatcher | undefined
140
167
  private readonly timers: HostTimerFace
168
+ /** Live reader of the global native-/goal switch; frozen per execution. */
169
+ private readonly goalRunEnabled: () => boolean
141
170
  private lastPowerJson = ''
142
171
  private readonly now: () => number
143
172
  /** The deployment's workspaces, consulted to resolve a card that pins none. */
@@ -163,6 +192,14 @@ export class TaskBoardHostService {
163
192
  maxSubtaskDepth?: number
164
193
  team?: TaskBoardTeamDispatcher
165
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
166
203
  /**
167
204
  * Live acceptance settings (volatile config reads). Absent keeps goal
168
205
  * acceptance OFF for every execution this service opens, which is what a
@@ -182,6 +219,7 @@ export class TaskBoardHostService {
182
219
  this.timers = options.timers ?? PROCESS_TIMERS
183
220
  this.power = options.power ?? new PowerInhibitor()
184
221
  this.now = options.now ?? Date.now
222
+ this.goalRunEnabled = options.goalRunEnabled ?? (() => false)
185
223
  this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
186
224
  this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
187
225
  this.extensions = new TaskBoardExtensionRegistry({
@@ -210,8 +248,11 @@ export class TaskBoardHostService {
210
248
  start(): void {
211
249
  if (this.disposed || this.pollTimer !== undefined) return
212
250
  this.syncPowerReasons()
213
- this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, SESSION_POLL_MS)
251
+ this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, sessionPollMs(this.sessionPollSeconds))
214
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()
215
256
  // Boot is a recovery point: an occurrence armed while the Host was down is
216
257
  // not replayed, and each schedule rolls to its next future target. A
217
258
  // schedule the Board should have served while running is then armed
@@ -219,10 +260,30 @@ export class TaskBoardHostService {
219
260
  this.recoverSchedule()
220
261
  }
221
262
 
222
- 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 {
223
273
  const resumed = !this.active && active
224
274
  this.active = active
225
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
+ }
226
287
  if (resumed) {
227
288
  const current = this.power.snapshot()
228
289
  this.power.updateReasons({
@@ -258,6 +319,10 @@ export class TaskBoardHostService {
258
319
  sessionDefaultPermission: this.ledger.sessionDefaultPermission,
259
320
  maxSubtaskDepth: this.ledger.maxSubtaskDepth,
260
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(),
261
326
  ...(Object.keys(published).length === 0 ? {} : { extensions: published }),
262
327
  }
263
328
  }
@@ -411,6 +476,7 @@ export class TaskBoardHostService {
411
476
  this.disposed = true
412
477
  this.extensions.dispose()
413
478
  this.clearScheduleTimer()
479
+ this.clearPollBackoff()
414
480
  this.pollTimer?.()
415
481
  this.pollTimer = undefined
416
482
  this.power.dispose()
@@ -419,12 +485,14 @@ export class TaskBoardHostService {
419
485
  }
420
486
 
421
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
422
490
  try {
423
491
  // A team run always mints a fresh Lead session: teammates are immutable
424
492
  // children of that session, so reusing an older one would collide on
425
493
  // their names and orphan the previous team.
426
494
  const team = opened.task.teamRun === true
427
- const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
495
+ const reuseSessionId = team ? undefined : await this.reuseSessionFor(opened.task)
428
496
  // Both modes tell the launched agent what else this run opens; only a team
429
497
  // run names teammates, because only then does this session own them.
430
498
  const peers = others.length === 0 ? undefined : others.map(other => ({
@@ -445,6 +513,13 @@ export class TaskBoardHostService {
445
513
  // same contract from an OFF switch: the board-wide switch decides the
446
514
  // default, the card decides this execution, and both are read here.
447
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
448
523
  // The card's opt-out is a DISTINCT reason, not the board switch: the
449
524
  // report must be able to say which of the two turned the gate off.
450
525
  const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
@@ -452,16 +527,28 @@ export class TaskBoardHostService {
452
527
  { ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
453
528
  await this.verificationCatalog(),
454
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'
455
540
  const initial: ExecutionVerification = {
456
541
  contract,
457
542
  attempts: [],
458
- applicability: team ? 'team-member' : !contract.enabled ? skippedBy : 'goal-unavailable',
543
+ applicability: team ? 'team-member' : !contract.enabled ? skippedBy : notArmed,
459
544
  }
460
545
  this.ledger.setVerification(opened.task.id, opened.execution.id, initial)
461
- let attached: string | undefined
462
546
  const sessionId = await this.runner.launch(opened.task, {
463
547
  ...(reuseSessionId === undefined ? {} : { reuseSessionId }),
464
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,
465
552
  // Bind the session to the execution before the prompt is queued, so the
466
553
  // completion gate can never observe the agent without its binding.
467
554
  onSession: (id) => {
@@ -471,14 +558,22 @@ export class TaskBoardHostService {
471
558
  onGoalArmed: (armed) => {
472
559
  this.setApplicability(opened.task.id, opened.execution.id, team
473
560
  ? 'team-member'
474
- : !contract.enabled ? skippedBy : armed ? 'enforced' : 'goal-unavailable')
561
+ : !contract.enabled ? skippedBy : armed ? 'enforced' : notArmed)
475
562
  },
476
563
  })
477
564
  if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
478
565
  if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
479
566
  } catch (error) {
480
- if (error instanceof SessionLaunchError) {
481
- 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())
482
577
  }
483
578
  this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
484
579
  }
@@ -520,21 +615,42 @@ export class TaskBoardHostService {
520
615
  }
521
616
  }
522
617
 
523
- private async pollSessions(): Promise<void> {
524
- if (this.disposed) return
525
- 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
+ }
526
644
  const running = await this.runner.listRunning()
527
645
  const previous = this.power.snapshot()
528
646
  if (!running.known) {
529
- this.idleSessionIds = undefined
530
647
  this.power.updateReasons({
531
648
  runningSessions: previous.runningSessions,
532
- armedSchedules: this.ledger.armedScheduleCount(),
649
+ armedSchedules: runtime.armedSchedules,
533
650
  sessionStateKnown: false,
534
651
  })
535
- return
652
+ return false
536
653
  }
537
- this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
538
654
  // Fold whatever the board can already decide before spending inspection
539
655
  // RPCs: a team run whose Lead recorded its verdict, and any lineage whose
540
656
  // members are all settled. Idempotent, so an already folded board is free.
@@ -543,15 +659,44 @@ export class TaskBoardHostService {
543
659
  this.emitStatusChanges(foldedBefore)
544
660
  // Read after the RPC so executions attached while it was in flight are
545
661
  // included in this pass, matching the former full-state snapshot timing.
546
- const runtime = this.ledger.runtimeView()
662
+ const current = this.ledger.runtimeView()
547
663
  this.power.updateReasons({
548
664
  runningSessions: running.count,
549
- armedSchedules: runtime.armedSchedules,
665
+ armedSchedules: current.armedSchedules,
550
666
  sessionStateKnown: true,
551
667
  })
552
668
  // No unconditional emit here: real changes already emit through the
553
669
  // ledger subscription (settles) and the gated power listener above.
554
- 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)))
555
700
  }
556
701
 
557
702
  /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
@@ -588,11 +733,20 @@ export class TaskBoardHostService {
588
733
  // be mistaken for a verified success. Only a matching pass record
589
734
  // settles this execution as succeeded.
590
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)`.
591
741
  this.settleAndNotify(
592
742
  execution.taskId,
593
743
  execution.executionId,
594
744
  'failed',
595
- 'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。',
745
+ verificationNeverInvoked(verification)
746
+ ? NEVER_INVOKED_VERIFICATION_REASON
747
+ : verificationOnlyInvalid(verification)
748
+ ? INVALID_ONLY_VERIFICATION_REASON
749
+ : NO_MATCHING_PASS_VERIFICATION_REASON,
596
750
  )
597
751
  continue
598
752
  }
@@ -636,6 +790,73 @@ export class TaskBoardHostService {
636
790
  outcome,
637
791
  ...(error === undefined ? {} : { error }),
638
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
+ }
639
860
  }
640
861
 
641
862
  private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
@@ -717,10 +938,16 @@ export class TaskBoardHostService {
717
938
  }
718
939
  const before = this.statusMap()
719
940
  for (const schedule of this.ledger.dueSchedules(now)) {
720
- 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.
721
943
  this.dispatchRuns(
722
- this.ledger.openScheduled(schedule.taskId, next, now),
723
- { 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
+ },
724
951
  )
725
952
  }
726
953
  this.emitStatusChanges(before)
@@ -769,11 +996,47 @@ export class TaskBoardHostService {
769
996
  private schedulePoll(): void {
770
997
  if (this.pollInFlight || this.disposed) return
771
998
  this.pollInFlight = true
772
- 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()
773
1006
  safeConsoleError('[dsh-task-board] session polling failed', error)
774
1007
  }).finally(() => { this.pollInFlight = false })
775
1008
  }
776
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
+
777
1040
  /**
778
1041
  * Re-arm from the ledger's current targets. Callers that just changed a
779
1042
  * schedule (the host routes, the agent tools) invoke this after the write