@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
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
6
  import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
7
7
  import { isTaskRecord, parseLedger } from './core/store.ts'
8
- import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
8
+ import { canMoveManually, hasOpenExecution, retainRecentExecutions, scheduleExhausted, scheduleRunBudget, settleExecution, settledStatus, startExecution, withSchedule, withStatus, type ExecutionOutcome, type ExecutionRecord, type ScheduleMode, type ScheduleStopReason, type TaskRecord } from './core/tasks.ts'
9
9
  import {
10
10
  DEFAULT_SUBTASK_DEPTH,
11
11
  cascadeTargets,
@@ -18,11 +18,13 @@ import {
18
18
  import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
19
19
  import { applyCreateTask } from './core/use-cases/task-create.ts'
20
20
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
21
- import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
21
+ import { applySetSchedule, applyScheduleProgress, applyScheduleRefund, type ScheduleProgress } from './core/use-cases/task-schedule.ts'
22
22
  import { applySetParent } from './core/use-cases/task-parent.ts'
23
+ import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
23
24
  import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
24
- import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_OLDER_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
25
- import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
25
+ import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
26
+ import { withoutAcceptanceAnomalies, type ExecutionVerification } from './core/verification.ts'
27
+ import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
26
28
 
27
29
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
28
30
  importedSources?: string[]
@@ -57,7 +59,7 @@ function bindingRefusalMessage(
57
59
  surface: 'run' | 'schedule',
58
60
  ): string {
59
61
  if (refusal.kind === 'subtask-pin') {
60
- return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear that card's permission or run the tree without Agent Team`
62
+ return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session and inherits its permission, which that pin exceeds; clear the card's permission or run the tree without Agent Team`
61
63
  }
62
64
  if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
63
65
  if (refusal.kind === 'root') {
@@ -96,7 +98,10 @@ export interface OpenExecutionReference {
96
98
  /** Minimal value copy used by the Host scheduler. */
97
99
  export interface DueScheduleReference {
98
100
  readonly taskId: string
99
- readonly cron: string
101
+ /** Whether this due rule is a recurring cron rule or a single planned instant. */
102
+ readonly mode: ScheduleMode
103
+ /** Cron expression of a recurring rule; absent on a one-shot. */
104
+ readonly cron?: string
100
105
  /** Zone this rule's wall clock is read in. */
101
106
  readonly timeZone: string
102
107
  readonly nextRunAt: number
@@ -106,6 +111,24 @@ export interface DueScheduleReference {
106
111
  export interface LedgerRuntimeView {
107
112
  readonly armedSchedules: number
108
113
  readonly openExecutions: readonly OpenExecutionReference[]
114
+ /**
115
+ * Session ids of every open execution, including the ones
116
+ * {@link openExecutions} skips because their own outcome is already
117
+ * recorded. A caller that asks whether the board still observes any session
118
+ * must use this list: a deferred cascade parent is settled by its members,
119
+ * but it is still an open execution.
120
+ */
121
+ readonly openSessionIds: readonly string[]
122
+ /**
123
+ * Whether this document holds anything the session roster can still decide:
124
+ * an open execution that needs inspecting, or a card in the running column
125
+ * whose verdict may arrive from a settle this process never saw.
126
+ *
127
+ * A board with no running card, no open execution and no armed schedule has
128
+ * no use for the roster at all, which is what lets the Host poll stand down
129
+ * instead of re-reading every persisted session forever.
130
+ */
131
+ readonly needsSessionState: boolean
109
132
  }
110
133
 
111
134
  const MAX_REQUEST_CACHE = 256
@@ -123,12 +146,25 @@ function scheduleZone(schedule: { timeZone?: string }): string {
123
146
  return schedule.timeZone ?? timeZone()
124
147
  }
125
148
 
126
- function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
127
- return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
128
- }
129
-
130
- function hasOpenExecution(task: TaskRecord): boolean {
131
- return task.executions.some(execution => execution.endedAt === undefined)
149
+ /**
150
+ * The task collection one board read hands out.
151
+ *
152
+ * A read must not hand a consumer the ledger's own array: the state route, every
153
+ * action response and the agent tools all receive this value in-process, so an
154
+ * in-place rewrite of that array (splice, sort, push, `length = 0`) would edit
155
+ * the authoritative document from outside the write path. One level of copying
156
+ * is what that contract needs, and it is all it needs: every write in this class
157
+ * replaces the document's array (`this.document.tasks = ...`) and replaces the
158
+ * records it changes rather than mutating them, so the records a caller receives
159
+ * are stable values. Copying them as well only rebuilt the entire document on
160
+ * every board read - measured at 0.43 ms per read for 400 cards with 2
161
+ * executions each and 2.72 ms for 400 cards with 20 - and bought nothing a
162
+ * consumer relies on. Callers still must not mutate a record in place; that is
163
+ * what the shared record objects now make possible, and no consumer in this
164
+ * package does it.
165
+ */
166
+ function tasksForRead(tasks: readonly TaskRecord[]): TaskRecord[] {
167
+ return [...tasks]
132
168
  }
133
169
 
134
170
  /**
@@ -341,7 +377,17 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
341
377
  const previous = byId.get(entry.id)
342
378
  byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
343
379
  }
344
- const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
380
+ // Acceptance evidence is the one field "betterExecution" cannot choose: a
381
+ // browser half that also wrote this execution carries no attempts, and taking
382
+ // its copy would erase the Host's verdict. The copy with the most recorded
383
+ // attempts wins, and one execution's verdict is never copied onto another.
384
+ const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt).map(entry => {
385
+ if (entry.verification !== undefined) return entry
386
+ const richer = [...a.executions, ...b.executions]
387
+ .filter(other => other.id === entry.id && other.verification !== undefined)
388
+ .sort((x, y) => (y.verification?.attempts.length ?? 0) - (x.verification?.attempts.length ?? 0))[0]
389
+ return richer?.verification === undefined ? entry : { ...entry, verification: richer.verification }
390
+ })
345
391
  return { ...newer, executions: retainRecentExecutions(executions) }
346
392
  }
347
393
 
@@ -389,17 +435,24 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
389
435
  const rawSchedule = rawById.get(task.id)?.schedule
390
436
  if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
391
437
  const schedule = rawSchedule as Record<string, unknown>
438
+ // A one-shot carries no cron; its instant is validated by the wire gate and
439
+ // the store repair, so there is nothing to salvage here.
440
+ if (schedule.mode === 'once') return task
392
441
  if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task
393
442
  return {
394
443
  ...task,
395
444
  schedule: {
396
445
  enabled: false,
446
+ mode: 'cron',
397
447
  cron: schedule.cron,
398
448
  ...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
399
449
  nextRunAt: undefined,
400
450
  lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
401
451
  ? schedule.lastTriggeredAt
402
452
  : undefined,
453
+ runCount: typeof schedule.runCount === 'number' && Number.isInteger(schedule.runCount) && schedule.runCount >= 0
454
+ ? schedule.runCount
455
+ : 0,
403
456
  },
404
457
  }
405
458
  })
@@ -416,8 +469,18 @@ export class HostTaskLedger {
416
469
  /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
417
470
  readonly schedulerFile: string
418
471
 
419
- /** Session-default permission the confirmation gate compares against. */
420
- readonly sessionDefaultPermission: TaskPermission
472
+ /** Resolves the baseline the confirmation gate compares a binding against. */
473
+ private readonly sessionDefault: () => TaskPermission
474
+
475
+ /**
476
+ * Session-default permission the confirmation gate compares against. Read
477
+ * through the resolver on every use, so a baseline that follows the Host's
478
+ * own default preset tracks a Settings change made while the board runs.
479
+ */
480
+ get sessionDefaultPermission(): TaskPermission {
481
+ return this.sessionDefault()
482
+ }
483
+
421
484
  /**
422
485
  * Deployment subtask depth limit (1..3): the lineage gate every write obeys.
423
486
  * The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
@@ -425,8 +488,10 @@ export class HostTaskLedger {
425
488
  */
426
489
  private depthLimit: number
427
490
 
428
- constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
429
- this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
491
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
492
+ // Resolved before the load/repair passes below: they can evaluate the gate.
493
+ const baseline = options.sessionDefaultPermission
494
+ this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
430
495
  this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
431
496
  mkdirSync(dir, { recursive: true })
432
497
  this.file = join(dir, 'ledger-v2.json')
@@ -497,16 +562,17 @@ export class HostTaskLedger {
497
562
 
498
563
  state(): LedgerState {
499
564
  const { revision, scheduler } = this.summary()
500
- return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
565
+ return { revision, tasks: tasksForRead(this.document.tasks), scheduler }
501
566
  }
502
567
 
503
568
  /**
504
- * Runtime-only projection for the 5 s Host poll. It copies just primitive
569
+ * Runtime-only projection for the Host poll. It copies just primitive
505
570
  * identifiers and timestamps, never the complete task/execution history or
506
571
  * an authoritative mutable object from the ledger.
507
572
  */
508
573
  runtimeView(): LedgerRuntimeView {
509
574
  let armedSchedules = 0
575
+ let runningCards = 0
510
576
  // Run groups opened by a team-mode card: every other member of those groups
511
577
  // runs as a teammate inside that card's Lead session.
512
578
  const teamGroups = new Set<string>()
@@ -517,10 +583,13 @@ export class HostTaskLedger {
517
583
  }
518
584
  }
519
585
  const openExecutions: OpenExecutionReference[] = []
586
+ const openSessionIds: string[] = []
520
587
  for (const task of this.document.tasks) {
521
588
  if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
589
+ if (task.status === 'running') runningCards += 1
522
590
  for (const execution of task.executions) {
523
591
  if (execution.endedAt !== undefined) continue
592
+ if (execution.sessionId !== undefined) openSessionIds.push(execution.sessionId)
524
593
  // A deferred cascade parent already knows its own outcome; the monitor
525
594
  // has nothing left to inspect, and its children's settles finalize it.
526
595
  if (execution.ownResult !== undefined) continue
@@ -535,7 +604,12 @@ export class HostTaskLedger {
535
604
  })
536
605
  }
537
606
  }
538
- return { armedSchedules, openExecutions }
607
+ return {
608
+ armedSchedules,
609
+ openExecutions,
610
+ openSessionIds,
611
+ needsSessionState: openExecutions.length > 0 || runningCards > 0,
612
+ }
539
613
  }
540
614
 
541
615
  /** Count armed, non-archived schedules without cloning task histories. */
@@ -574,7 +648,13 @@ export class HostTaskLedger {
574
648
  if (task.archivedAt !== undefined) continue
575
649
  const schedule = task.schedule
576
650
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
577
- due.push({ taskId: task.id, cron: schedule.cron, timeZone: scheduleZone(schedule), nextRunAt: schedule.nextRunAt })
651
+ due.push({
652
+ taskId: task.id,
653
+ mode: schedule.mode,
654
+ ...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
655
+ timeZone: scheduleZone(schedule),
656
+ nextRunAt: schedule.nextRunAt,
657
+ })
578
658
  }
579
659
  return due
580
660
  }
@@ -626,38 +706,140 @@ export class HostTaskLedger {
626
706
  /**
627
707
  * Open the cascade one due schedule triggers: an empty array means nothing
628
708
  * ran (already running, or a participant whose elevated permission is still
629
- * unconfirmed), and the rule rolls to its next occurrence either way.
709
+ * unconfirmed, a one-shot whose instant was missed, or an exhausted budget),
710
+ * and the rule's own state says which.
711
+ *
712
+ * The run budget is enforced HERE, inside the single ledger writer: a rule
713
+ * whose counter already reached its cap is stopped instead of opened, so no
714
+ * restart, repeated timer fire or re-arm can overrun it.
715
+ * @param taskId - the task whose rule came due.
716
+ * @param triggeredAt - the instant the occurrence came due.
717
+ * @returns the executions this occurrence opened.
630
718
  */
631
- openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun[] {
719
+ openScheduled(taskId: string, triggeredAt: number): OpenedRun[] {
632
720
  const task = this.document.tasks.find(item => item.id === taskId)
633
721
  if (task === undefined || task.archivedAt !== undefined) return []
634
- const rollForward = (): void => {
635
- this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
636
- this.commit()
722
+ const schedule = task.schedule
723
+ if (schedule === undefined || !schedule.enabled) return []
724
+
725
+ // A rule that has spent its budget never opens another run. The stop is
726
+ // recorded here rather than trusted to the firing timer, so a rule that
727
+ // was hand-edited into an exhausted state still terminates visibly.
728
+ if (scheduleExhausted(schedule)) {
729
+ this.progressSchedule(taskId, {
730
+ consumed: 0,
731
+ endedReason: schedule.mode === 'once' ? 'fired' : 'limit',
732
+ }, triggeredAt)
733
+ return []
637
734
  }
735
+
638
736
  const refusal = this.bindingRefusal(task)
639
737
  if (refusal !== undefined) {
640
738
  // An unconfirmed above-default permission must never run unattended: the
641
- // whole tree is refused and the schedule rolls to the next occurrence,
642
- // exactly like the already-running refusal.
739
+ // whole tree is refused and the occurrence is skipped without consuming
740
+ // the run budget, exactly like the already-running refusal.
643
741
  this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
644
- rollForward()
742
+ this.skipOccurrence(taskId, 'permission', triggeredAt)
645
743
  return []
646
744
  }
647
- if (task.status === 'running' || hasOpenExecution(task)) {
648
- rollForward()
745
+ if (hasOpenExecution(task)) {
746
+ this.skipOccurrence(taskId, 'busy', triggeredAt)
649
747
  return []
650
748
  }
651
- return this.startCascade(task, triggeredAt, undefined, false, nextRunAt)
749
+
750
+ if (schedule.mode === 'once') {
751
+ // The single planned instant is due: open its one run and stop the rule
752
+ // in the same commit, so the budget can never produce a second one.
753
+ return this.startCascade(task, triggeredAt, undefined, false, {
754
+ consumed: 1,
755
+ lastTriggeredAt: triggeredAt,
756
+ endedReason: 'fired',
757
+ })
758
+ }
759
+
760
+ const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? triggeredAt, scheduleZone(schedule))
761
+ if (next === undefined) {
762
+ this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, triggeredAt)
763
+ return []
764
+ }
765
+ const consumed = schedule.runCount + 1
766
+ const budget = scheduleRunBudget(schedule)
767
+ const spent = budget !== undefined && consumed >= budget
768
+ return this.startCascade(task, triggeredAt, undefined, false, {
769
+ consumed: 1,
770
+ nextRunAt: spent ? undefined : next,
771
+ lastTriggeredAt: triggeredAt,
772
+ ...(spent ? { endedReason: 'limit' as const } : {}),
773
+ })
652
774
  }
653
775
 
776
+ /**
777
+ * Record a due occurrence that opened no execution: roll a recurring rule to
778
+ * its next occurrence (budget untouched), or stop it when there is none. A
779
+ * one-shot has no second occurrence, so it always stops here with the reason.
780
+ */
781
+ private skipOccurrence(taskId: string, reason: ScheduleStopReason, now: number): void {
782
+ const task = this.document.tasks.find(item => item.id === taskId)
783
+ const schedule = task?.schedule
784
+ if (schedule === undefined) return
785
+ if (schedule.mode === 'once') {
786
+ this.progressSchedule(taskId, { consumed: 0, endedReason: reason }, now)
787
+ return
788
+ }
789
+ const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? now, scheduleZone(schedule))
790
+ if (next === undefined) this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, now)
791
+ else this.progressSchedule(taskId, { consumed: 0, nextRunAt: next, skippedReason: reason }, now)
792
+ }
793
+
794
+ /** Apply a schedule-occurrence transition and persist it atomically. */
795
+ private progressSchedule(taskId: string, progress: ScheduleProgress, now: number): void {
796
+ this.document.tasks = [...applyScheduleProgress(this.document.tasks, taskId, progress, now)]
797
+ this.commit()
798
+ }
799
+
800
+ /**
801
+ * Refund the one scheduled run an occurrence consumed before its launch
802
+ * failed without creating a session (see
803
+ * {@link applyScheduleRefund}). Called by the service on the failed-launch
804
+ * path only; a manual run and a launch that did reach a session are never
805
+ * refunded. A no-op for a task with no rule, so a failed cascade member does
806
+ * not touch anything.
807
+ * @param taskId - the task whose occurrence is being refunded.
808
+ * @param now - clock instant (ms epoch).
809
+ */
810
+ refundScheduledOccurrence(taskId: string, now: number): void {
811
+ const before = this.document.tasks.find(item => item.id === taskId)?.schedule
812
+ if (before === undefined) return
813
+ const next = applyScheduleRefund(this.document.tasks, taskId, now, timeZone())
814
+ const after = next.find(item => item.id === taskId)?.schedule
815
+ if (JSON.stringify(before) === JSON.stringify(after)) return
816
+ this.document.tasks = [...next]
817
+ this.commit()
818
+ }
819
+
820
+ /**
821
+ * Boot / resume recovery: skip every occurrence that came due while the board
822
+ * was not running through it. A recurring rule rolls to its next future
823
+ * target; a one-shot whose instant has passed is stopped as missed, because
824
+ * there is no later occurrence left to run. Rendering the missed occurrence
825
+ * is deliberately not attempted: the ACL of a card that fired hours ago is
826
+ * stale, and the board's own recovery contract is "missed triggers are
827
+ * skipped, never replayed".
828
+ */
654
829
  skipMissed(now: number): void {
655
830
  let changed = false
656
831
  this.document.tasks = this.document.tasks.map(task => {
657
832
  const schedule = task.schedule
658
833
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
659
834
  changed = true
660
- return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule)) }, updatedAt: now }
835
+ if (schedule.mode === 'once') {
836
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
837
+ }
838
+ const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
839
+ if (next === undefined) {
840
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
841
+ }
842
+ return withSchedule(task, { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }, now)
661
843
  })
662
844
  if (changed) this.commit()
663
845
  }
@@ -736,6 +918,24 @@ export class HostTaskLedger {
736
918
  this.commit()
737
919
  }
738
920
 
921
+ allTasks(): readonly TaskRecord[] {
922
+ return this.document.tasks
923
+ }
924
+
925
+ getTask(id: string): TaskRecord | undefined {
926
+ return this.document.tasks.find(item => item.id === id)
927
+ }
928
+
929
+ saveTaskRecord(task: TaskRecord): void {
930
+ const exists = this.document.tasks.some(item => item.id === task.id)
931
+ if (exists) {
932
+ this.document.tasks = this.document.tasks.map(item => item.id === task.id ? task : item)
933
+ } else {
934
+ this.document.tasks = [...this.document.tasks, task]
935
+ }
936
+ this.commit()
937
+ }
938
+
739
939
  private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
740
940
  const now = this.now()
741
941
  let runs: OpenedRun[] | undefined
@@ -744,7 +944,9 @@ export class HostTaskLedger {
744
944
  const sources = new Set(this.document.scheduler.importedSources ?? [])
745
945
  if (sources.has(action.sourceId)) return { state: this.state() }
746
946
  const invalidScheduleIds = action.tasks
747
- .filter(task => task.schedule !== undefined && !isValidCron(task.schedule.cron))
947
+ .filter(task => task.schedule !== undefined
948
+ && task.schedule.mode !== 'once'
949
+ && !isValidCron(task.schedule.cron ?? ''))
748
950
  .map(task => task.id)
749
951
  const incoming = parseHostTasks(action.tasks)
750
952
  const merged = new Map(this.document.tasks.map(task => [task.id, task]))
@@ -765,9 +967,8 @@ export class HostTaskLedger {
765
967
  }
766
968
  case 'create': {
767
969
  if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
768
- if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
769
- || !isValidTimeZone(action.input.schedule.timeZone ?? timeZone())
770
- || nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
970
+ const requested = action.input.schedule
971
+ if (requested?.enabled === true && !this.validScheduleRequest(requested, now)) {
771
972
  throw new Error('invalid schedule')
772
973
  }
773
974
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
@@ -801,7 +1002,7 @@ export class HostTaskLedger {
801
1002
  case 'delete': {
802
1003
  const task = this.document.tasks.find(task => task.id === action.taskId)
803
1004
  if (task === undefined) throw new Error('task not found')
804
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
1005
+ if (hasOpenExecution(task)) throw new Error('running task cannot be deleted')
805
1006
  // Subtasks keep their link: deleting the parent would leave dangling
806
1007
  // children, so the user detaches or deletes them explicitly first.
807
1008
  if (this.document.tasks.some(item => item.parentId === action.taskId)) {
@@ -820,7 +1021,10 @@ export class HostTaskLedger {
820
1021
  const task = this.document.tasks.find(item => item.id === action.taskId)
821
1022
  if (task === undefined) throw new Error('task not found')
822
1023
  if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
823
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be moved')
1024
+ // The lock belongs to an open execution, not to the column: a card
1025
+ // parked in 'running' by hand has no session and stays movable, while
1026
+ // a card the runner is executing cannot be moved out from under it.
1027
+ if (hasOpenExecution(task)) throw new Error('running task cannot be moved')
824
1028
  if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
825
1029
  this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
826
1030
  break
@@ -832,6 +1036,37 @@ export class HostTaskLedger {
832
1036
  this.document.tasks = [...result.tasks]
833
1037
  break
834
1038
  }
1039
+ case 'record-external-outcome': {
1040
+ const task = this.document.tasks.find(item => item.id === action.taskId)
1041
+ if (task === undefined) throw new Error('task not found')
1042
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
1043
+ // The one guardrail that matters: while the Host still owns a run it is
1044
+ // the only authority on the card, so an outside agent may not write the
1045
+ // terminal verdict over it. This is also what keeps a stale report from
1046
+ // racing the session it claims to have replaced.
1047
+ if (hasOpenExecution(task)) throw new Error('running task cannot receive an external outcome')
1048
+ const execution: ExecutionRecord = {
1049
+ // A fresh id, exactly like a Host-launched run: the external work is a
1050
+ // new attempt in the history, never an overwrite of an earlier record.
1051
+ id: crypto.randomUUID(),
1052
+ sessionId: undefined,
1053
+ startedAt: now,
1054
+ endedAt: now,
1055
+ result: action.result,
1056
+ error: action.summary,
1057
+ initiatedBy: action.initiatedBy,
1058
+ external: true,
1059
+ }
1060
+ this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId
1061
+ ? item
1062
+ : {
1063
+ ...item,
1064
+ status: settledStatus(item, action.result),
1065
+ updatedAt: now,
1066
+ executions: retainRecentExecutions([...item.executions, execution]),
1067
+ })
1068
+ break
1069
+ }
835
1070
  case 'settle': {
836
1071
  const task = this.document.tasks.find(item => item.id === action.taskId)
837
1072
  if (task === undefined) throw new Error('task not found')
@@ -860,6 +1095,32 @@ export class HostTaskLedger {
860
1095
  this.finalizeReadyRuns(false)
861
1096
  break
862
1097
  }
1098
+ case 'reset-verification': {
1099
+ // The explicit user reset of the acceptance anomaly counter (issue
1100
+ // #1828). It clears the attempts the ENVIRONMENT produced — timeouts,
1101
+ // authentication failures, unresolvable routes, and attempts the time
1102
+ // budget ended — and nothing else: a quality verdict is the board's own
1103
+ // judgement of the work and survives every reset. Only the open
1104
+ // execution is addressable, because the reset exists to unblock a
1105
+ // completion claim on a run that is still going.
1106
+ const task = this.document.tasks.find(item => item.id === action.taskId)
1107
+ if (task === undefined) throw new Error('task not found')
1108
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
1109
+ const execution = [...task.executions].reverse().find(entry => entry.endedAt === undefined)
1110
+ if (execution === undefined) throw new Error('task has no open execution')
1111
+ const verification = execution.verification
1112
+ if (verification === undefined || verification.applicability !== 'enforced') {
1113
+ throw new Error('this execution is not gated by task acceptance')
1114
+ }
1115
+ const cleared = withoutAcceptanceAnomalies(verification)
1116
+ if (cleared === undefined) throw new Error('this execution has no acceptance anomaly to reset')
1117
+ this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId ? item : {
1118
+ ...item,
1119
+ updatedAt: now,
1120
+ executions: item.executions.map(entry => entry.id === execution.id ? { ...entry, verification: cleared } : entry),
1121
+ })
1122
+ break
1123
+ }
863
1124
  case 'restore': {
864
1125
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
865
1126
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
@@ -876,6 +1137,18 @@ export class HostTaskLedger {
876
1137
  : item)
877
1138
  break
878
1139
  }
1140
+ case 'rename-tag': {
1141
+ const result = applyRenameTag(this.document.tasks, action.from, action.to, now)
1142
+ if (result.error !== undefined) throw new Error(result.error)
1143
+ if (result.changed) this.document.tasks = [...result.tasks]
1144
+ break
1145
+ }
1146
+ case 'delete-tag': {
1147
+ const result = applyDeleteTag(this.document.tasks, action.name, now)
1148
+ if (result.error !== undefined) throw new Error(result.error)
1149
+ if (result.changed) this.document.tasks = [...result.tasks]
1150
+ break
1151
+ }
879
1152
  case 'set-schedule': {
880
1153
  const task = this.document.tasks.find(task => task.id === action.taskId)
881
1154
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
@@ -888,7 +1161,7 @@ export class HostTaskLedger {
888
1161
  case 'run': {
889
1162
  const task = this.document.tasks.find(item => item.id === action.taskId)
890
1163
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
891
- if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
1164
+ if (task === undefined || hasOpenExecution(task)) throw new Error('task is already running or missing')
892
1165
  // The confirmation gate judges the RESOLVED binding: a subtask that
893
1166
  // inherits an elevated permission from its parent is exactly as
894
1167
  // unconfirmed as the parent would be without its own stamp.
@@ -904,15 +1177,42 @@ export class HostTaskLedger {
904
1177
  return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
905
1178
  }
906
1179
 
1180
+ /**
1181
+ * Whether a creation-time schedule request is usable. A one-shot must carry
1182
+ * a whole-millisecond instant in the future; a recurring rule must carry a
1183
+ * valid expression with a reachable occurrence and, when capped, a positive
1184
+ * whole run budget. An unusable zone is refused rather than reinterpreted as
1185
+ * the Host zone.
1186
+ */
1187
+ private validScheduleRequest(request: {
1188
+ readonly enabled: boolean
1189
+ readonly mode?: ScheduleMode
1190
+ readonly cron?: string
1191
+ readonly at?: number
1192
+ readonly timeZone?: string
1193
+ readonly maxRuns?: number
1194
+ }, now: number): boolean {
1195
+ if (request.timeZone !== undefined && !isValidTimeZone(request.timeZone)) return false
1196
+ if (request.mode === 'once') {
1197
+ return typeof request.at === 'number' && Number.isInteger(request.at) && request.at > now
1198
+ }
1199
+ const cron = (request.cron ?? '').trim()
1200
+ if (cron === '' || !isValidCron(cron)) return false
1201
+ if (request.maxRuns !== undefined && (!Number.isInteger(request.maxRuns) || request.maxRuns < 1)) return false
1202
+ return nextRunAtMs(cron, now, request.timeZone ?? timeZone()) !== undefined
1203
+ }
1204
+
907
1205
  /**
908
1206
  * The binding that makes a run illegal, if any.
909
1207
  *
910
1208
  * A plain cascade launches one session per participant, so every participant
911
1209
  * carries its own resolved binding and each one gates the run. A team run
912
- * launches only the Lead session: the Lead's binding gates it, while a
913
- * subtask's OWN above-default pin cannot be applied to a teammate and is
914
- * refused instead of being silently dropped (an inherited binding is the
915
- * Lead's own and stays allowed once the Lead is confirmed).
1210
+ * launches only the Lead session: the Lead's binding gates it, and a
1211
+ * teammate inherits that session's permission, so a subtask's OWN pin is
1212
+ * refused exactly when the Lead session does not already carry it — the
1213
+ * teammate could never be given that authority, and dropping the pin
1214
+ * silently would misreport the work (an inherited binding is the Lead's own
1215
+ * and stays allowed once the Lead is confirmed).
916
1216
  * @param root - the task being run.
917
1217
  * @returns the first refusal, or undefined when the run may start.
918
1218
  */
@@ -923,12 +1223,17 @@ export class HostTaskLedger {
923
1223
  if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
924
1224
  return { kind: 'root', title: lead.title }
925
1225
  }
926
- // The raw record carries the subtask's OWN binding: the resolved
927
- // participant above already folded the inherited one into its permission.
1226
+ // The Lead session runs at the Lead's own binding, or at the deployment
1227
+ // default when it pins none; a teammate inherits that permission and
1228
+ // cannot be narrowed below it, so it carries a subtask pin when it is
1229
+ // already at least as wide. The raw record carries the subtask's OWN
1230
+ // binding: the resolved participant above already folded an inherited one
1231
+ // into its permission.
1232
+ const leadPermission = effectivePermission(lead ?? root) ?? this.sessionDefaultPermission
928
1233
  const pinned = participants.find(participant => {
929
1234
  if (participant.id === root.id) return false
930
1235
  const raw = this.document.tasks.find(task => task.id === participant.id)
931
- return raw !== undefined && requiresPermissionConfirmation(raw, this.sessionDefaultPermission)
1236
+ return raw !== undefined && !permissionCarriedBy(leadPermission, effectivePermission(raw))
932
1237
  })
933
1238
  return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
934
1239
  }
@@ -947,14 +1252,13 @@ export class HostTaskLedger {
947
1252
  return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
948
1253
  .map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
949
1254
  .filter(participant => participant.archivedAt === undefined
950
- && participant.status !== 'running'
951
1255
  && !hasOpenExecution(participant))
952
1256
  }
953
1257
 
954
1258
  /** Whether a task or any of its subtasks still has an open execution. */
955
1259
  private subtreeHasOpenExecution(id: string): boolean {
956
1260
  return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
957
- .some(task => task.status === 'running' || hasOpenExecution(task))
1261
+ .some(task => hasOpenExecution(task))
958
1262
  }
959
1263
 
960
1264
  /**
@@ -966,9 +1270,11 @@ export class HostTaskLedger {
966
1270
  * @param now - clock instant (ms epoch).
967
1271
  * @param initiator - the DSH session that asked for the run (audit only).
968
1272
  * @param rerun - true to reset the root to 'todo' before starting it.
969
- * @param nextRunAt - when set, the root's schedule also rolls forward.
1273
+ * @param progress - when set, this run was a due occurrence and the root's
1274
+ * rule is advanced (counter, next target, stop/skip record) in the same
1275
+ * atomic commit that opened the executions.
970
1276
  */
971
- private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, nextRunAt?: number): OpenedRun[] {
1277
+ private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, progress?: ScheduleProgress): OpenedRun[] {
972
1278
  const before = this.document.tasks
973
1279
  const groupId = crypto.randomUUID()
974
1280
  // A team-mode root runs as the Team Lead: every other member of the same
@@ -979,7 +1285,7 @@ export class HostTaskLedger {
979
1285
  const runs: OpenedRun[] = []
980
1286
  for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
981
1287
  if (task.archivedAt !== undefined) continue
982
- if (task.status === 'running' || hasOpenExecution(task)) continue
1288
+ if (hasOpenExecution(task)) continue
983
1289
  const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
984
1290
  const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
985
1291
  started.set(task.id, opened.task)
@@ -991,7 +1297,7 @@ export class HostTaskLedger {
991
1297
  }
992
1298
  if (runs.length === 0) return []
993
1299
  let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
994
- if (nextRunAt !== undefined) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now)
1300
+ if (progress !== undefined) tasks = applyScheduleProgress(tasks, root.id, progress, now)
995
1301
  this.document.tasks = [...tasks]
996
1302
  this.commit()
997
1303
  return runs
@@ -1122,6 +1428,14 @@ export class HostTaskLedger {
1122
1428
  return changed
1123
1429
  }
1124
1430
 
1431
+ /**
1432
+ * Re-align every armed rule at load/import. A recurring rule is recomputed
1433
+ * from the current instant; a one-shot is armed at its planned instant when
1434
+ * that is still in the future, and stopped as missed once it has passed
1435
+ * (there is no later occurrence to run). A recurring rule recomputed past an
1436
+ * occurrence records the skip, so the board can show that a fire was passed
1437
+ * over rather than silently rewinding.
1438
+ */
1125
1439
  private repairSchedules(skipPast: boolean, persist = true): void {
1126
1440
  const now = this.now()
1127
1441
  let changed = false
@@ -1129,15 +1443,32 @@ export class HostTaskLedger {
1129
1443
  const schedule = task.schedule
1130
1444
  if (schedule === undefined || !schedule.enabled) return task
1131
1445
  if (!skipPast && schedule.nextRunAt !== undefined) return task
1132
- const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
1446
+ if (schedule.mode === 'once') {
1447
+ const at = schedule.at
1448
+ if (at === undefined) {
1449
+ changed = true
1450
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
1451
+ }
1452
+ if (at <= now) {
1453
+ changed = true
1454
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
1455
+ }
1456
+ if (schedule.nextRunAt === at) return task
1457
+ changed = true
1458
+ return withSchedule(task, { nextRunAt: at }, now)
1459
+ }
1460
+ const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
1133
1461
  if (next === undefined) {
1134
1462
  changed = true
1135
1463
  this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
1136
- return { ...task, schedule: { ...schedule, enabled: false, nextRunAt: undefined }, updatedAt: now }
1464
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
1137
1465
  }
1138
- if (schedule.nextRunAt === next) return task
1466
+ const passed = schedule.nextRunAt !== undefined && schedule.nextRunAt <= now
1467
+ if (schedule.nextRunAt === next && !passed) return task
1139
1468
  changed = true
1140
- return { ...task, schedule: { ...schedule, nextRunAt: next }, updatedAt: now }
1469
+ return withSchedule(task, passed
1470
+ ? { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }
1471
+ : { nextRunAt: next }, now)
1141
1472
  })
1142
1473
  if (changed && persist) this.commit()
1143
1474
  }
@@ -1147,7 +1478,6 @@ export class HostTaskLedger {
1147
1478
  let changed = false
1148
1479
  const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
1149
1480
  this.document.tasks = this.document.tasks.map(task => {
1150
- if (task.status !== 'running') return task
1151
1481
  const execution = task.executions.at(-1)
1152
1482
  if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
1153
1483
  changed = true
@@ -1166,7 +1496,7 @@ export class HostTaskLedger {
1166
1496
  }
1167
1497
 
1168
1498
  /**
1169
- * Field-preserving migration of a pre-v4 document. It first proves every task
1499
+ * Field-preserving migration of an older document. It first proves every task
1170
1500
  * row is structurally valid, so a document that would silently drop or coerce
1171
1501
  * rows fails loudly instead (no quarantined-empty restart), and then reuses
1172
1502
  * the current normalization.
@@ -1177,23 +1507,84 @@ export class HostTaskLedger {
1177
1507
  * (the stored `nextRunAt` already encodes the old zone), but the rule stops
1178
1508
  * following a later `TZ` change, which is what made an existing schedule
1179
1509
  * silently move when the Host's zone changed.
1510
+ *
1511
+ * v5 adds the per-execution acceptance block. The migration deliberately does
1512
+ * NOT stamp one: an execution opened before v5 keeps no contract, so it
1513
+ * settles on its own historical verdict and no in-flight run is retroactively
1514
+ * judged by a gate that was never armed for it.
1180
1515
  */
1181
1516
  private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
1182
1517
  if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
1183
1518
  throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
1184
1519
  }
1520
+ // Only generations before the stored zone was introduced (plus the one that
1521
+ // introduced it) get the Host zone stamped: a v5 rule either carries its own
1522
+ // zone or deliberately follows the Host, and re-stamping it would freeze it
1523
+ // to the zone in effect at this load.
1524
+ const stampZone = typeof parsed.schemaVersion === 'number'
1525
+ && parsed.schemaVersion <= TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION
1185
1526
  // Every row passed `isTaskRecord` above, so the stamped rows are still
1186
1527
  // task records; the cast only re-narrows the unknown-typed on-disk array.
1187
- const rows = (parsed.tasks as readonly unknown[]).map((value) => {
1188
- const row = value as { schedule?: unknown }
1189
- const schedule = row.schedule
1190
- if (typeof schedule !== 'object' || schedule === null) return value
1191
- if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
1192
- return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
1193
- })
1528
+ const rows = stampZone
1529
+ ? (parsed.tasks as readonly unknown[]).map((value) => {
1530
+ const row = value as { schedule?: unknown }
1531
+ const schedule = row.schedule
1532
+ if (typeof schedule !== 'object' || schedule === null) return value
1533
+ if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
1534
+ return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
1535
+ })
1536
+ : parsed.tasks
1194
1537
  return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
1195
1538
  }
1196
1539
 
1540
+ /**
1541
+ * Find the still-open execution that ran in one session, with its task.
1542
+ *
1543
+ * The completion gate keys on the session id its tool call arrives from: the
1544
+ * execution record is the only object binding a session to a card, and an
1545
+ * already-settled execution is deliberately not returned (a session the board
1546
+ * no longer owns must not be gated).
1547
+ * @param sessionId - the DSH session the tool call runs in.
1548
+ * @returns the open execution and its task, or undefined.
1549
+ */
1550
+ findOpenExecutionBySession(sessionId: string): { task: TaskRecord; execution: ExecutionRecord } | undefined {
1551
+ if (sessionId === '') return undefined
1552
+ for (const task of this.document.tasks) {
1553
+ for (const execution of task.executions) {
1554
+ if (execution.endedAt !== undefined) continue
1555
+ if (execution.sessionId === sessionId) return { task, execution }
1556
+ }
1557
+ }
1558
+ return undefined
1559
+ }
1560
+
1561
+ /**
1562
+ * Replace one execution's acceptance block (creating it when absent).
1563
+ *
1564
+ * A no-op when the serialized value is unchanged, so a coarse caller that
1565
+ * re-writes the same state never bumps the ledger revision. A settled
1566
+ * execution keeps its record: the report of a finished cycle must survive,
1567
+ * and only the board's own settlement moves the card.
1568
+ * @param taskId - the task owning the execution.
1569
+ * @param executionId - the execution to update.
1570
+ * @param verification - the new block.
1571
+ * @returns true when the ledger changed.
1572
+ */
1573
+ setVerification(taskId: string, executionId: string, verification: ExecutionVerification): boolean {
1574
+ const now = this.now()
1575
+ const task = this.document.tasks.find(item => item.id === taskId)
1576
+ const execution = task?.executions.find(entry => entry.id === executionId)
1577
+ if (task === undefined || execution === undefined) return false
1578
+ if (JSON.stringify(execution.verification ?? null) === JSON.stringify(verification)) return false
1579
+ this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
1580
+ ...item,
1581
+ updatedAt: now,
1582
+ executions: item.executions.map(entry => entry.id === executionId ? { ...entry, verification } : entry),
1583
+ })
1584
+ this.commit()
1585
+ return true
1586
+ }
1587
+
1197
1588
  private load(dir: string): LedgerDocument {
1198
1589
  const existed = existsSync(this.file)
1199
1590
  // schemaVersion stays unknown-typed here: on-disk documents may be v2 or
@@ -1204,8 +1595,8 @@ export class HostTaskLedger {
1204
1595
  } catch (error) {
1205
1596
  return this.recoverCorrupt(dir, existed, error)
1206
1597
  }
1207
- if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION
1208
- || parsed.schemaVersion === TASK_BOARD_OLDER_SCHEMA_VERSION) {
1598
+ if (typeof parsed.schemaVersion === 'number'
1599
+ && TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
1209
1600
  try {
1210
1601
  return this.migrateLegacyDocument(parsed)
1211
1602
  } catch (error) {
@@ -1228,8 +1619,14 @@ export class HostTaskLedger {
1228
1619
  if (typeof value !== 'object' || value === null) return []
1229
1620
  const row = value as { id?: unknown; schedule?: unknown }
1230
1621
  if (typeof row.schedule !== 'object' || row.schedule === null) return []
1231
- const cron = (row.schedule as { cron?: unknown }).cron
1232
- return typeof cron !== 'string' || !isValidCron(cron)
1622
+ const rule = row.schedule as { cron?: unknown; mode?: unknown; at?: unknown }
1623
+ // A one-shot has no cron: its instant is what must be usable.
1624
+ if (rule.mode === 'once') {
1625
+ return typeof rule.at === 'number' && Number.isFinite(rule.at)
1626
+ ? []
1627
+ : [typeof row.id === 'string' ? row.id : 'unknown']
1628
+ }
1629
+ return typeof rule.cron !== 'string' || !isValidCron(rule.cron)
1233
1630
  ? [typeof row.id === 'string' ? row.id : 'unknown']
1234
1631
  : []
1235
1632
  })
@@ -1426,4 +1823,4 @@ export class HostTaskLedger {
1426
1823
  }
1427
1824
  throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
1428
1825
  }
1429
- }
1826
+ }