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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +20 -12
  3. package/README.zh.md +20 -12
  4. package/lib/client.js +1327 -229
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1909 -348
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
  8. package/lib/types/client/index.d.ts +4 -5
  9. package/lib/types/client/locales.d.ts +65 -0
  10. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  11. package/lib/types/client/schedule-zone.d.ts +50 -0
  12. package/lib/types/core/controller.d.ts +10 -11
  13. package/lib/types/core/poll-cadence.d.ts +31 -0
  14. package/lib/types/core/tasks.d.ts +144 -14
  15. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  16. package/lib/types/core/verification-budget.d.ts +52 -0
  17. package/lib/types/core/verification.d.ts +376 -9
  18. package/lib/types/host/verification-gate.d.ts +24 -0
  19. package/lib/types/host/verification-runner.d.ts +38 -7
  20. package/lib/types/host-ledger.d.ts +82 -7
  21. package/lib/types/host-runner.d.ts +32 -9
  22. package/lib/types/host-service.d.ts +117 -9
  23. package/lib/types/index.d.ts +54 -0
  24. package/lib/types/protocol.d.ts +50 -12
  25. package/package.json +2 -6
  26. package/src/client/TaskBoardSettingsCard.tsx +74 -1
  27. package/src/client/board/NewTaskModal.tsx +151 -20
  28. package/src/client/board/TaskCard.tsx +3 -3
  29. package/src/client/board/TaskDetail.tsx +219 -18
  30. package/src/client/board/VerificationReport.tsx +132 -13
  31. package/src/client/board/status-key.ts +3 -0
  32. package/src/client/index.ts +43 -50
  33. package/src/client/locales.ts +137 -4
  34. package/src/client/plugin-card-seat.ts +7 -0
  35. package/src/client/schedule-zone.ts +100 -2
  36. package/src/core/controller.ts +12 -8
  37. package/src/core/poll-cadence.ts +41 -0
  38. package/src/core/store.ts +64 -15
  39. package/src/core/tasks.ts +193 -39
  40. package/src/core/use-cases/task-create.ts +30 -9
  41. package/src/core/use-cases/task-schedule.ts +190 -25
  42. package/src/core/use-cases/task-update.ts +3 -3
  43. package/src/core/verification-budget.ts +115 -0
  44. package/src/core/verification.ts +785 -17
  45. package/src/host/agent-tools.ts +106 -21
  46. package/src/host/verification-gate.ts +172 -16
  47. package/src/host/verification-runner.ts +207 -40
  48. package/src/host-ledger.ts +328 -42
  49. package/src/host-runner.ts +52 -19
  50. package/src/host-service.ts +299 -36
  51. package/src/index.ts +101 -5
  52. package/src/protocol.ts +132 -28
  53. package/lib/invariant.js +0 -6
  54. package/lib/types/client/board-card-seat.d.ts +0 -34
  55. package/lib/types/invariant.d.ts +0 -3
  56. package/src/client/board-card-seat.ts +0 -102
  57. package/src/invariant.ts +0 -4
@@ -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, 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,
@@ -18,12 +18,12 @@ 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
23
  import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
24
24
  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'
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
27
  import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
28
28
 
29
29
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
@@ -98,7 +98,10 @@ export interface OpenExecutionReference {
98
98
  /** Minimal value copy used by the Host scheduler. */
99
99
  export interface DueScheduleReference {
100
100
  readonly taskId: string
101
- 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
102
105
  /** Zone this rule's wall clock is read in. */
103
106
  readonly timeZone: string
104
107
  readonly nextRunAt: number
@@ -108,6 +111,24 @@ export interface DueScheduleReference {
108
111
  export interface LedgerRuntimeView {
109
112
  readonly armedSchedules: number
110
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
111
132
  }
112
133
 
113
134
  const MAX_REQUEST_CACHE = 256
@@ -125,8 +146,25 @@ function scheduleZone(schedule: { timeZone?: string }): string {
125
146
  return schedule.timeZone ?? timeZone()
126
147
  }
127
148
 
128
- function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
129
- return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
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]
130
168
  }
131
169
 
132
170
  /**
@@ -397,17 +435,24 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
397
435
  const rawSchedule = rawById.get(task.id)?.schedule
398
436
  if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
399
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
400
441
  if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task
401
442
  return {
402
443
  ...task,
403
444
  schedule: {
404
445
  enabled: false,
446
+ mode: 'cron',
405
447
  cron: schedule.cron,
406
448
  ...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
407
449
  nextRunAt: undefined,
408
450
  lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
409
451
  ? schedule.lastTriggeredAt
410
452
  : undefined,
453
+ runCount: typeof schedule.runCount === 'number' && Number.isInteger(schedule.runCount) && schedule.runCount >= 0
454
+ ? schedule.runCount
455
+ : 0,
411
456
  },
412
457
  }
413
458
  })
@@ -517,16 +562,17 @@ export class HostTaskLedger {
517
562
 
518
563
  state(): LedgerState {
519
564
  const { revision, scheduler } = this.summary()
520
- return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
565
+ return { revision, tasks: tasksForRead(this.document.tasks), scheduler }
521
566
  }
522
567
 
523
568
  /**
524
- * 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
525
570
  * identifiers and timestamps, never the complete task/execution history or
526
571
  * an authoritative mutable object from the ledger.
527
572
  */
528
573
  runtimeView(): LedgerRuntimeView {
529
574
  let armedSchedules = 0
575
+ let runningCards = 0
530
576
  // Run groups opened by a team-mode card: every other member of those groups
531
577
  // runs as a teammate inside that card's Lead session.
532
578
  const teamGroups = new Set<string>()
@@ -537,10 +583,13 @@ export class HostTaskLedger {
537
583
  }
538
584
  }
539
585
  const openExecutions: OpenExecutionReference[] = []
586
+ const openSessionIds: string[] = []
540
587
  for (const task of this.document.tasks) {
541
588
  if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
589
+ if (task.status === 'running') runningCards += 1
542
590
  for (const execution of task.executions) {
543
591
  if (execution.endedAt !== undefined) continue
592
+ if (execution.sessionId !== undefined) openSessionIds.push(execution.sessionId)
544
593
  // A deferred cascade parent already knows its own outcome; the monitor
545
594
  // has nothing left to inspect, and its children's settles finalize it.
546
595
  if (execution.ownResult !== undefined) continue
@@ -555,7 +604,12 @@ export class HostTaskLedger {
555
604
  })
556
605
  }
557
606
  }
558
- return { armedSchedules, openExecutions }
607
+ return {
608
+ armedSchedules,
609
+ openExecutions,
610
+ openSessionIds,
611
+ needsSessionState: openExecutions.length > 0 || runningCards > 0,
612
+ }
559
613
  }
560
614
 
561
615
  /** Count armed, non-archived schedules without cloning task histories. */
@@ -594,7 +648,13 @@ export class HostTaskLedger {
594
648
  if (task.archivedAt !== undefined) continue
595
649
  const schedule = task.schedule
596
650
  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 })
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
+ })
598
658
  }
599
659
  return due
600
660
  }
@@ -646,38 +706,140 @@ export class HostTaskLedger {
646
706
  /**
647
707
  * Open the cascade one due schedule triggers: an empty array means nothing
648
708
  * ran (already running, or a participant whose elevated permission is still
649
- * 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.
650
718
  */
651
- openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun[] {
719
+ openScheduled(taskId: string, triggeredAt: number): OpenedRun[] {
652
720
  const task = this.document.tasks.find(item => item.id === taskId)
653
721
  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()
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 []
657
734
  }
735
+
658
736
  const refusal = this.bindingRefusal(task)
659
737
  if (refusal !== undefined) {
660
738
  // 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.
739
+ // whole tree is refused and the occurrence is skipped without consuming
740
+ // the run budget, exactly like the already-running refusal.
663
741
  this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
664
- rollForward()
742
+ this.skipOccurrence(taskId, 'permission', triggeredAt)
665
743
  return []
666
744
  }
667
745
  if (hasOpenExecution(task)) {
668
- rollForward()
746
+ this.skipOccurrence(taskId, 'busy', triggeredAt)
669
747
  return []
670
748
  }
671
- 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
+ })
774
+ }
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()
672
818
  }
673
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
+ */
674
829
  skipMissed(now: number): void {
675
830
  let changed = false
676
831
  this.document.tasks = this.document.tasks.map(task => {
677
832
  const schedule = task.schedule
678
833
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
679
834
  changed = true
680
- 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)
681
843
  })
682
844
  if (changed) this.commit()
683
845
  }
@@ -782,7 +944,9 @@ export class HostTaskLedger {
782
944
  const sources = new Set(this.document.scheduler.importedSources ?? [])
783
945
  if (sources.has(action.sourceId)) return { state: this.state() }
784
946
  const invalidScheduleIds = action.tasks
785
- .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 ?? ''))
786
950
  .map(task => task.id)
787
951
  const incoming = parseHostTasks(action.tasks)
788
952
  const merged = new Map(this.document.tasks.map(task => [task.id, task]))
@@ -803,9 +967,8 @@ export class HostTaskLedger {
803
967
  }
804
968
  case 'create': {
805
969
  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)) {
970
+ const requested = action.input.schedule
971
+ if (requested?.enabled === true && !this.validScheduleRequest(requested, now)) {
809
972
  throw new Error('invalid schedule')
810
973
  }
811
974
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
@@ -873,6 +1036,37 @@ export class HostTaskLedger {
873
1036
  this.document.tasks = [...result.tasks]
874
1037
  break
875
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
+ }
876
1070
  case 'settle': {
877
1071
  const task = this.document.tasks.find(item => item.id === action.taskId)
878
1072
  if (task === undefined) throw new Error('task not found')
@@ -901,6 +1095,32 @@ export class HostTaskLedger {
901
1095
  this.finalizeReadyRuns(false)
902
1096
  break
903
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
+ }
904
1124
  case 'restore': {
905
1125
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
906
1126
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
@@ -957,6 +1177,31 @@ export class HostTaskLedger {
957
1177
  return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
958
1178
  }
959
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
+
960
1205
  /**
961
1206
  * The binding that makes a run illegal, if any.
962
1207
  *
@@ -1025,9 +1270,11 @@ export class HostTaskLedger {
1025
1270
  * @param now - clock instant (ms epoch).
1026
1271
  * @param initiator - the DSH session that asked for the run (audit only).
1027
1272
  * @param rerun - true to reset the root to 'todo' before starting it.
1028
- * @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.
1029
1276
  */
1030
- 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[] {
1031
1278
  const before = this.document.tasks
1032
1279
  const groupId = crypto.randomUUID()
1033
1280
  // A team-mode root runs as the Team Lead: every other member of the same
@@ -1050,7 +1297,7 @@ export class HostTaskLedger {
1050
1297
  }
1051
1298
  if (runs.length === 0) return []
1052
1299
  let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
1053
- if (nextRunAt !== undefined) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now)
1300
+ if (progress !== undefined) tasks = applyScheduleProgress(tasks, root.id, progress, now)
1054
1301
  this.document.tasks = [...tasks]
1055
1302
  this.commit()
1056
1303
  return runs
@@ -1181,6 +1428,14 @@ export class HostTaskLedger {
1181
1428
  return changed
1182
1429
  }
1183
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
+ */
1184
1439
  private repairSchedules(skipPast: boolean, persist = true): void {
1185
1440
  const now = this.now()
1186
1441
  let changed = false
@@ -1188,15 +1443,32 @@ export class HostTaskLedger {
1188
1443
  const schedule = task.schedule
1189
1444
  if (schedule === undefined || !schedule.enabled) return task
1190
1445
  if (!skipPast && schedule.nextRunAt !== undefined) return task
1191
- 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))
1192
1461
  if (next === undefined) {
1193
1462
  changed = true
1194
1463
  this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
1195
- return { ...task, schedule: { ...schedule, enabled: false, nextRunAt: undefined }, updatedAt: now }
1464
+ return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
1196
1465
  }
1197
- if (schedule.nextRunAt === next) return task
1466
+ const passed = schedule.nextRunAt !== undefined && schedule.nextRunAt <= now
1467
+ if (schedule.nextRunAt === next && !passed) return task
1198
1468
  changed = true
1199
- return { ...task, schedule: { ...schedule, nextRunAt: next }, updatedAt: now }
1469
+ return withSchedule(task, passed
1470
+ ? { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }
1471
+ : { nextRunAt: next }, now)
1200
1472
  })
1201
1473
  if (changed && persist) this.commit()
1202
1474
  }
@@ -1245,15 +1517,23 @@ export class HostTaskLedger {
1245
1517
  if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
1246
1518
  throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
1247
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
1248
1526
  // Every row passed `isTaskRecord` above, so the stamped rows are still
1249
1527
  // 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
- })
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
1257
1537
  return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
1258
1538
  }
1259
1539
 
@@ -1339,8 +1619,14 @@ export class HostTaskLedger {
1339
1619
  if (typeof value !== 'object' || value === null) return []
1340
1620
  const row = value as { id?: unknown; schedule?: unknown }
1341
1621
  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)
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)
1344
1630
  ? [typeof row.id === 'string' ? row.id : 'unknown']
1345
1631
  : []
1346
1632
  })