@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
@@ -16,7 +16,7 @@ import { hasOpenExecution, withStatus, type NewTaskInput, type TaskRecord, type
16
16
  import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
17
17
  import { applyCreateTask } from './use-cases/task-create.ts'
18
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
19
- import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
19
+ import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule, type SetSchedulePatch } from './use-cases/task-schedule.ts'
20
20
  import { resolveHostTimeZone } from './schedule.ts'
21
21
  import { applySetParent } from './use-cases/task-parent.ts'
22
22
  import { applyDeleteTag, applyRenameTag } from './use-cases/task-tag.ts'
@@ -127,6 +127,17 @@ export interface ExecutionModelOption {
127
127
  name?: string
128
128
  label?: string
129
129
  provider?: string
130
+ /**
131
+ * Reasoning metadata the adapter declares for this exact route. An effort
132
+ * picker is offered only while `supported`, and its choices are `efforts` —
133
+ * the ids the target model really accepts, so the UI never offers (or keeps)
134
+ * a level the model would reject. Absent means the deployment published no
135
+ * capability for this model; the card value is then left untouched.
136
+ */
137
+ reasoning?: {
138
+ supported: boolean
139
+ efforts?: readonly string[]
140
+ }
130
141
  }
131
142
 
132
143
  /** The execution-target option sets the UI feeds into the controller. */
@@ -141,7 +152,7 @@ export interface ExecutionOptionsSnapshot {
141
152
  * (revision/scheduler/power); a full snapshot also carries the deployment
142
153
  * constants the UI reads (session default permission, subtask depth).
143
154
  */
144
- export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'extensions'>
155
+ export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'goalRunEnabled' | 'cardDefaults' | 'extensions'>
145
156
 
146
157
  /** Immutable controller snapshot for UI subscriptions. */
147
158
  export interface ControllerSnapshot {
@@ -318,6 +329,22 @@ export class BoardController {
318
329
  return await this.initializeRemote()
319
330
  }
320
331
 
332
+ /**
333
+ * Re-read the authoritative Host snapshot.
334
+ *
335
+ * The board's volatile deployment configuration — the new-card defaults among
336
+ * it — is republished the moment the settings document is written, but the SSE
337
+ * frame that republish emits carries the value as of BEFORE the write (the
338
+ * loader hands the update to the fiber while the new document is not yet
339
+ * visible to it), so a mirror that followed frames alone would stay one edit
340
+ * behind and the create dialog would prefill from the previous defaults for
341
+ * the rest of the session. The surface that owns the write asks for the
342
+ * snapshot again instead of trusting the frame.
343
+ */
344
+ refreshHostState(): void {
345
+ void this.refreshRemote()
346
+ }
347
+
321
348
  // --- view state -------------------------------------------------------------
322
349
 
323
350
  /**
@@ -407,7 +434,7 @@ export class BoardController {
407
434
 
408
435
  createTask(input: NewTaskInput): TaskRecord | undefined {
409
436
  const id = this.uuid()
410
- const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth())
437
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth(), undefined, this.hostState?.cardDefaults)
411
438
  if (task === undefined) return undefined
412
439
  this.tasks = [...tasks]
413
440
  this.persistAndNotify()
@@ -421,7 +448,7 @@ export class BoardController {
421
448
  // The preview must obey the SAME lineage gate the Host will: a browser-side
422
449
  // default of one level would reject a legal subtask-of-subtask before the
423
450
  // request is ever sent.
424
- const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth()).task
451
+ const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth(), undefined, this.hostState?.cardDefaults).task
425
452
  if (preview === undefined) return undefined
426
453
  return await this.commitRemote({ kind: 'create', id, input }, id)
427
454
  ? this.tasks.find(task => task.id === id)
@@ -597,15 +624,17 @@ export class BoardController {
597
624
  // --- scheduling ---------------------------------------------------------------
598
625
 
599
626
  /**
600
- * Update a task's schedule rule. A blank or invalid cron expression is
601
- * rejected (returns false, state untouched). When the rule ends up enabled
602
- * the next run instant is computed immediately; a disabled rule carries no
603
- * next-run instant. Delegates the domain transition to the schedule use case.
627
+ * Update a task's schedule rule. An unusable plan (blank or invalid cron, a
628
+ * one-shot with no future instant, a non-positive run cap) is rejected
629
+ * (returns false, state untouched). When the rule ends up enabled the next
630
+ * run instant is computed immediately — a one-shot's next instant IS its
631
+ * planned instant — and a disabled rule carries none. Delegates the domain
632
+ * transition to the schedule use case.
604
633
  * @param id - the task to schedule.
605
634
  * @param patch - fields to change (absent fields keep their current value).
606
- * @returns true when applied, false when rejected (invalid cron / unknown task).
635
+ * @returns true when applied, false when rejected (unknown task / unusable plan).
607
636
  */
608
- setSchedule(id: string, patch: { enabled?: boolean; cron?: string; timeZone?: string | null }): boolean {
637
+ setSchedule(id: string, patch: SetSchedulePatch): boolean {
609
638
  // The Host zone is the fallback a rule with no stored zone follows: the
610
639
  // mirrored snapshot carries it, and a controller without a Host mirror
611
640
  // (pure client tests) falls back to the process zone.
@@ -802,17 +831,25 @@ export class BoardController {
802
831
  }
803
832
 
804
833
  /**
805
- * Project a Host snapshot onto the browser's mirror. SSE frames and partial
806
- * snapshots carry only the volatile subset (revision/scheduler/power), so the
807
- * deployment constants the UI reads — the session-default permission the
808
- * confirmation banner compares against and the subtask depth limit — are
809
- * carried over from the last full snapshot instead of being dropped by a
810
- * heartbeat frame.
834
+ * Project a Host snapshot onto the browser's mirror. Every snapshot the
835
+ * transport serves is a full document (bootstrap/state/action all answer
836
+ * `TaskBoardHostService.snapshot()`), so the deployment constants the UI reads
837
+ * — the session-default permission the confirmation banner compares against,
838
+ * the subtask depth limit, the platform switches and the extensions a provider
839
+ * published — are taken as published, carried over only where the snapshot
840
+ * itself omits the key.
811
841
  */
812
842
  private mirrorOf(snapshot: TaskBoardSnapshot): HostMirror {
813
843
  const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
814
844
  const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
815
845
  const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
846
+ const goalRunEnabled = snapshot.goalRunEnabled ?? this.hostState?.goalRunEnabled
847
+ // The new-card defaults are NOT carried over: the Host publishes a target
848
+ // nothing configures by omitting it (protocol.ts states that explicitly), so
849
+ // an absent key means "this deployment configures none". Carrying the last
850
+ // value over would keep a CLEARED default alive in the create dialog, which
851
+ // is the mirror image of the staleness this key is re-read for.
852
+ const cardDefaults = snapshot.cardDefaults
816
853
  const extensions = snapshot.extensions ?? this.hostState?.extensions
817
854
  return {
818
855
  revision: snapshot.revision,
@@ -821,6 +858,8 @@ export class BoardController {
821
858
  ...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
822
859
  ...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
823
860
  ...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
861
+ ...(goalRunEnabled === undefined ? {} : { goalRunEnabled }),
862
+ ...(cardDefaults === undefined ? {} : { cardDefaults }),
824
863
  ...(extensions === undefined ? {} : { extensions }),
825
864
  }
826
865
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Run-launch admission width: the one bound on how many sessions this board
3
+ * starts at the same time.
4
+ *
5
+ * One board action opens a whole subtree, and a team run hands every member of
6
+ * it to the Lead at once, so without a bound the Host asks DSH for dozens of
7
+ * sessions in a single tick. The repository's own measured guidance caps heavy
8
+ * implementation subagents at three (docs/multi-agent-resources.md) and no
9
+ * runtime mechanism enforced it; this setting is that mechanism.
10
+ *
11
+ * The bounds and the default live here rather than in either half, exactly like
12
+ * the roster cadence: the settings card validates a draft against the same
13
+ * range the Host clamps a hand-edited configuration to, and a programmatic
14
+ * mount that names no width gets the default.
15
+ *
16
+ * @module dsh-task-board/core/launch-width
17
+ */
18
+
19
+ /** Width used when neither the settings document nor the mount names one. */
20
+ export const DEFAULT_RUN_LAUNCH_WIDTH = 3
21
+
22
+ /** Narrowest accepted width: one launch at a time. */
23
+ export const RUN_LAUNCH_WIDTH_MIN = 1
24
+
25
+ /** Widest accepted width. */
26
+ export const RUN_LAUNCH_WIDTH_MAX = 16
27
+
28
+ /**
29
+ * Clamp a configured width into the supported range. A value the schema would
30
+ * refuse (non-finite, below the floor, above the ceiling) still leaves the
31
+ * board launching: an out-of-range setting never stalls a run.
32
+ * @param width - configured width, or undefined for the default.
33
+ * @returns the effective width.
34
+ */
35
+ export function normalizeRunLaunchWidth(width: number | undefined): number {
36
+ if (width === undefined || !Number.isFinite(width)) return DEFAULT_RUN_LAUNCH_WIDTH
37
+ return Math.min(Math.max(Math.trunc(width), RUN_LAUNCH_WIDTH_MIN), RUN_LAUNCH_WIDTH_MAX)
38
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Roster-poll cadence: the one recurring Host timer the board holds.
3
+ *
4
+ * The cadence is a user setting (seconds), so the bounds and the seconds to
5
+ * milliseconds conversion live here rather than in either half: the settings
6
+ * card validates a draft against the same range the Host clamps a hand-edited
7
+ * configuration to, and a programmatic mount that passes no cadence gets the
8
+ * default.
9
+ *
10
+ * @module dsh-task-board/core/poll-cadence
11
+ */
12
+
13
+ /** Cadence used when neither the settings document nor the mount names one. */
14
+ export const DEFAULT_SESSION_POLL_SECONDS = 5
15
+
16
+ /** Shortest accepted cadence: a faster poll only burns CPU on the Host. */
17
+ export const SESSION_POLL_MIN_SECONDS = 1
18
+
19
+ /** Longest accepted cadence, five minutes. */
20
+ export const SESSION_POLL_MAX_SECONDS = 300
21
+
22
+ /**
23
+ * Clamp a configured cadence into the supported range. A value the schema
24
+ * would refuse (non-finite, below the floor, above the ceiling) still leaves
25
+ * the board polling: an out-of-range setting never disables the timer.
26
+ * @param seconds - configured cadence in seconds, or undefined for the default.
27
+ * @returns the effective cadence in seconds.
28
+ */
29
+ export function normalizeSessionPollSeconds(seconds: number | undefined): number {
30
+ if (seconds === undefined || !Number.isFinite(seconds)) return DEFAULT_SESSION_POLL_SECONDS
31
+ return Math.min(Math.max(Math.trunc(seconds), SESSION_POLL_MIN_SECONDS), SESSION_POLL_MAX_SECONDS)
32
+ }
33
+
34
+ /**
35
+ * The cadence in the milliseconds the Host timer face takes.
36
+ * @param seconds - configured cadence in seconds, or undefined for the default.
37
+ * @returns the effective cadence in milliseconds.
38
+ */
39
+ export function sessionPollMs(seconds: number | undefined): number {
40
+ return normalizeSessionPollSeconds(seconds) * 1_000
41
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Which reasoning effort one execution may actually send.
3
+ *
4
+ * The rule lives here, in one pure place, so the launch path cannot drift from
5
+ * what the picker promises: an effort is sent ONLY for the exact model route
6
+ * whose adapter declares that id. An unsupported value is dropped rather than
7
+ * passed through — the Host validates the selection against the same
8
+ * capability (`resolveCallConfig` rejects an unsupported effort) and the whole
9
+ * model selection would fail with it. An absent effort stays absent: the SDK
10
+ * then materializes the model's own default, which is exactly what "inherit
11
+ * the model" means.
12
+ *
13
+ * A card that pins a bare model id also gets its provider resolved here: the
14
+ * session wire contract requires `provider` (ModelSelection), so the catalog is
15
+ * the only place that can complete the documented `provider/model` or model-id
16
+ * pin.
17
+ */
18
+ import type { CatalogModel, ModelCatalogView } from './verification.ts'
19
+
20
+ /** One execution route an effort resolves against: `provider/model`, or a bare model id. */
21
+ export interface ReasoningRoute {
22
+ provider?: string
23
+ model: string
24
+ }
25
+
26
+ /** The catalog entry a route names, once the catalog can complete it. */
27
+ export interface DeclaredReasoningRoute {
28
+ provider: string
29
+ model: string
30
+ /** The adapter's reasoning metadata; absent when it exposes none. */
31
+ reasoning?: CatalogModel['reasoning']
32
+ }
33
+
34
+ /** What one card's effort resolves to for one route. */
35
+ export interface ReasoningEffortDecision {
36
+ /** The effort to send; absent means "send none". */
37
+ effort?: string
38
+ /** The card's own value when it will NOT be sent. */
39
+ dropped?: string
40
+ }
41
+
42
+ /** Trim one optional effort: blank text means "not set", like every other pinned target. */
43
+ export function normalizeReasoningEffort(value: string | undefined): string | undefined {
44
+ const trimmed = value?.trim()
45
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed
46
+ }
47
+
48
+ /**
49
+ * Complete one route through the host catalog: a qualified route must match its
50
+ * provider group, and a bare model id matches the first group carrying it —
51
+ * which is the provider the request then names.
52
+ * @param catalog - the host model catalog, when this deployment serves one.
53
+ * @param route - the route as the card pins it.
54
+ * @returns the completed route with its reasoning metadata, or undefined when
55
+ * the catalog declares no such model.
56
+ */
57
+ export function declaredRoute(
58
+ catalog: ModelCatalogView | undefined,
59
+ route: ReasoningRoute,
60
+ ): DeclaredReasoningRoute | undefined {
61
+ const groups = catalog?.groups ?? []
62
+ const matched = route.provider === undefined || route.provider === ''
63
+ ? groups.find(group => group.models.some(model => model.id === route.model))
64
+ : groups.find(group => group.id === route.provider)
65
+ const model = matched?.models.find(candidate => candidate.id === route.model)
66
+ if (matched === undefined || model === undefined) return undefined
67
+ return {
68
+ provider: matched.id,
69
+ model: model.id,
70
+ ...(model.reasoning === undefined ? {} : { reasoning: model.reasoning }),
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Resolve the effort one execution sends.
76
+ * @param requested - the card's stored effort (absent or blank = inherit).
77
+ * @param catalog - the host model catalog, when this deployment serves one.
78
+ * @param route - the route the execution will actually run at.
79
+ * @returns the effort to send, or the dropped value that will not be sent.
80
+ */
81
+ export function resolveReasoningEffort(
82
+ requested: string | undefined,
83
+ catalog: ModelCatalogView | undefined,
84
+ route: ReasoningRoute | undefined,
85
+ ): ReasoningEffortDecision {
86
+ const effort = normalizeReasoningEffort(requested)
87
+ if (effort === undefined) return {}
88
+ if (route === undefined) return { dropped: effort }
89
+ const declared = declaredRoute(catalog, route)
90
+ const supported = declared?.reasoning?.efforts.some(option => option.id === effort) === true
91
+ return supported ? { effort } : { dropped: effort }
92
+ }
package/src/core/store.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * localStorage backend.
11
11
  */
12
12
  import { isValidCron, isValidTimeZone } from './schedule.ts'
13
- import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
13
+ import { SCHEDULE_STOP_REASONS, isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type ScheduleStopReason, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
14
14
  import { isExecutionOutcome } from './subtask.ts'
15
15
  import type { TaskHandover } from './handover.ts'
16
16
  import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
@@ -68,6 +68,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
68
68
  if (record.parentId !== undefined && typeof record.parentId !== 'string') return false
69
69
  if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
70
70
  if (record.mode !== undefined && typeof record.mode !== 'string') return false
71
+ if (record.reasoningEffort !== undefined && typeof record.reasoningEffort !== 'string') return false
71
72
  if (record.permission !== undefined && typeof record.permission !== 'string') return false
72
73
  if (record.integrations !== undefined && (typeof record.integrations !== 'object' || record.integrations === null || Array.isArray(record.integrations))) return false
73
74
  if (record.hidden !== undefined && typeof record.hidden !== 'boolean') return false
@@ -87,6 +88,10 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
87
88
  if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
88
89
  if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
89
90
  if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
91
+ // An external-outcome marker is optional in BOTH directions: a ledger
92
+ // written before the field existed carries none, and a non-boolean value
93
+ // is dropped by the repair below rather than trusted as provenance.
94
+ if (entry.external !== undefined && typeof entry.external !== 'boolean') return false
90
95
  // The acceptance block is repaired like every other optional field: an
91
96
  // unusable one is dropped, which can only make the board MORE strict.
92
97
  if (entry.verification !== undefined && normalizeVerification(entry.verification) === undefined) return false
@@ -104,29 +109,73 @@ function normalizeStatus(status: unknown): TaskStatus {
104
109
  return isTaskStatus(status) ? status : 'todo'
105
110
  }
106
111
 
112
+ /** A persisted ms timestamp, kept only when it is a usable finite number. */
113
+ function optionalInstant(value: unknown): number | undefined {
114
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
115
+ }
116
+
117
+ /** A persisted positive-integer run cap, or undefined for unlimited/repair. */
118
+ function optionalRunCap(value: unknown): number | undefined {
119
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : undefined
120
+ }
121
+
122
+ /** A persisted stop reason, kept only when it names a reason this build knows. */
123
+ function optionalStopReason(value: unknown): ScheduleStopReason | undefined {
124
+ return typeof value === 'string' && SCHEDULE_STOP_REASONS.includes(value as ScheduleStopReason)
125
+ ? value as ScheduleStopReason
126
+ : undefined
127
+ }
128
+
107
129
  /**
108
- * Repair a persisted schedule rule: drop rules without a usable cron string,
109
- * coerce booleans/numbers, and leave `nextRunAt`/`lastTriggeredAt` undefined
110
- * when missing (a fresh recompute or the next tick fixes them).
130
+ * Repair a persisted schedule rule. Both modes are repaired field by field and
131
+ * a rule that carries no usable plan is dropped (the task row survives):
132
+ * a recurring rule without a well-formed 5-field cron, or a one-shot without a
133
+ * finite planned instant. A rule written before `mode` existed is a cron rule.
111
134
  */
112
135
  function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
113
136
  if (typeof schedule !== 'object' || schedule === null) return undefined
114
137
  const rule = schedule as Record<string, unknown>
138
+ // A stored zone survives only when this runtime can resolve it; an unusable
139
+ // name is dropped (the rule then follows the Host zone) instead of being
140
+ // kept to fail every later resolve.
141
+ const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
142
+ const runCountValue = rule.runCount
143
+ const runCount = typeof runCountValue === 'number' && Number.isInteger(runCountValue) && runCountValue >= 0
144
+ ? runCountValue
145
+ : 0
146
+ const endedAt = optionalInstant(rule.endedAt)
147
+ const endedReason = optionalStopReason(rule.endedReason)
148
+ const skippedAt = optionalInstant(rule.skippedAt)
149
+ const skippedReason = optionalStopReason(rule.skippedReason)
150
+ const common = {
151
+ enabled: rule.enabled === true,
152
+ ...(timeZone === undefined ? {} : { timeZone }),
153
+ nextRunAt: optionalInstant(rule.nextRunAt),
154
+ lastTriggeredAt: optionalInstant(rule.lastTriggeredAt),
155
+ runCount,
156
+ ...(endedAt === undefined ? {} : { endedAt }),
157
+ ...(endedReason === undefined ? {} : { endedReason }),
158
+ ...(skippedAt === undefined ? {} : { skippedAt }),
159
+ ...(skippedReason === undefined ? {} : { skippedReason }),
160
+ }
161
+ if (rule.mode === 'once') {
162
+ const at = optionalInstant(rule.at)
163
+ // A one-shot with no instant can never fire; drop it rather than keep a
164
+ // rule the scheduler would have to interpret.
165
+ if (at === undefined) return undefined
166
+ return { ...common, mode: 'once', at }
167
+ }
115
168
  // Reject (drop) a schedule whose cron is not a well-formed 5-field
116
169
  // expression: a malformed rule would otherwise linger as a never-firing
117
170
  // schedule instead of being dropped for later repair.
118
171
  if (typeof rule.cron !== 'string') return undefined
119
172
  if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
120
- // A stored zone survives only when this runtime can resolve it; an unusable
121
- // name is dropped (the rule then follows the Host zone) instead of being
122
- // kept to fail every later resolve.
123
- const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
173
+ const maxRuns = optionalRunCap(rule.maxRuns)
124
174
  return {
125
- enabled: rule.enabled === true,
175
+ ...common,
176
+ mode: 'cron',
126
177
  cron: rule.cron,
127
- ...(timeZone === undefined ? {} : { timeZone }),
128
- nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
129
- lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
178
+ ...(maxRuns === undefined ? {} : { maxRuns }),
130
179
  }
131
180
  }
132
181
 
@@ -210,18 +259,23 @@ export function parseLedger(raw: string | null): TaskRecord[] {
210
259
  // A malformed acceptance block is dropped rather than dropping the
211
260
  // execution record: the board then treats that run as unverified.
212
261
  verification: normalizeVerification(execution.verification),
262
+ external: execution.external === true ? true : undefined,
213
263
  }))
214
264
  // Execution targets are normalized like the schedule: blank strings
215
265
  // clear the pin and unknown permission strings from a future version
216
266
  // fall back to the session default instead of dropping the row.
217
267
  task.workspaceId = normalizeTargetId(row.workspaceId)
218
268
  task.mode = normalizeTargetId(row.mode)
269
+ // The effort is repaired like the other execution-target strings: blank text
270
+ // from a hand-edited document clears the pin instead of dropping the row,
271
+ // and the card then runs at its model's own default level.
272
+ task.reasoningEffort = normalizeTargetId(row.reasoningEffort)
219
273
  task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
220
274
  task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
221
275
  task.reuseSession = row.reuseSession === true ? true : undefined
222
- // The goal opt-in is stored only when it is OFF: absent means the default
223
- // (start each run with /goal), so a hand-edited true normalizes back to it.
224
- task.goalRun = row.goalRun === false ? false : undefined
276
+ // The goal opt-in is stored only when it is ON: absent means the default
277
+ // (one plain turn per run), so a hand-edited false normalizes back to it.
278
+ task.goalRun = row.goalRun === true ? true : undefined
225
279
  // The acceptance opt-out is stored only when it is ON: absent inherits the
226
280
  // board-wide switch, so a hand-edited false normalizes back to inherit.
227
281
  task.skipVerification = row.skipVerification === true ? true : undefined
@@ -183,9 +183,12 @@ export function checkParentLink(
183
183
 
184
184
  /**
185
185
  * The tasks one run request opens executions for: the requested task first,
186
- * then its on-board descendants within the configured depth. Archived tasks
187
- * never run; a participant that already has an open execution is skipped by
188
- * the caller, which cannot start it twice.
186
+ * then its on-board descendants within the configured depth. This is the
187
+ * SHAPE of a cascade — every walk that must see the whole tree (settlement,
188
+ * archive/restore, the running lock) reads it. The run path filters it with
189
+ * {@link joinsCascadeRun}: archived tasks never run, a participant that already
190
+ * has an open execution is skipped by the caller, which cannot start it twice,
191
+ * and a finished descendant is left alone.
189
192
  */
190
193
  export function cascadeTargets(
191
194
  tasks: readonly TaskRecord[],
@@ -197,6 +200,27 @@ export function cascadeTargets(
197
200
  return [root, ...descendantTasks(tasks, rootId, maxSubtaskDepth)]
198
201
  }
199
202
 
203
+ /**
204
+ * Whether a cascade member takes part in a new run.
205
+ *
206
+ * The task the user ran is always a member: the request names that card, and
207
+ * the operator asked for it to run whatever its column says. A descendant does
208
+ * not join once it sits in the `done` column — its work is finished, so running
209
+ * the parent again is a run of the parent, never a second execution of work
210
+ * that is already done. The rule keys on the column alone, so it covers both a
211
+ * subtask that ran and one an operator moved to done by hand, and it holds for
212
+ * every trigger: a manual run, a re-run, and a due occurrence of a schedule.
213
+ * A card whose own schedule is enabled settles back into `todo`, so a scheduled
214
+ * subtask stays a participant; to make a finished one run again, move it back
215
+ * to `todo` or run it on its own.
216
+ * @param task - the candidate member.
217
+ * @param rootId - the task the run request named.
218
+ * @returns false when this member is left alone by the run.
219
+ */
220
+ export function joinsCascadeRun(task: TaskRecord, rootId: string): boolean {
221
+ return task.id === rootId || task.status !== 'done'
222
+ }
223
+
200
224
  /** One still-open execution belonging to a cascade run group. */
201
225
  export interface OpenCascadeChild {
202
226
  taskId: string
@@ -263,11 +287,12 @@ export function effectiveTaskPermission(task: TaskRecord): TaskRecord['permissio
263
287
  /**
264
288
  * Resolve the execution targets a (sub)task actually runs with: its own value
265
289
  * wins, and an unset target inherits the nearest ancestor up to the root.
266
- * Workspace, agent preset and model inherit the ancestor's PIN only — a
267
- * handover bundle is a per-card continuation artefact and is never inherited.
268
- * The permission inherits the ancestor's EFFECTIVE binding, and its human
269
- * confirmation travels with it, so a subtask of an already-confirmed elevated
270
- * card is runnable while a subtask's own unconfirmed binding stays gated.
290
+ * Workspace, agent preset, model and reasoning effort inherit the ancestor's
291
+ * PIN only — a handover bundle is a per-card continuation artefact and is never
292
+ * inherited. The permission inherits the ancestor's EFFECTIVE binding, and its
293
+ * human confirmation travels with it, so a subtask of an already-confirmed
294
+ * elevated card is runnable while a subtask's own unconfirmed binding stays
295
+ * gated.
271
296
  */
272
297
  /**
273
298
  * The immutable teammate name for one team-mode member. Teammate names are
@@ -317,13 +342,22 @@ export function resolveExecutionTargets(
317
342
  ): TaskRecord {
318
343
  if (task.parentId === undefined) return task
319
344
  const chain = [task, ...ancestorChain(tasks, task).slice(0, maxSubtaskDepth)]
320
- const first = (key: 'workspaceId' | 'mode' | 'model'): string | undefined => {
321
- for (const candidate of chain) {
345
+ const first = (key: 'workspaceId' | 'mode' | 'model' | 'reasoningEffort', candidates: readonly TaskRecord[]): string | undefined => {
346
+ for (const candidate of candidates) {
322
347
  const value = candidate[key]
323
348
  if (value !== undefined) return value
324
349
  }
325
350
  return undefined
326
351
  }
352
+ // The model pin is the boundary of the effort: an effort is only ever taken
353
+ // from the card the model came from, or from the card that pinned it below
354
+ // that one. A card with a model of its own thus sends NO effort when it stores
355
+ // none — the model's own default — instead of quietly running at an ancestor
356
+ // level chosen for a different model, even when both models declare that
357
+ // level. A card with no model of its own still inherits the nearest model
358
+ // together with the nearest level pinned at or below it.
359
+ const boundary = chain.findIndex(candidate => candidate.model !== undefined)
360
+ const effortChain = boundary === -1 ? chain : chain.slice(0, boundary + 1)
327
361
  const own = effectiveTaskPermission(task)
328
362
  // An own binding (pin or handover) keeps its own confirmation stamp; only an
329
363
  // absent one borrows the ancestor's binding AND that ancestor's stamp.
@@ -332,9 +366,10 @@ export function resolveExecutionTargets(
332
366
  : undefined
333
367
  return {
334
368
  ...task,
335
- workspaceId: first('workspaceId'),
336
- mode: first('mode'),
337
- model: first('model'),
369
+ workspaceId: first('workspaceId', chain),
370
+ mode: first('mode', chain),
371
+ model: first('model', chain),
372
+ reasoningEffort: first('reasoningEffort', effortChain),
338
373
  permission: own ?? (owner === undefined ? undefined : effectiveTaskPermission(owner)),
339
374
  permissionConfirmedAt: own === undefined ? owner?.permissionConfirmedAt : task.permissionConfirmedAt,
340
375
  }