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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -18
  3. package/README.zh.md +34 -18
  4. package/lib/client.js +2804 -1024
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2576 -416
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/index.d.ts +4 -5
  15. package/lib/types/client/locales.d.ts +81 -0
  16. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  17. package/lib/types/client/schedule-zone.d.ts +50 -0
  18. package/lib/types/core/card-defaults.d.ts +78 -0
  19. package/lib/types/core/controller.d.ts +41 -17
  20. package/lib/types/core/launch-width.d.ts +31 -0
  21. package/lib/types/core/poll-cadence.d.ts +31 -0
  22. package/lib/types/core/reasoning-effort.d.ts +57 -0
  23. package/lib/types/core/subtask.d.ts +30 -8
  24. package/lib/types/core/tasks.d.ts +163 -14
  25. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  26. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  27. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  28. package/lib/types/core/verification-budget.d.ts +52 -0
  29. package/lib/types/core/verification.d.ts +376 -9
  30. package/lib/types/host/launch-admission.d.ts +72 -0
  31. package/lib/types/host/verification-gate.d.ts +24 -0
  32. package/lib/types/host/verification-runner.d.ts +38 -7
  33. package/lib/types/host-ledger.d.ts +91 -11
  34. package/lib/types/host-runner.d.ts +111 -13
  35. package/lib/types/host-service.d.ts +199 -8
  36. package/lib/types/index.d.ts +141 -0
  37. package/lib/types/protocol.d.ts +63 -12
  38. package/package.json +29 -33
  39. package/src/client/CardDefaultPresetField.tsx +101 -0
  40. package/src/client/TaskBoardSettingsCard.tsx +288 -17
  41. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  42. package/src/client/board/NewTaskModal.tsx +237 -60
  43. package/src/client/board/ReasoningEffortField.tsx +50 -0
  44. package/src/client/board/TaskCard.tsx +3 -3
  45. package/src/client/board/TaskDetail.tsx +261 -24
  46. package/src/client/board/VerificationReport.tsx +132 -13
  47. package/src/client/board/model-options.ts +178 -0
  48. package/src/client/board/preset-label.ts +32 -0
  49. package/src/client/board/preset-roster.ts +58 -0
  50. package/src/client/board/status-key.ts +3 -0
  51. package/src/client/board/team-run-promotion.ts +39 -0
  52. package/src/client/index.ts +105 -108
  53. package/src/client/locales.ts +171 -6
  54. package/src/client/plugin-card-seat.ts +7 -0
  55. package/src/client/schedule-zone.ts +100 -2
  56. package/src/client/settings-entry-form.ts +0 -1
  57. package/src/core/card-defaults.ts +132 -0
  58. package/src/core/controller.ts +55 -16
  59. package/src/core/launch-width.ts +38 -0
  60. package/src/core/poll-cadence.ts +41 -0
  61. package/src/core/reasoning-effort.ts +92 -0
  62. package/src/core/store.ts +69 -15
  63. package/src/core/subtask.ts +48 -13
  64. package/src/core/tasks.ts +213 -39
  65. package/src/core/use-cases/task-create.ts +57 -25
  66. package/src/core/use-cases/task-schedule.ts +190 -25
  67. package/src/core/use-cases/task-update.ts +9 -4
  68. package/src/core/verification-budget.ts +115 -0
  69. package/src/core/verification.ts +785 -17
  70. package/src/host/agent-tools.ts +127 -29
  71. package/src/host/launch-admission.ts +140 -0
  72. package/src/host/verification-gate.ts +172 -16
  73. package/src/host/verification-runner.ts +207 -40
  74. package/src/host-ledger.ts +346 -48
  75. package/src/host-runner.ts +234 -35
  76. package/src/host-service.ts +539 -52
  77. package/src/index.ts +250 -7
  78. package/src/protocol.ts +160 -32
  79. package/lib/invariant.js +0 -6
  80. package/lib/types/client/board-card-seat.d.ts +0 -34
  81. package/lib/types/invariant.d.ts +0 -3
  82. package/src/client/board-card-seat.ts +0 -102
  83. package/src/invariant.ts +0 -4
@@ -5,11 +5,12 @@ 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, hasOpenExecution, 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,
12
12
  combineCascadeOutcome,
13
+ joinsCascadeRun,
13
14
  normalizeSubtaskDepth,
14
15
  openGroupExecution,
15
16
  pendingCascadeChildren,
@@ -17,13 +18,14 @@ import {
17
18
  } from './core/subtask.ts'
18
19
  import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
19
20
  import { applyCreateTask } from './core/use-cases/task-create.ts'
21
+ import type { CardDefaults } from './core/card-defaults.ts'
20
22
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
21
- import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
23
+ import { applySetSchedule, applyScheduleProgress, applyScheduleRefund, type ScheduleProgress } from './core/use-cases/task-schedule.ts'
22
24
  import { applySetParent } from './core/use-cases/task-parent.ts'
23
25
  import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
24
26
  import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
25
- import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
26
- import type { ExecutionVerification } from './core/verification.ts'
27
+ import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
28
+ import { withoutAcceptanceAnomalies, type ExecutionVerification } from './core/verification.ts'
27
29
  import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
28
30
 
29
31
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
@@ -98,7 +100,10 @@ export interface OpenExecutionReference {
98
100
  /** Minimal value copy used by the Host scheduler. */
99
101
  export interface DueScheduleReference {
100
102
  readonly taskId: string
101
- readonly cron: string
103
+ /** Whether this due rule is a recurring cron rule or a single planned instant. */
104
+ readonly mode: ScheduleMode
105
+ /** Cron expression of a recurring rule; absent on a one-shot. */
106
+ readonly cron?: string
102
107
  /** Zone this rule's wall clock is read in. */
103
108
  readonly timeZone: string
104
109
  readonly nextRunAt: number
@@ -108,6 +113,24 @@ export interface DueScheduleReference {
108
113
  export interface LedgerRuntimeView {
109
114
  readonly armedSchedules: number
110
115
  readonly openExecutions: readonly OpenExecutionReference[]
116
+ /**
117
+ * Session ids of every open execution, including the ones
118
+ * {@link openExecutions} skips because their own outcome is already
119
+ * recorded. A caller that asks whether the board still observes any session
120
+ * must use this list: a deferred cascade parent is settled by its members,
121
+ * but it is still an open execution.
122
+ */
123
+ readonly openSessionIds: readonly string[]
124
+ /**
125
+ * Whether this document holds anything the session roster can still decide:
126
+ * an open execution that needs inspecting, or a card in the running column
127
+ * whose verdict may arrive from a settle this process never saw.
128
+ *
129
+ * A board with no running card, no open execution and no armed schedule has
130
+ * no use for the roster at all, which is what lets the Host poll stand down
131
+ * instead of re-reading every persisted session forever.
132
+ */
133
+ readonly needsSessionState: boolean
111
134
  }
112
135
 
113
136
  const MAX_REQUEST_CACHE = 256
@@ -125,8 +148,25 @@ function scheduleZone(schedule: { timeZone?: string }): string {
125
148
  return schedule.timeZone ?? timeZone()
126
149
  }
127
150
 
128
- function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
129
- return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
151
+ /**
152
+ * The task collection one board read hands out.
153
+ *
154
+ * A read must not hand a consumer the ledger's own array: the state route, every
155
+ * action response and the agent tools all receive this value in-process, so an
156
+ * in-place rewrite of that array (splice, sort, push, `length = 0`) would edit
157
+ * the authoritative document from outside the write path. One level of copying
158
+ * is what that contract needs, and it is all it needs: every write in this class
159
+ * replaces the document's array (`this.document.tasks = ...`) and replaces the
160
+ * records it changes rather than mutating them, so the records a caller receives
161
+ * are stable values. Copying them as well only rebuilt the entire document on
162
+ * every board read - measured at 0.43 ms per read for 400 cards with 2
163
+ * executions each and 2.72 ms for 400 cards with 20 - and bought nothing a
164
+ * consumer relies on. Callers still must not mutate a record in place; that is
165
+ * what the shared record objects now make possible, and no consumer in this
166
+ * package does it.
167
+ */
168
+ function tasksForRead(tasks: readonly TaskRecord[]): TaskRecord[] {
169
+ return [...tasks]
130
170
  }
131
171
 
132
172
  /**
@@ -397,17 +437,24 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
397
437
  const rawSchedule = rawById.get(task.id)?.schedule
398
438
  if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
399
439
  const schedule = rawSchedule as Record<string, unknown>
440
+ // A one-shot carries no cron; its instant is validated by the wire gate and
441
+ // the store repair, so there is nothing to salvage here.
442
+ if (schedule.mode === 'once') return task
400
443
  if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task
401
444
  return {
402
445
  ...task,
403
446
  schedule: {
404
447
  enabled: false,
448
+ mode: 'cron',
405
449
  cron: schedule.cron,
406
450
  ...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
407
451
  nextRunAt: undefined,
408
452
  lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
409
453
  ? schedule.lastTriggeredAt
410
454
  : undefined,
455
+ runCount: typeof schedule.runCount === 'number' && Number.isInteger(schedule.runCount) && schedule.runCount >= 0
456
+ ? schedule.runCount
457
+ : 0,
411
458
  },
412
459
  }
413
460
  })
@@ -443,11 +490,15 @@ export class HostTaskLedger {
443
490
  */
444
491
  private depthLimit: number
445
492
 
446
- constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
493
+ /** Live reader of the deployment's new-card defaults (a volatile config read). */
494
+ private readonly cardDefaults: () => CardDefaults
495
+
496
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number; cardDefaults?: () => CardDefaults } = {}) {
447
497
  // Resolved before the load/repair passes below: they can evaluate the gate.
448
498
  const baseline = options.sessionDefaultPermission
449
499
  this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
450
500
  this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
501
+ this.cardDefaults = options.cardDefaults ?? (() => ({}))
451
502
  mkdirSync(dir, { recursive: true })
452
503
  this.file = join(dir, 'ledger-v2.json')
453
504
  this.lockFile = join(dir, 'ledger-v2.lock')
@@ -517,16 +568,17 @@ export class HostTaskLedger {
517
568
 
518
569
  state(): LedgerState {
519
570
  const { revision, scheduler } = this.summary()
520
- return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
571
+ return { revision, tasks: tasksForRead(this.document.tasks), scheduler }
521
572
  }
522
573
 
523
574
  /**
524
- * Runtime-only projection for the 5 s Host poll. It copies just primitive
575
+ * Runtime-only projection for the Host poll. It copies just primitive
525
576
  * identifiers and timestamps, never the complete task/execution history or
526
577
  * an authoritative mutable object from the ledger.
527
578
  */
528
579
  runtimeView(): LedgerRuntimeView {
529
580
  let armedSchedules = 0
581
+ let runningCards = 0
530
582
  // Run groups opened by a team-mode card: every other member of those groups
531
583
  // runs as a teammate inside that card's Lead session.
532
584
  const teamGroups = new Set<string>()
@@ -537,10 +589,13 @@ export class HostTaskLedger {
537
589
  }
538
590
  }
539
591
  const openExecutions: OpenExecutionReference[] = []
592
+ const openSessionIds: string[] = []
540
593
  for (const task of this.document.tasks) {
541
594
  if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
595
+ if (task.status === 'running') runningCards += 1
542
596
  for (const execution of task.executions) {
543
597
  if (execution.endedAt !== undefined) continue
598
+ if (execution.sessionId !== undefined) openSessionIds.push(execution.sessionId)
544
599
  // A deferred cascade parent already knows its own outcome; the monitor
545
600
  // has nothing left to inspect, and its children's settles finalize it.
546
601
  if (execution.ownResult !== undefined) continue
@@ -555,7 +610,12 @@ export class HostTaskLedger {
555
610
  })
556
611
  }
557
612
  }
558
- return { armedSchedules, openExecutions }
613
+ return {
614
+ armedSchedules,
615
+ openExecutions,
616
+ openSessionIds,
617
+ needsSessionState: openExecutions.length > 0 || runningCards > 0,
618
+ }
559
619
  }
560
620
 
561
621
  /** Count armed, non-archived schedules without cloning task histories. */
@@ -594,7 +654,13 @@ export class HostTaskLedger {
594
654
  if (task.archivedAt !== undefined) continue
595
655
  const schedule = task.schedule
596
656
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
597
- due.push({ taskId: task.id, cron: schedule.cron, timeZone: scheduleZone(schedule), nextRunAt: schedule.nextRunAt })
657
+ due.push({
658
+ taskId: task.id,
659
+ mode: schedule.mode,
660
+ ...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
661
+ timeZone: scheduleZone(schedule),
662
+ nextRunAt: schedule.nextRunAt,
663
+ })
598
664
  }
599
665
  return due
600
666
  }
@@ -646,38 +712,140 @@ export class HostTaskLedger {
646
712
  /**
647
713
  * Open the cascade one due schedule triggers: an empty array means nothing
648
714
  * ran (already running, or a participant whose elevated permission is still
649
- * unconfirmed), and the rule rolls to its next occurrence either way.
715
+ * unconfirmed, a one-shot whose instant was missed, or an exhausted budget),
716
+ * and the rule's own state says which.
717
+ *
718
+ * The run budget is enforced HERE, inside the single ledger writer: a rule
719
+ * whose counter already reached its cap is stopped instead of opened, so no
720
+ * restart, repeated timer fire or re-arm can overrun it.
721
+ * @param taskId - the task whose rule came due.
722
+ * @param triggeredAt - the instant the occurrence came due.
723
+ * @returns the executions this occurrence opened.
650
724
  */
651
- openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun[] {
725
+ openScheduled(taskId: string, triggeredAt: number): OpenedRun[] {
652
726
  const task = this.document.tasks.find(item => item.id === taskId)
653
727
  if (task === undefined || task.archivedAt !== undefined) return []
654
- const rollForward = (): void => {
655
- this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
656
- this.commit()
728
+ const schedule = task.schedule
729
+ if (schedule === undefined || !schedule.enabled) return []
730
+
731
+ // A rule that has spent its budget never opens another run. The stop is
732
+ // recorded here rather than trusted to the firing timer, so a rule that
733
+ // was hand-edited into an exhausted state still terminates visibly.
734
+ if (scheduleExhausted(schedule)) {
735
+ this.progressSchedule(taskId, {
736
+ consumed: 0,
737
+ endedReason: schedule.mode === 'once' ? 'fired' : 'limit',
738
+ }, triggeredAt)
739
+ return []
657
740
  }
741
+
658
742
  const refusal = this.bindingRefusal(task)
659
743
  if (refusal !== undefined) {
660
744
  // An unconfirmed above-default permission must never run unattended: the
661
- // whole tree is refused and the schedule rolls to the next occurrence,
662
- // exactly like the already-running refusal.
745
+ // whole tree is refused and the occurrence is skipped without consuming
746
+ // the run budget, exactly like the already-running refusal.
663
747
  this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
664
- rollForward()
748
+ this.skipOccurrence(taskId, 'permission', triggeredAt)
665
749
  return []
666
750
  }
667
751
  if (hasOpenExecution(task)) {
668
- rollForward()
752
+ this.skipOccurrence(taskId, 'busy', triggeredAt)
753
+ return []
754
+ }
755
+
756
+ if (schedule.mode === 'once') {
757
+ // The single planned instant is due: open its one run and stop the rule
758
+ // in the same commit, so the budget can never produce a second one.
759
+ return this.startCascade(task, triggeredAt, undefined, false, {
760
+ consumed: 1,
761
+ lastTriggeredAt: triggeredAt,
762
+ endedReason: 'fired',
763
+ })
764
+ }
765
+
766
+ const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? triggeredAt, scheduleZone(schedule))
767
+ if (next === undefined) {
768
+ this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, triggeredAt)
669
769
  return []
670
770
  }
671
- return this.startCascade(task, triggeredAt, undefined, false, nextRunAt)
771
+ const consumed = schedule.runCount + 1
772
+ const budget = scheduleRunBudget(schedule)
773
+ const spent = budget !== undefined && consumed >= budget
774
+ return this.startCascade(task, triggeredAt, undefined, false, {
775
+ consumed: 1,
776
+ nextRunAt: spent ? undefined : next,
777
+ lastTriggeredAt: triggeredAt,
778
+ ...(spent ? { endedReason: 'limit' as const } : {}),
779
+ })
780
+ }
781
+
782
+ /**
783
+ * Record a due occurrence that opened no execution: roll a recurring rule to
784
+ * its next occurrence (budget untouched), or stop it when there is none. A
785
+ * one-shot has no second occurrence, so it always stops here with the reason.
786
+ */
787
+ private skipOccurrence(taskId: string, reason: ScheduleStopReason, now: number): void {
788
+ const task = this.document.tasks.find(item => item.id === taskId)
789
+ const schedule = task?.schedule
790
+ if (schedule === undefined) return
791
+ if (schedule.mode === 'once') {
792
+ this.progressSchedule(taskId, { consumed: 0, endedReason: reason }, now)
793
+ return
794
+ }
795
+ const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? now, scheduleZone(schedule))
796
+ if (next === undefined) this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, now)
797
+ else this.progressSchedule(taskId, { consumed: 0, nextRunAt: next, skippedReason: reason }, now)
672
798
  }
673
799
 
800
+ /** Apply a schedule-occurrence transition and persist it atomically. */
801
+ private progressSchedule(taskId: string, progress: ScheduleProgress, now: number): void {
802
+ this.document.tasks = [...applyScheduleProgress(this.document.tasks, taskId, progress, now)]
803
+ this.commit()
804
+ }
805
+
806
+ /**
807
+ * Refund the one scheduled run an occurrence consumed before its launch
808
+ * failed without creating a session (see
809
+ * {@link applyScheduleRefund}). Called by the service on the failed-launch
810
+ * path only; a manual run and a launch that did reach a session are never
811
+ * refunded. A no-op for a task with no rule, so a failed cascade member does
812
+ * not touch anything.
813
+ * @param taskId - the task whose occurrence is being refunded.
814
+ * @param now - clock instant (ms epoch).
815
+ */
816
+ refundScheduledOccurrence(taskId: string, now: number): void {
817
+ const before = this.document.tasks.find(item => item.id === taskId)?.schedule
818
+ if (before === undefined) return
819
+ const next = applyScheduleRefund(this.document.tasks, taskId, now, timeZone())
820
+ const after = next.find(item => item.id === taskId)?.schedule
821
+ if (JSON.stringify(before) === JSON.stringify(after)) return
822
+ this.document.tasks = [...next]
823
+ this.commit()
824
+ }
825
+
826
+ /**
827
+ * Boot / resume recovery: skip every occurrence that came due while the board
828
+ * was not running through it. A recurring rule rolls to its next future
829
+ * target; a one-shot whose instant has passed is stopped as missed, because
830
+ * there is no later occurrence left to run. Rendering the missed occurrence
831
+ * is deliberately not attempted: the ACL of a card that fired hours ago is
832
+ * stale, and the board's own recovery contract is "missed triggers are
833
+ * skipped, never replayed".
834
+ */
674
835
  skipMissed(now: number): void {
675
836
  let changed = false
676
837
  this.document.tasks = this.document.tasks.map(task => {
677
838
  const schedule = task.schedule
678
839
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
679
840
  changed = true
680
- return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule)) }, updatedAt: now }
841
+ if (schedule.mode === 'once') {
842
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
843
+ }
844
+ const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
845
+ if (next === undefined) {
846
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
847
+ }
848
+ return withSchedule(task, { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }, now)
681
849
  })
682
850
  if (changed) this.commit()
683
851
  }
@@ -782,7 +950,9 @@ export class HostTaskLedger {
782
950
  const sources = new Set(this.document.scheduler.importedSources ?? [])
783
951
  if (sources.has(action.sourceId)) return { state: this.state() }
784
952
  const invalidScheduleIds = action.tasks
785
- .filter(task => task.schedule !== undefined && !isValidCron(task.schedule.cron))
953
+ .filter(task => task.schedule !== undefined
954
+ && task.schedule.mode !== 'once'
955
+ && !isValidCron(task.schedule.cron ?? ''))
786
956
  .map(task => task.id)
787
957
  const incoming = parseHostTasks(action.tasks)
788
958
  const merged = new Map(this.document.tasks.map(task => [task.id, task]))
@@ -803,15 +973,14 @@ export class HostTaskLedger {
803
973
  }
804
974
  case 'create': {
805
975
  if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
806
- if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
807
- || !isValidTimeZone(action.input.schedule.timeZone ?? timeZone())
808
- || nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
976
+ const requested = action.input.schedule
977
+ if (requested?.enabled === true && !this.validScheduleRequest(requested, now)) {
809
978
  throw new Error('invalid schedule')
810
979
  }
811
980
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
812
981
  ? action.input
813
982
  : { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
814
- const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
983
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone(), this.cardDefaults())
815
984
  if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
816
985
  this.document.tasks = [...result.tasks]
817
986
  break
@@ -873,6 +1042,37 @@ export class HostTaskLedger {
873
1042
  this.document.tasks = [...result.tasks]
874
1043
  break
875
1044
  }
1045
+ case 'record-external-outcome': {
1046
+ const task = this.document.tasks.find(item => item.id === action.taskId)
1047
+ if (task === undefined) throw new Error('task not found')
1048
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
1049
+ // The one guardrail that matters: while the Host still owns a run it is
1050
+ // the only authority on the card, so an outside agent may not write the
1051
+ // terminal verdict over it. This is also what keeps a stale report from
1052
+ // racing the session it claims to have replaced.
1053
+ if (hasOpenExecution(task)) throw new Error('running task cannot receive an external outcome')
1054
+ const execution: ExecutionRecord = {
1055
+ // A fresh id, exactly like a Host-launched run: the external work is a
1056
+ // new attempt in the history, never an overwrite of an earlier record.
1057
+ id: crypto.randomUUID(),
1058
+ sessionId: undefined,
1059
+ startedAt: now,
1060
+ endedAt: now,
1061
+ result: action.result,
1062
+ error: action.summary,
1063
+ initiatedBy: action.initiatedBy,
1064
+ external: true,
1065
+ }
1066
+ this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId
1067
+ ? item
1068
+ : {
1069
+ ...item,
1070
+ status: settledStatus(item, action.result),
1071
+ updatedAt: now,
1072
+ executions: retainRecentExecutions([...item.executions, execution]),
1073
+ })
1074
+ break
1075
+ }
876
1076
  case 'settle': {
877
1077
  const task = this.document.tasks.find(item => item.id === action.taskId)
878
1078
  if (task === undefined) throw new Error('task not found')
@@ -901,6 +1101,32 @@ export class HostTaskLedger {
901
1101
  this.finalizeReadyRuns(false)
902
1102
  break
903
1103
  }
1104
+ case 'reset-verification': {
1105
+ // The explicit user reset of the acceptance anomaly counter (issue
1106
+ // #1828). It clears the attempts the ENVIRONMENT produced — timeouts,
1107
+ // authentication failures, unresolvable routes, and attempts the time
1108
+ // budget ended — and nothing else: a quality verdict is the board's own
1109
+ // judgement of the work and survives every reset. Only the open
1110
+ // execution is addressable, because the reset exists to unblock a
1111
+ // completion claim on a run that is still going.
1112
+ const task = this.document.tasks.find(item => item.id === action.taskId)
1113
+ if (task === undefined) throw new Error('task not found')
1114
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
1115
+ const execution = [...task.executions].reverse().find(entry => entry.endedAt === undefined)
1116
+ if (execution === undefined) throw new Error('task has no open execution')
1117
+ const verification = execution.verification
1118
+ if (verification === undefined || verification.applicability !== 'enforced') {
1119
+ throw new Error('this execution is not gated by task acceptance')
1120
+ }
1121
+ const cleared = withoutAcceptanceAnomalies(verification)
1122
+ if (cleared === undefined) throw new Error('this execution has no acceptance anomaly to reset')
1123
+ this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId ? item : {
1124
+ ...item,
1125
+ updatedAt: now,
1126
+ executions: item.executions.map(entry => entry.id === execution.id ? { ...entry, verification: cleared } : entry),
1127
+ })
1128
+ break
1129
+ }
904
1130
  case 'restore': {
905
1131
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
906
1132
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
@@ -957,6 +1183,31 @@ export class HostTaskLedger {
957
1183
  return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
958
1184
  }
959
1185
 
1186
+ /**
1187
+ * Whether a creation-time schedule request is usable. A one-shot must carry
1188
+ * a whole-millisecond instant in the future; a recurring rule must carry a
1189
+ * valid expression with a reachable occurrence and, when capped, a positive
1190
+ * whole run budget. An unusable zone is refused rather than reinterpreted as
1191
+ * the Host zone.
1192
+ */
1193
+ private validScheduleRequest(request: {
1194
+ readonly enabled: boolean
1195
+ readonly mode?: ScheduleMode
1196
+ readonly cron?: string
1197
+ readonly at?: number
1198
+ readonly timeZone?: string
1199
+ readonly maxRuns?: number
1200
+ }, now: number): boolean {
1201
+ if (request.timeZone !== undefined && !isValidTimeZone(request.timeZone)) return false
1202
+ if (request.mode === 'once') {
1203
+ return typeof request.at === 'number' && Number.isInteger(request.at) && request.at > now
1204
+ }
1205
+ const cron = (request.cron ?? '').trim()
1206
+ if (cron === '' || !isValidCron(cron)) return false
1207
+ if (request.maxRuns !== undefined && (!Number.isInteger(request.maxRuns) || request.maxRuns < 1)) return false
1208
+ return nextRunAtMs(cron, now, request.timeZone ?? timeZone()) !== undefined
1209
+ }
1210
+
960
1211
  /**
961
1212
  * The binding that makes a run illegal, if any.
962
1213
  *
@@ -1017,17 +1268,20 @@ export class HostTaskLedger {
1017
1268
  }
1018
1269
 
1019
1270
  /**
1020
- * Open one execution per cascade participant (the requested task and its
1021
- * on-board descendants, within the depth limit) under a single run group, and
1022
- * commit the ledger once. The returned runs are ordered root-first, so the
1023
- * Host launches the parent before its subtasks.
1271
+ * Open one execution per cascade participant that still has work (the
1272
+ * requested task and its on-board descendants, within the depth limit) under
1273
+ * a single run group, and commit the ledger once. A descendant in 'done' is
1274
+ * not a participant ({@link joinsCascadeRun}). The returned runs are ordered
1275
+ * root-first, so the Host launches the parent before its subtasks.
1024
1276
  * @param root - the task the user ran.
1025
1277
  * @param now - clock instant (ms epoch).
1026
1278
  * @param initiator - the DSH session that asked for the run (audit only).
1027
1279
  * @param rerun - true to reset the root to 'todo' before starting it.
1028
- * @param nextRunAt - when set, the root's schedule also rolls forward.
1280
+ * @param progress - when set, this run was a due occurrence and the root's
1281
+ * rule is advanced (counter, next target, stop/skip record) in the same
1282
+ * atomic commit that opened the executions.
1029
1283
  */
1030
- private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, nextRunAt?: number): OpenedRun[] {
1284
+ private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, progress?: ScheduleProgress): OpenedRun[] {
1031
1285
  const before = this.document.tasks
1032
1286
  const groupId = crypto.randomUUID()
1033
1287
  // A team-mode root runs as the Team Lead: every other member of the same
@@ -1038,6 +1292,11 @@ export class HostTaskLedger {
1038
1292
  const runs: OpenedRun[] = []
1039
1293
  for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
1040
1294
  if (task.archivedAt !== undefined) continue
1295
+ // A subtask that already sits in 'done' is left alone: running the parent
1296
+ // repeats the parent, never the work a subtask has already finished. The
1297
+ // rule is the card's column, so it holds for a manual run, a re-run and a
1298
+ // due occurrence alike.
1299
+ if (!joinsCascadeRun(task, root.id)) continue
1041
1300
  if (hasOpenExecution(task)) continue
1042
1301
  const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
1043
1302
  const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
@@ -1050,7 +1309,7 @@ export class HostTaskLedger {
1050
1309
  }
1051
1310
  if (runs.length === 0) return []
1052
1311
  let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
1053
- if (nextRunAt !== undefined) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now)
1312
+ if (progress !== undefined) tasks = applyScheduleProgress(tasks, root.id, progress, now)
1054
1313
  this.document.tasks = [...tasks]
1055
1314
  this.commit()
1056
1315
  return runs
@@ -1181,6 +1440,14 @@ export class HostTaskLedger {
1181
1440
  return changed
1182
1441
  }
1183
1442
 
1443
+ /**
1444
+ * Re-align every armed rule at load/import. A recurring rule is recomputed
1445
+ * from the current instant; a one-shot is armed at its planned instant when
1446
+ * that is still in the future, and stopped as missed once it has passed
1447
+ * (there is no later occurrence to run). A recurring rule recomputed past an
1448
+ * occurrence records the skip, so the board can show that a fire was passed
1449
+ * over rather than silently rewinding.
1450
+ */
1184
1451
  private repairSchedules(skipPast: boolean, persist = true): void {
1185
1452
  const now = this.now()
1186
1453
  let changed = false
@@ -1188,15 +1455,32 @@ export class HostTaskLedger {
1188
1455
  const schedule = task.schedule
1189
1456
  if (schedule === undefined || !schedule.enabled) return task
1190
1457
  if (!skipPast && schedule.nextRunAt !== undefined) return task
1191
- const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
1458
+ if (schedule.mode === 'once') {
1459
+ const at = schedule.at
1460
+ if (at === undefined) {
1461
+ changed = true
1462
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
1463
+ }
1464
+ if (at <= now) {
1465
+ changed = true
1466
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
1467
+ }
1468
+ if (schedule.nextRunAt === at) return task
1469
+ changed = true
1470
+ return withSchedule(task, { nextRunAt: at }, now)
1471
+ }
1472
+ const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
1192
1473
  if (next === undefined) {
1193
1474
  changed = true
1194
1475
  this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
1195
- return { ...task, schedule: { ...schedule, enabled: false, nextRunAt: undefined }, updatedAt: now }
1476
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
1196
1477
  }
1197
- if (schedule.nextRunAt === next) return task
1478
+ const passed = schedule.nextRunAt !== undefined && schedule.nextRunAt <= now
1479
+ if (schedule.nextRunAt === next && !passed) return task
1198
1480
  changed = true
1199
- return { ...task, schedule: { ...schedule, nextRunAt: next }, updatedAt: now }
1481
+ return withSchedule(task, passed
1482
+ ? { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }
1483
+ : { nextRunAt: next }, now)
1200
1484
  })
1201
1485
  if (changed && persist) this.commit()
1202
1486
  }
@@ -1245,15 +1529,23 @@ export class HostTaskLedger {
1245
1529
  if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
1246
1530
  throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
1247
1531
  }
1532
+ // Only generations before the stored zone was introduced (plus the one that
1533
+ // introduced it) get the Host zone stamped: a v5 rule either carries its own
1534
+ // zone or deliberately follows the Host, and re-stamping it would freeze it
1535
+ // to the zone in effect at this load.
1536
+ const stampZone = typeof parsed.schemaVersion === 'number'
1537
+ && parsed.schemaVersion <= TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION
1248
1538
  // Every row passed `isTaskRecord` above, so the stamped rows are still
1249
1539
  // task records; the cast only re-narrows the unknown-typed on-disk array.
1250
- const rows = (parsed.tasks as readonly unknown[]).map((value) => {
1251
- const row = value as { schedule?: unknown }
1252
- const schedule = row.schedule
1253
- if (typeof schedule !== 'object' || schedule === null) return value
1254
- if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
1255
- return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
1256
- })
1540
+ const rows = stampZone
1541
+ ? (parsed.tasks as readonly unknown[]).map((value) => {
1542
+ const row = value as { schedule?: unknown }
1543
+ const schedule = row.schedule
1544
+ if (typeof schedule !== 'object' || schedule === null) return value
1545
+ if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
1546
+ return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
1547
+ })
1548
+ : parsed.tasks
1257
1549
  return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
1258
1550
  }
1259
1551
 
@@ -1339,8 +1631,14 @@ export class HostTaskLedger {
1339
1631
  if (typeof value !== 'object' || value === null) return []
1340
1632
  const row = value as { id?: unknown; schedule?: unknown }
1341
1633
  if (typeof row.schedule !== 'object' || row.schedule === null) return []
1342
- const cron = (row.schedule as { cron?: unknown }).cron
1343
- return typeof cron !== 'string' || !isValidCron(cron)
1634
+ const rule = row.schedule as { cron?: unknown; mode?: unknown; at?: unknown }
1635
+ // A one-shot has no cron: its instant is what must be usable.
1636
+ if (rule.mode === 'once') {
1637
+ return typeof rule.at === 'number' && Number.isFinite(rule.at)
1638
+ ? []
1639
+ : [typeof row.id === 'string' ? row.id : 'unknown']
1640
+ }
1641
+ return typeof rule.cron !== 'string' || !isValidCron(rule.cron)
1344
1642
  ? [typeof row.id === 'string' ? row.id : 'unknown']
1345
1643
  : []
1346
1644
  })