@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
package/lib/index.js CHANGED
@@ -5,8 +5,193 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readF
5
5
  import { dirname, isAbsolute, join, win32 } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
8
+ import { Context } from "@deepseek-ai/cordis";
8
9
  import { ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
9
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
+ /**
12
+ * Clamp a configured width into the supported range. A value the schema would
13
+ * refuse (non-finite, below the floor, above the ceiling) still leaves the
14
+ * board launching: an out-of-range setting never stalls a run.
15
+ * @param width - configured width, or undefined for the default.
16
+ * @returns the effective width.
17
+ */
18
+ function normalizeRunLaunchWidth(width) {
19
+ if (width === void 0 || !Number.isFinite(width)) return 3;
20
+ return Math.min(Math.max(Math.trunc(width), 1), 16);
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
+ function normalizeSessionPollSeconds(seconds) {
30
+ if (seconds === void 0 || !Number.isFinite(seconds)) return 5;
31
+ return Math.min(Math.max(Math.trunc(seconds), 1), 300);
32
+ }
33
+ /**
34
+ * The cadence in the milliseconds the Host timer face takes.
35
+ * @param seconds - configured cadence in seconds, or undefined for the default.
36
+ * @returns the effective cadence in milliseconds.
37
+ */
38
+ function sessionPollMs(seconds) {
39
+ return normalizeSessionPollSeconds(seconds) * 1e3;
40
+ }
41
+ //#endregion
42
+ //#region src/core/session-reuse.ts
43
+ /**
44
+ * Pick the session a new execution of this task may continue in, or undefined
45
+ * to mint a fresh conversation. Reuse requires ALL of:
46
+ * - the task opted in (`reuseSession === true`);
47
+ * - the task's newest SETTLED execution carries a session id (issue #1587: the
48
+ * launch path calls this after `startExecution` appended this run's own open
49
+ * record, so reading the array tail always found an unsettled row and reuse
50
+ * never happened);
51
+ * - the roster is known and that session is present and idle.
52
+ *
53
+ * An unknown roster (session/list unavailable) never reuses: minting a fresh
54
+ * conversation is always safe, prompting into a session we cannot see is not.
55
+ * @param task - the task about to run.
56
+ * @param idleSessionIds - ids the last roster saw as present and not running;
57
+ * undefined when that roster is unknown.
58
+ * @returns the session id to continue in, or undefined for a fresh session.
59
+ */
60
+ function reusableSessionId(task, idleSessionIds) {
61
+ if (task.reuseSession !== true || idleSessionIds === void 0) return void 0;
62
+ let last;
63
+ for (let index = task.executions.length - 1; index >= 0; index -= 1) {
64
+ const candidate = task.executions[index];
65
+ if (candidate.sessionId !== void 0 && candidate.endedAt !== void 0) {
66
+ last = candidate;
67
+ break;
68
+ }
69
+ }
70
+ if (last === void 0 || last.sessionId === void 0) return void 0;
71
+ return idleSessionIds.has(last.sessionId) ? last.sessionId : void 0;
72
+ }
73
+ //#endregion
74
+ //#region src/host/launch-admission.ts
75
+ var LaunchAdmissionQueue = class {
76
+ width;
77
+ /** Waiting launches per run group, in arrival order. */
78
+ waiting = /* @__PURE__ */ new Map();
79
+ /** Run groups in round-robin order. */
80
+ rotation = [];
81
+ /** Slot holders of the launches in flight, one per admitted launch. */
82
+ inFlight = /* @__PURE__ */ new Set();
83
+ /** Highest number of launches in flight at once; observability and tests. */
84
+ peakInFlight = 0;
85
+ /**
86
+ * @param width - live admission width (already normalized by the caller).
87
+ */
88
+ constructor(width) {
89
+ this.width = width;
90
+ }
91
+ /** Launches the queue has not admitted yet. */
92
+ get pendingCount() {
93
+ let total = 0;
94
+ for (const queue of this.waiting.values()) total += queue.length;
95
+ return total;
96
+ }
97
+ /** Launches currently holding a slot. */
98
+ get inFlightCount() {
99
+ return this.inFlight.size;
100
+ }
101
+ /** Admit one launch, immediately if a slot is free. */
102
+ enqueue(launch) {
103
+ const queue = this.waiting.get(launch.groupId);
104
+ if (queue === void 0) {
105
+ this.waiting.set(launch.groupId, [launch]);
106
+ this.rotation.push(launch.groupId);
107
+ } else queue.push(launch);
108
+ this.pump();
109
+ }
110
+ /**
111
+ * Abandon every waiting launch: the queue is being torn down, so an entry
112
+ * that never started can never start. Launches already in flight are left
113
+ * alone — their session may already exist, and the callers that own them
114
+ * settle them through the normal path.
115
+ * @returns the abandoned launches, in abandonment order.
116
+ */
117
+ drain() {
118
+ const abandoned = [];
119
+ for (const groupId of this.rotation) for (const launch of this.waiting.get(groupId) ?? []) abandoned.push(launch);
120
+ this.rotation = [];
121
+ this.waiting.clear();
122
+ for (const launch of abandoned) launch.abandon();
123
+ return abandoned;
124
+ }
125
+ pump() {
126
+ while (this.inFlight.size < this.width()) {
127
+ const launch = this.takeNext();
128
+ if (launch === void 0) return;
129
+ this.admit(launch);
130
+ }
131
+ }
132
+ /** The next launch by round-robin across groups and FIFO inside one. */
133
+ takeNext() {
134
+ while (this.rotation.length > 0) {
135
+ const groupId = this.rotation.shift();
136
+ if (groupId === void 0) return void 0;
137
+ const queue = this.waiting.get(groupId);
138
+ if (queue === void 0 || queue.length === 0) {
139
+ this.waiting.delete(groupId);
140
+ continue;
141
+ }
142
+ const launch = queue.shift();
143
+ if (launch === void 0) continue;
144
+ if (queue.length === 0) this.waiting.delete(groupId);
145
+ else this.rotation.push(groupId);
146
+ return launch;
147
+ }
148
+ }
149
+ admit(launch) {
150
+ const release = () => {
151
+ this.inFlight.delete(release);
152
+ this.pump();
153
+ };
154
+ this.inFlight.add(release);
155
+ if (this.inFlight.size > this.peakInFlight) this.peakInFlight = this.inFlight.size;
156
+ Promise.resolve().then(() => launch.start()).catch(() => {}).then(release);
157
+ }
158
+ };
159
+ //#endregion
160
+ //#region src/dsh-home.ts
161
+ /**
162
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
163
+ * environment override wins, the platform home fallback follows. Mirrors
164
+ * what dsh-pet and dsh-liangshen each used to implement locally.
165
+ */
166
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
167
+ function expandHome(path, home = homedir()) {
168
+ const j = home.startsWith("/") ? join$1 : join;
169
+ if (path === "~") return home;
170
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
171
+ return path;
172
+ }
173
+ /**
174
+ * Resolve the DSH home directory.
175
+ * @param env - process environment to read DSH_HOME from.
176
+ * @param home - platform home directory fallback (test seam).
177
+ * @returns the absolute DSH home path.
178
+ */
179
+ function resolveDshHome(env = process.env, home = homedir()) {
180
+ const isPosix = home.startsWith("/");
181
+ const j = isPosix ? join$1 : join;
182
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
183
+ const raw = env.DSH_HOME;
184
+ if (raw !== void 0 && raw.trim() !== "") {
185
+ const expanded = expandHome(raw.trim(), home);
186
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
187
+ }
188
+ return j(home, ".dsh");
189
+ }
190
+ /** Resolve the DSH home directory from the live environment. */
191
+ function dshHome() {
192
+ return resolveDshHome();
193
+ }
194
+ //#endregion
10
195
  //#region src/core/schedule.ts
11
196
  /** Inclusive ranges per field, in cron order. */
12
197
  const FIELD_RANGES = [
@@ -283,73 +468,6 @@ function isDigits(value) {
283
468
  return /^\d+$/.test(value);
284
469
  }
285
470
  //#endregion
286
- //#region src/core/session-reuse.ts
287
- /**
288
- * Pick the session a new execution of this task may continue in, or undefined
289
- * to mint a fresh conversation. Reuse requires ALL of:
290
- * - the task opted in (`reuseSession === true`);
291
- * - the task's newest SETTLED execution carries a session id (issue #1587: the
292
- * launch path calls this after `startExecution` appended this run's own open
293
- * record, so reading the array tail always found an unsettled row and reuse
294
- * never happened);
295
- * - the roster is known and that session is present and idle.
296
- *
297
- * An unknown roster (session/list unavailable) never reuses: minting a fresh
298
- * conversation is always safe, prompting into a session we cannot see is not.
299
- * @param task - the task about to run.
300
- * @param idleSessionIds - ids the last roster saw as present and not running;
301
- * undefined when that roster is unknown.
302
- * @returns the session id to continue in, or undefined for a fresh session.
303
- */
304
- function reusableSessionId(task, idleSessionIds) {
305
- if (task.reuseSession !== true || idleSessionIds === void 0) return void 0;
306
- let last;
307
- for (let index = task.executions.length - 1; index >= 0; index -= 1) {
308
- const candidate = task.executions[index];
309
- if (candidate.sessionId !== void 0 && candidate.endedAt !== void 0) {
310
- last = candidate;
311
- break;
312
- }
313
- }
314
- if (last === void 0 || last.sessionId === void 0) return void 0;
315
- return idleSessionIds.has(last.sessionId) ? last.sessionId : void 0;
316
- }
317
- //#endregion
318
- //#region src/dsh-home.ts
319
- /**
320
- * DSH_HOME resolution shared by the plugin family's Host halves: the
321
- * environment override wins, the platform home fallback follows. Mirrors
322
- * what dsh-pet and dsh-liangshen each used to implement locally.
323
- */
324
- /** Expand a leading ~ (or ~user) in a path, platform-style. */
325
- function expandHome(path, home = homedir()) {
326
- const j = home.startsWith("/") ? join$1 : join;
327
- if (path === "~") return home;
328
- if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
329
- return path;
330
- }
331
- /**
332
- * Resolve the DSH home directory.
333
- * @param env - process environment to read DSH_HOME from.
334
- * @param home - platform home directory fallback (test seam).
335
- * @returns the absolute DSH home path.
336
- */
337
- function resolveDshHome(env = process.env, home = homedir()) {
338
- const isPosix = home.startsWith("/");
339
- const j = isPosix ? join$1 : join;
340
- const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
341
- const raw = env.DSH_HOME;
342
- if (raw !== void 0 && raw.trim() !== "") {
343
- const expanded = expandHome(raw.trim(), home);
344
- return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
345
- }
346
- return j(home, ".dsh");
347
- }
348
- /** Resolve the DSH home directory from the live environment. */
349
- function dshHome() {
350
- return resolveDshHome();
351
- }
352
- //#endregion
353
471
  //#region src/core/extension.ts
354
472
  /** Cordis service name the host and browser halves each publish. */
355
473
  const TASK_BOARD_SERVICE_NAME = "taskBoard";
@@ -437,6 +555,37 @@ function retainRecentExecutions(executions) {
437
555
  const keepSettled = Math.max(20 - open.length, 0);
438
556
  return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open];
439
557
  }
558
+ /** Every stop reason this build understands; the repair and wire gates share it. */
559
+ const SCHEDULE_STOP_REASONS = [
560
+ "fired",
561
+ "limit",
562
+ "no-target",
563
+ "missed",
564
+ "busy",
565
+ "permission",
566
+ "launch-failed"
567
+ ];
568
+ /**
569
+ * The rule's total run budget: one for a one-shot, the stored cap for a
570
+ * recurring rule, and undefined when the rule is unlimited.
571
+ * @param rule - the schedule rule.
572
+ * @returns the budget, or undefined for an uncapped rule.
573
+ */
574
+ function scheduleRunBudget(rule) {
575
+ if (rule.mode === "once") return 1;
576
+ return rule.maxRuns;
577
+ }
578
+ /**
579
+ * Whether a rule has no runs left. The scheduler refuses to open another
580
+ * execution for an exhausted rule, which is what makes the budget terminal
581
+ * regardless of restarts or repeated timer fires.
582
+ * @param rule - the schedule rule.
583
+ * @returns true when the run budget is spent.
584
+ */
585
+ function scheduleExhausted(rule) {
586
+ const budget = scheduleRunBudget(rule);
587
+ return budget !== void 0 && rule.runCount >= budget;
588
+ }
440
589
  /** Whether an unknown value is a well-formed tag (strict: the wire gate). */
441
590
  function isTaskTag(value) {
442
591
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -608,9 +757,10 @@ function createTask(input, now, id) {
608
757
  mode: normalizeTargetId(input.mode),
609
758
  permission: isTaskPermission(input.permission) ? input.permission : void 0,
610
759
  model: normalizeTargetId(input.model),
760
+ reasoningEffort: normalizeTargetId(input.reasoningEffort),
611
761
  reuseSession: input.reuseSession === true ? true : void 0,
612
762
  teamRun: input.teamRun === true ? true : void 0,
613
- goalRun: input.goalRun === false ? false : void 0,
763
+ goalRun: input.goalRun === true ? true : void 0,
614
764
  skipVerification: input.skipVerification === true ? true : void 0,
615
765
  ...input.freeze === void 0 ? {} : { freeze: freezeOf(input.freeze, now) },
616
766
  ...input.handover === void 0 ? {} : { handover: {
@@ -630,27 +780,47 @@ function withStatus(task, status, now) {
630
780
  updatedAt: now
631
781
  };
632
782
  }
783
+ /** Every field a schedule patch may carry; the rest of the rule is carried over. */
784
+ const SCHEDULE_PATCH_KEYS = [
785
+ "enabled",
786
+ "mode",
787
+ "cron",
788
+ "at",
789
+ "timeZone",
790
+ "nextRunAt",
791
+ "lastTriggeredAt",
792
+ "runCount",
793
+ "maxRuns",
794
+ "endedAt",
795
+ "endedReason",
796
+ "skippedAt",
797
+ "skippedReason"
798
+ ];
633
799
  /**
634
800
  * Merge a schedule patch into a task's schedule rule (creating it when
635
801
  * absent), with a fresh updatedAt. Keys present in the patch overwrite the
636
802
  * current value — including explicit `undefined`, which clears a field (used
637
- * to disarm `nextRunAt`); absent keys keep their current value.
803
+ * to disarm `nextRunAt` or to drop an ended/skip record); absent keys keep
804
+ * their current value.
638
805
  */
639
806
  function withSchedule(task, patch, now) {
640
- const current = task.schedule;
641
- const schedule = {
642
- enabled: current?.enabled ?? false,
643
- cron: current?.cron ?? "",
644
- ...current?.timeZone === void 0 ? {} : { timeZone: current.timeZone },
645
- nextRunAt: current?.nextRunAt,
646
- lastTriggeredAt: current?.lastTriggeredAt
647
- };
648
- if ("enabled" in patch) schedule.enabled = patch.enabled ?? false;
649
- if ("cron" in patch) schedule.cron = patch.cron ?? "";
650
- if ("timeZone" in patch) if (patch.timeZone === void 0) delete schedule.timeZone;
651
- else schedule.timeZone = patch.timeZone;
652
- if ("nextRunAt" in patch) schedule.nextRunAt = patch.nextRunAt;
653
- if ("lastTriggeredAt" in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt;
807
+ const schedule = task.schedule === void 0 ? {
808
+ enabled: false,
809
+ mode: "cron",
810
+ nextRunAt: void 0,
811
+ lastTriggeredAt: void 0,
812
+ runCount: 0
813
+ } : { ...task.schedule };
814
+ const fields = schedule;
815
+ const next = patch;
816
+ for (const key of SCHEDULE_PATCH_KEYS) {
817
+ if (!(key in patch)) continue;
818
+ if (next[key] === void 0) delete fields[key];
819
+ else fields[key] = next[key];
820
+ }
821
+ if (schedule.enabled !== true) schedule.enabled = false;
822
+ if (schedule.mode !== "once") schedule.mode = "cron";
823
+ if (!Number.isFinite(schedule.runCount) || schedule.runCount < 0) schedule.runCount = 0;
654
824
  return {
655
825
  ...task,
656
826
  updatedAt: now,
@@ -689,6 +859,20 @@ function startExecution(task, now, executionId, initiatedBy, runGroupId) {
689
859
  };
690
860
  }
691
861
  /**
862
+ * The column a settled outcome puts a card in. The ONE derivation shared by
863
+ * every settlement path (issue #1826): a Host-run execution and an outcome an
864
+ * outside agent reported both land in the same column from the same rule, so
865
+ * the column never becomes a second, independent piece of state.
866
+ @param task - the card being settled.
867
+ @param outcome - the settled outcome.
868
+ @returns the status the card shows afterwards.
869
+ */
870
+ function settledStatus(task, outcome) {
871
+ if (outcome === "succeeded") return task.schedule?.enabled === true ? "todo" : "done";
872
+ if (outcome === "failed") return "failed";
873
+ return task.status === "running" ? "todo" : task.status;
874
+ }
875
+ /**
692
876
  * Settle a running execution: record the outcome and move the task into the
693
877
  * matching column. No-op (returns the input task) when the execution is not
694
878
  * the task's latest or is already settled.
@@ -706,10 +890,9 @@ function settleExecution(task, executionId, outcome, now, error) {
706
890
  };
707
891
  const executions = [...task.executions];
708
892
  executions[index] = settled;
709
- const status = outcome === "succeeded" ? task.schedule?.enabled ? "todo" : "done" : outcome === "failed" ? "failed" : task.status === "running" ? "todo" : task.status;
710
893
  return {
711
894
  ...task,
712
- status,
895
+ status: settledStatus(task, outcome),
713
896
  updatedAt: now,
714
897
  executions
715
898
  };
@@ -837,15 +1020,38 @@ function checkParentLink(tasks, childId, parentId, maxSubtaskDepth, lineage) {
837
1020
  }
838
1021
  /**
839
1022
  * The tasks one run request opens executions for: the requested task first,
840
- * then its on-board descendants within the configured depth. Archived tasks
841
- * never run; a participant that already has an open execution is skipped by
842
- * the caller, which cannot start it twice.
1023
+ * then its on-board descendants within the configured depth. This is the
1024
+ * SHAPE of a cascade — every walk that must see the whole tree (settlement,
1025
+ * archive/restore, the running lock) reads it. The run path filters it with
1026
+ * {@link joinsCascadeRun}: archived tasks never run, a participant that already
1027
+ * has an open execution is skipped by the caller, which cannot start it twice,
1028
+ * and a finished descendant is left alone.
843
1029
  */
844
1030
  function cascadeTargets(tasks, rootId, maxSubtaskDepth) {
845
1031
  const root = tasks.find((task) => task.id === rootId);
846
1032
  if (root === void 0) return [];
847
1033
  return [root, ...descendantTasks(tasks, rootId, maxSubtaskDepth)];
848
1034
  }
1035
+ /**
1036
+ * Whether a cascade member takes part in a new run.
1037
+ *
1038
+ * The task the user ran is always a member: the request names that card, and
1039
+ * the operator asked for it to run whatever its column says. A descendant does
1040
+ * not join once it sits in the `done` column — its work is finished, so running
1041
+ * the parent again is a run of the parent, never a second execution of work
1042
+ * that is already done. The rule keys on the column alone, so it covers both a
1043
+ * subtask that ran and one an operator moved to done by hand, and it holds for
1044
+ * every trigger: a manual run, a re-run, and a due occurrence of a schedule.
1045
+ * A card whose own schedule is enabled settles back into `todo`, so a scheduled
1046
+ * subtask stays a participant; to make a finished one run again, move it back
1047
+ * to `todo` or run it on its own.
1048
+ * @param task - the candidate member.
1049
+ * @param rootId - the task the run request named.
1050
+ * @returns false when this member is left alone by the run.
1051
+ */
1052
+ function joinsCascadeRun(task, rootId) {
1053
+ return task.id === rootId || task.status !== "done";
1054
+ }
849
1055
  /** The open execution of a task inside a run group, if it still exists. */
850
1056
  function openGroupExecution(task, runGroupId) {
851
1057
  return task.executions.find((execution) => execution.runGroupId === runGroupId && execution.endedAt === void 0);
@@ -905,11 +1111,12 @@ function effectiveTaskPermission(task) {
905
1111
  /**
906
1112
  * Resolve the execution targets a (sub)task actually runs with: its own value
907
1113
  * wins, and an unset target inherits the nearest ancestor up to the root.
908
- * Workspace, agent preset and model inherit the ancestor's PIN only — a
909
- * handover bundle is a per-card continuation artefact and is never inherited.
910
- * The permission inherits the ancestor's EFFECTIVE binding, and its human
911
- * confirmation travels with it, so a subtask of an already-confirmed elevated
912
- * card is runnable while a subtask's own unconfirmed binding stays gated.
1114
+ * Workspace, agent preset, model and reasoning effort inherit the ancestor's
1115
+ * PIN only — a handover bundle is a per-card continuation artefact and is never
1116
+ * inherited. The permission inherits the ancestor's EFFECTIVE binding, and its
1117
+ * human confirmation travels with it, so a subtask of an already-confirmed
1118
+ * elevated card is runnable while a subtask's own unconfirmed binding stays
1119
+ * gated.
913
1120
  */
914
1121
  /**
915
1122
  * The immutable teammate name for one team-mode member. Teammate names are
@@ -955,19 +1162,22 @@ function stableTag(value) {
955
1162
  function resolveExecutionTargets(task, tasks, maxSubtaskDepth = 3) {
956
1163
  if (task.parentId === void 0) return task;
957
1164
  const chain = [task, ...ancestorChain(tasks, task).slice(0, maxSubtaskDepth)];
958
- const first = (key) => {
959
- for (const candidate of chain) {
1165
+ const first = (key, candidates) => {
1166
+ for (const candidate of candidates) {
960
1167
  const value = candidate[key];
961
1168
  if (value !== void 0) return value;
962
1169
  }
963
1170
  };
1171
+ const boundary = chain.findIndex((candidate) => candidate.model !== void 0);
1172
+ const effortChain = boundary === -1 ? chain : chain.slice(0, boundary + 1);
964
1173
  const own = effectiveTaskPermission(task);
965
1174
  const owner = own === void 0 ? chain.slice(1).find((candidate) => effectiveTaskPermission(candidate) !== void 0) : void 0;
966
1175
  return {
967
1176
  ...task,
968
- workspaceId: first("workspaceId"),
969
- mode: first("mode"),
970
- model: first("model"),
1177
+ workspaceId: first("workspaceId", chain),
1178
+ mode: first("mode", chain),
1179
+ model: first("model", chain),
1180
+ reasoningEffort: first("reasoningEffort", effortChain),
971
1181
  permission: own ?? (owner === void 0 ? void 0 : effectiveTaskPermission(owner)),
972
1182
  permissionConfirmedAt: own === void 0 ? owner?.permissionConfirmedAt : task.permissionConfirmedAt
973
1183
  };
@@ -1205,6 +1415,20 @@ const SCALE_DESCRIPTION = [
1205
1415
  " Q-S = failed with some partial progress",
1206
1416
  " T = clearly and completely failed (worst)"
1207
1417
  ].join("\n");
1418
+ /**
1419
+ * Which trajectory slot holds the WORK during one round.
1420
+ *
1421
+ * The even round (0) puts the work in slot A and the empty-work baseline in
1422
+ * slot B; the odd round (1) swaps them, which is how the acceptance cancels
1423
+ * position bias. The judge prompt ALWAYS introduces the work as
1424
+ * `location="TRAJECTORY_B"`, because that is the slot the work occupies in the
1425
+ * prompt's own framing; the parser maps the cited slot back through this table.
1426
+ * @param round - the round index (0-based).
1427
+ * @returns the slot letter the work occupies in that round.
1428
+ */
1429
+ function workSlotOfRound(round) {
1430
+ return round % 2 === 1 ? "B" : "A";
1431
+ }
1208
1432
  Array.from({ length: 20 }, (_, index) => String.fromCharCode(65 + index));
1209
1433
  /**
1210
1434
  * Normalize one score token to an A-T letter.
@@ -1286,8 +1510,9 @@ function evidenceNonce(...parts) {
1286
1510
  * byte-identical for a deployment that serves no change service.
1287
1511
  * @returns the rendered prompt.
1288
1512
  */
1289
- function buildAcceptancePrompt(problem, traceA, traceB, criterion, groundTruthNote = DEFAULT_GROUND_TRUTH_NOTE, context) {
1513
+ function buildAcceptancePrompt(problem, traceA, traceB, criterion, groundTruthNote = DEFAULT_GROUND_TRUTH_NOTE, context, workSlot = "A") {
1290
1514
  const reference = context?.trim() ?? "";
1515
+ const workSlotTag = workSlot === "A" ? "Trajectory A" : "Trajectory B";
1291
1516
  const token = reference === "" ? evidenceNonce(problem, traceA, traceB) : evidenceNonce(problem, reference, traceA, traceB);
1292
1517
  return [
1293
1518
  EVALUATOR_ROLE,
@@ -1300,6 +1525,10 @@ function buildAcceptancePrompt(problem, traceA, traceB, criterion, groundTruthNo
1300
1525
  "**Rating Scale:**\n" + SCALE_DESCRIPTION,
1301
1526
  "**Evaluation Guideline — " + criterion.name + ":**\n" + criterion.description,
1302
1527
  "Score each trajectory ONLY on this specific criterion (\"" + criterion.name + "\"). Ignore other aspects that are not relevant to it.",
1528
+ "If and only if the score you give the WORK under this criterion makes it fail the criterion, you MUST justify it with one finding element per failing observation, in exactly this form (replace the placeholders, keep the attribute names and the quoting):",
1529
+ "<finding criterion=\"" + criterion.name + "\" location=\"" + workSlot + "\" requirement=\"the task requirement this criterion demands\" observation=\"what you actually observed\" gap=\"why the observation does not satisfy the requirement\" quote=\"a verbatim passage copied exactly from the block named by location\">optional suggested action</finding>",
1530
+ "Before citing the task you MUST name the failing criterion, restate the requirement it imposes, describe the observation that falls short, explain the difference, and cite a verbatim passage that occurs in the evidence. " + workSlotTag + " is the WORK under review and is the only trajectory a veto may cite: a passage quoted from the other trajectory, which is the empty-work baseline (No useful work or verification was performed.), is not evidence about the work and must never be used.",
1531
+ "A failure with no such citation is not a usable verdict: if the evidence you were given is truncated, incomplete, or simply does not contain the output needed to judge this criterion, say so in your prose and cite nothing rather than inventing a basis to veto.",
1303
1532
  "Reason it through first, then END your reply with exactly these two lines and nothing after them. Replace each placeholder with a single letter A-T, keeping the spaces around the letter exactly as shown:\n<score_A> LETTER_A_TO_T </score_A>\n<score_B> LETTER_A_TO_T </score_B>",
1304
1533
  "Begin your analysis now."
1305
1534
  ].join("\n\n");
@@ -1312,6 +1541,116 @@ function qualityAttempts(verification) {
1312
1541
  function exceptionAttempts(verification) {
1313
1542
  return verification === void 0 ? [] : verification.attempts.filter((attempt) => attempt.stage === "exception");
1314
1543
  }
1544
+ /**
1545
+ * Invalid acceptances spent by this cycle: a veto whose required evidence was
1546
+ * missing, unlocatable, or aimed at the empty-work baseline. They are NOT
1547
+ * quality verdicts, so they never close the cycle and never fail the card; the
1548
+ * bound is what keeps an evidence-free judge from re-running forever.
1549
+ */
1550
+ function invalidAttempts(verification) {
1551
+ return verification === void 0 ? [] : verification.attempts.filter((attempt) => attempt.stage === "invalid");
1552
+ }
1553
+ /** Whether this cycle still has invalid-acceptance budget left. */
1554
+ function hasInvalidBudget(verification) {
1555
+ return invalidAttempts(verification).length < 2;
1556
+ }
1557
+ /**
1558
+ * The acceptance block with every non-quality attempt dropped: the explicit
1559
+ * reset a user performs after fixing the environment (issue #1828).
1560
+ *
1561
+ * Only attempts that are not a quality verdict are dropped. A quality verdict
1562
+ * is the board's own judgement of the work and survives every reset, so this
1563
+ * can never buy an extra acceptance or re-open a judged failure; it only lets
1564
+ * the environment be judged again.
1565
+ * @param verification - the persisted acceptance state.
1566
+ * @returns the cleared block, or undefined when there is nothing to clear.
1567
+ */
1568
+ function withoutAcceptanceAnomalies(verification) {
1569
+ const kept = verification.attempts.filter((attempt) => attempt.stage === "quality");
1570
+ if (kept.length === verification.attempts.length) return void 0;
1571
+ const cleared = {
1572
+ ...verification,
1573
+ attempts: kept
1574
+ };
1575
+ delete cleared.inFlight;
1576
+ delete cleared.failedReason;
1577
+ delete cleared.failedAt;
1578
+ delete cleared.cleanup;
1579
+ return cleared;
1580
+ }
1581
+ /**
1582
+ * The one-line problem summary a cleaned execution keeps: the failing criteria
1583
+ * with their scores, plus how many locatable findings were removed. This is the
1584
+ * lightweight audit material that survives the detail cleanup.
1585
+ * @param attempt - the attempt whose detail is being removed.
1586
+ * @returns the bounded summary line.
1587
+ */
1588
+ function findingSummaryOf(attempt) {
1589
+ const failed = attempt.criteria.filter((criterion) => !criterion.passed);
1590
+ const parts = [];
1591
+ if (failed.length > 0) parts.push("未达标判据:" + failed.map((criterion) => criterion.name + " " + percent(criterion.score)).join("、"));
1592
+ const located = (attempt.criterionFindings ?? []).length;
1593
+ if (located > 0) parts.push("已移除的可定位问题 " + located + " 条");
1594
+ if (attempt.error !== void 0 && attempt.error !== "") parts.push(attempt.error);
1595
+ if (parts.length === 0) return void 0;
1596
+ return parts.join(";");
1597
+ }
1598
+ /**
1599
+ * The audit credential of one attempt: everything the completion gate, the
1600
+ * report and a later review need, and nothing that makes the ledger heavy.
1601
+ * @param attempt - the attempt being condensed.
1602
+ * @returns the lightweight audit record.
1603
+ */
1604
+ function auditOf(attempt) {
1605
+ const summary = findingSummaryOf(attempt);
1606
+ return {
1607
+ stage: attempt.stage,
1608
+ passed: attempt.passed,
1609
+ at: attempt.at,
1610
+ score: attempt.score,
1611
+ baseline: attempt.baseline,
1612
+ criteria: attempt.criteria.map((criterion) => ({ ...criterion })),
1613
+ evidenceHash: attempt.evidence.hash,
1614
+ route: { ...attempt.route },
1615
+ ...attempt.error === void 0 ? {} : { error: attempt.error },
1616
+ ...summary === void 0 ? {} : { findingSummary: summary },
1617
+ usage: { ...attempt.usage }
1618
+ };
1619
+ }
1620
+ /**
1621
+ * Remove this execution's OWN acceptance-only detail while keeping the light
1622
+ * audit record — the acceptance mechanism's side of requirement 2.
1623
+ *
1624
+ * Only material the acceptance mechanism itself created is removed: the
1625
+ * per-attempt findings, the invalid diagnostics and the token/evidence detail
1626
+ * beyond the summary. Everything a human needs to see the verdict survives
1627
+ * (stage, pass flag, time, scores, evidence hash, judge route, usage totals),
1628
+ * so the completion gate's pass record and a later review are unaffected, and
1629
+ * NO user file, task artifact, raw session history or other execution's record
1630
+ * is ever touched by this function.
1631
+ * @param verification - the execution's acceptance block.
1632
+ * @param now - cleanup instant (ms epoch).
1633
+ * @returns the cleaned block, or undefined when there is nothing to remove.
1634
+ */
1635
+ function withAcceptanceDetailCleared(verification, now) {
1636
+ if (verification.cleanup?.state === "cleaned") return void 0;
1637
+ const audit = verification.attempts.map(auditOf);
1638
+ return {
1639
+ ...verification,
1640
+ attempts: verification.attempts.map((attempt) => ({
1641
+ ...attempt,
1642
+ findings: [],
1643
+ criterionFindings: []
1644
+ })),
1645
+ cleanup: {
1646
+ state: "cleaned",
1647
+ attempts: (verification.cleanup?.attempts ?? 0) + 1,
1648
+ startedAt: verification.cleanup?.startedAt ?? now,
1649
+ cleanedAt: now,
1650
+ audit
1651
+ }
1652
+ };
1653
+ }
1315
1654
  /** The quality attempt that passed, when one did. */
1316
1655
  function passedAttempt(verification) {
1317
1656
  return qualityAttempts(verification).find((attempt) => attempt.passed);
@@ -1328,6 +1667,33 @@ function passedAttempt(verification) {
1328
1667
  function verificationRequired(verification) {
1329
1668
  return verification !== void 0 && verification.applicability === "enforced";
1330
1669
  }
1670
+ /**
1671
+ * Whether this execution's acceptance gate was never opened: acceptance was
1672
+ * enforced, the judge recorded nothing at all, and no cycle ever closed. Zero
1673
+ * attempts is therefore a fact about the RUN, not a verdict about the work — a
1674
+ * session that narrated completion instead of calling
1675
+ * `update_goal(action: complete)` produces exactly this shape, and its failure
1676
+ * reason must stay distinguishable from a quality verdict (issue #1837).
1677
+ * @param verification - the execution's persisted acceptance state.
1678
+ * @returns true when no acceptance attempt ever ran.
1679
+ */
1680
+ function verificationNeverInvoked(verification) {
1681
+ return verification !== void 0 && verification.applicability === "enforced" && verification.attempts.length === 0 && verification.failedReason === void 0;
1682
+ }
1683
+ /**
1684
+ * Whether this execution's acceptance produced no usable veto at all: it was
1685
+ * enforced, the judge recorded attempts, and every one of them is INVALID.
1686
+ *
1687
+ * Such a run must never be described as an evidence-backed quality veto (issue
1688
+ * #1837's sibling): the judge could not locate its own rejection, so the run
1689
+ * ended for lack of a matching pass rather than because the work was judged
1690
+ * and rejected. The settlement reason names that difference.
1691
+ * @param verification - the execution's persisted acceptance state.
1692
+ * @returns true when nothing but invalid acceptances were recorded.
1693
+ */
1694
+ function verificationOnlyInvalid(verification) {
1695
+ return verification !== void 0 && verification.applicability === "enforced" && verification.failedReason === void 0 && passedAttempt(verification) === void 0 && verification.attempts.length > 0 && invalidAttempts(verification).length === verification.attempts.length;
1696
+ }
1331
1697
  /** Whether this cycle still has quality budget left. */
1332
1698
  function hasQualityBudget(verification) {
1333
1699
  return qualityAttempts(verification).length < 2;
@@ -1336,6 +1702,99 @@ function hasQualityBudget(verification) {
1336
1702
  function hasExceptionBudget(verification) {
1337
1703
  return exceptionAttempts(verification).length < 2;
1338
1704
  }
1705
+ /** Whether a text carries a real statement rather than filler. */
1706
+ function substantive(text) {
1707
+ return text.trim().length >= 4;
1708
+ }
1709
+ /**
1710
+ * Program-level validity of a REJECTING acceptance, decided before any quality
1711
+ * verdict is booked (requirement 1: the rule cannot live in a prompt alone).
1712
+ *
1713
+ * A veto is only usable when every criterion that failed has its own structured
1714
+ * finding carrying the requirement, the observation, the difference and a
1715
+ * citation that really occurs in the evidence the judge saw. A citation aimed at
1716
+ * the empty-work baseline, a citation that is not in the reviewed evidence, an
1717
+ * empty or generic statement, and a truncated review that cannot substantiate
1718
+ * the veto are all invalid acceptances: no quality verdict, no quality budget,
1719
+ * and no card failure.
1720
+ * @param input - the attempt's scores, findings and the evidence it judged.
1721
+ * @returns valid, or the reason and detail of the invalidity.
1722
+ */
1723
+ function assessAcceptanceValidity(input) {
1724
+ const failed = input.criteria.filter((criterion) => !criterion.passed);
1725
+ const truncated = input.omittedCharacters > 0;
1726
+ if (failed.length === 0) {
1727
+ if (input.findings.filter((finding) => validFinding(finding, input.evidence) === void 0).length > 0) return { valid: true };
1728
+ return truncated ? {
1729
+ valid: false,
1730
+ reason: "insufficient-evidence",
1731
+ detail: "the review was truncated and no verifiable citation supports the total-score veto"
1732
+ } : {
1733
+ valid: false,
1734
+ reason: "missing-finding",
1735
+ detail: "the total score was rejected without any locatable finding"
1736
+ };
1737
+ }
1738
+ for (const criterion of failed) {
1739
+ const own = input.findings.filter((finding) => finding.criterionId === criterion.id);
1740
+ if (own.length === 0) return truncated ? {
1741
+ valid: false,
1742
+ reason: "insufficient-evidence",
1743
+ detail: criterion.name + ": the evidence was truncated and the judge reported no citation of its own, so this criterion cannot be judged"
1744
+ } : {
1745
+ valid: false,
1746
+ reason: "missing-finding",
1747
+ detail: criterion.name + ": the criterion failed with no structured finding of its own"
1748
+ };
1749
+ let usable = false;
1750
+ let lastReason;
1751
+ for (const finding of own) {
1752
+ const problem = validFinding(finding, input.evidence);
1753
+ if (problem === void 0) {
1754
+ usable = true;
1755
+ break;
1756
+ }
1757
+ lastReason = problem;
1758
+ }
1759
+ if (!usable) {
1760
+ const detail = (lastReason?.detail ?? criterion.name) + " (" + criterion.name + ")";
1761
+ return {
1762
+ valid: false,
1763
+ reason: lastReason?.reason ?? "vacuous-finding",
1764
+ detail
1765
+ };
1766
+ }
1767
+ }
1768
+ return { valid: true };
1769
+ }
1770
+ /**
1771
+ * Why one finding cannot support a veto, or undefined when it can.
1772
+ *
1773
+ * The citation is checked against the evidence text of the LOCATION the finding
1774
+ * names, so a quote invented by the judge, a quote taken from an A/B slot that
1775
+ * held the empty-work baseline, and a quote the reviewed window never contained
1776
+ * are all rejected.
1777
+ */
1778
+ function validFinding(finding, evidence) {
1779
+ if (!substantive(finding.requirement) || !substantive(finding.observation) || !substantive(finding.gap)) return {
1780
+ reason: "vacuous-finding",
1781
+ detail: "the finding states no requirement, observation or difference"
1782
+ };
1783
+ if (finding.location === "baseline") return {
1784
+ reason: "baseline-finding",
1785
+ detail: "the citation is taken from the empty-work baseline, which is not the work under review"
1786
+ };
1787
+ const quote = finding.quote.trim();
1788
+ if (quote.length < 12) return {
1789
+ reason: "unlocatable-quote",
1790
+ detail: "the citation is too short to locate anything in the evidence"
1791
+ };
1792
+ const haystack = finding.location === "task" ? evidence.task : finding.location === "workspace" ? evidence.workspace : finding.location === "trajectory" ? evidence.trajectory : "";
1793
+ if (haystack === "" || !haystack.includes(quote)) return {
1794
+ reason: "unlocatable-quote",
1795
+ detail: "the citation does not occur in the evidence the judge reviewed"
1796
+ };
1797
+ }
1339
1798
  /**
1340
1799
  * The acceptance rule, mirrored from the verifier's `sessionAccepted`:
1341
1800
  * the work must BEAT the empty-work baseline, reach the total threshold, and
@@ -1357,7 +1816,7 @@ function readAttempt(value) {
1357
1816
  if (typeof value !== "object" || value === null) return void 0;
1358
1817
  const row = value;
1359
1818
  if (typeof row.index !== "number" || typeof row.at !== "number") return void 0;
1360
- if (row.stage !== "quality" && row.stage !== "exception") return void 0;
1819
+ if (row.stage !== "quality" && row.stage !== "exception" && row.stage !== "budget" && row.stage !== "invalid") return void 0;
1361
1820
  if (typeof row.passed !== "boolean") return void 0;
1362
1821
  if (typeof row.score !== "number" || !Number.isFinite(row.score)) return void 0;
1363
1822
  if (typeof row.baseline !== "number" || !Number.isFinite(row.baseline)) return void 0;
@@ -1392,7 +1851,18 @@ function readAttempt(value) {
1392
1851
  const routeRow = route;
1393
1852
  if (typeof routeRow.provider !== "string" || typeof routeRow.model !== "string") return void 0;
1394
1853
  if (routeRow.reasoningEffort !== void 0 && typeof routeRow.reasoningEffort !== "string") return void 0;
1395
- const findings = Array.isArray(row.findings) ? row.findings.filter((item) => typeof item === "string") : [];
1854
+ const findings = (Array.isArray(row.findings) ? row.findings : []).filter((item) => typeof item === "string").slice(0, 12).map((item) => clipRead(item, 400));
1855
+ const criterionFindings = [];
1856
+ if (Array.isArray(row.criterionFindings)) {
1857
+ if (row.criterionFindings.length > 12) return void 0;
1858
+ for (const entry of row.criterionFindings) {
1859
+ const finding = readCriterionFinding(entry);
1860
+ if (finding === void 0) return void 0;
1861
+ criterionFindings.push(finding);
1862
+ }
1863
+ }
1864
+ const invalidReason = row.invalidReason;
1865
+ if (invalidReason !== void 0 && invalidReason !== "missing-finding" && invalidReason !== "unlocatable-quote" && invalidReason !== "baseline-finding" && invalidReason !== "vacuous-finding" && invalidReason !== "insufficient-evidence") return void 0;
1396
1866
  return {
1397
1867
  index: row.index,
1398
1868
  at: row.at,
@@ -1425,7 +1895,51 @@ function readAttempt(value) {
1425
1895
  },
1426
1896
  channel: "explicit-tag",
1427
1897
  rounds: typeof row.rounds === "number" ? row.rounds : 2,
1428
- ...typeof row.error === "string" ? { error: row.error } : {}
1898
+ ...typeof row.error === "string" ? { error: row.error } : {},
1899
+ ...criterionFindings.length === 0 ? {} : { criterionFindings },
1900
+ ...typeof invalidReason === "string" ? { invalidReason } : {}
1901
+ };
1902
+ }
1903
+ /**
1904
+ * Bound one persisted finding's text to what the acceptance itself would have
1905
+ * written.
1906
+ *
1907
+ * The reader is the only other door into the ledger: a hand-edited or
1908
+ * corrupted document could otherwise carry a megabyte of prose in a field the
1909
+ * writer caps at a few hundred characters, and every later render, audit
1910
+ * summary and cleanup pass would pay for it. Clipping on READ (never rejecting)
1911
+ * keeps the block usable and its capacity bounded: the acceptance-only material
1912
+ * the cleanup removes is bounded by construction on both sides.
1913
+ * @param value - the raw persisted text.
1914
+ * @param max - the cap the writer applies to that field.
1915
+ * @returns the bounded text.
1916
+ */
1917
+ function clipRead(value, max) {
1918
+ return value.length <= max ? value : value.slice(0, max);
1919
+ }
1920
+ /**
1921
+ * Repair one persisted structured finding, or undefined when it is unusable.
1922
+ * A malformed finding drops the WHOLE acceptance block (fail closed): a
1923
+ * persisted veto that cannot be read back could otherwise be counted as
1924
+ * evidence it no longer holds.
1925
+ */
1926
+ function readCriterionFinding(value) {
1927
+ if (typeof value !== "object" || value === null) return void 0;
1928
+ const row = value;
1929
+ if (typeof row.criterionId !== "string" || row.criterionId === "") return void 0;
1930
+ if (typeof row.requirement !== "string" || typeof row.observation !== "string" || typeof row.gap !== "string") return void 0;
1931
+ if (typeof row.quote !== "string") return void 0;
1932
+ if (row.criterionId.length > 64) return void 0;
1933
+ const location = row.location;
1934
+ if (location !== "task" && location !== "trajectory" && location !== "workspace" && location !== "baseline" && location !== "unknown") return void 0;
1935
+ return {
1936
+ criterionId: row.criterionId,
1937
+ requirement: clipRead(row.requirement, 400),
1938
+ observation: clipRead(row.observation, 400),
1939
+ gap: clipRead(row.gap, 400),
1940
+ quote: clipRead(row.quote, 600),
1941
+ location,
1942
+ ...typeof row.action === "string" ? { action: clipRead(row.action, 400) } : {}
1429
1943
  };
1430
1944
  }
1431
1945
  /**
@@ -1473,7 +1987,7 @@ function normalizeVerification(value) {
1473
1987
  };
1474
1988
  }
1475
1989
  const applicability = row.applicability;
1476
- if (applicability !== "enforced" && applicability !== "disabled" && applicability !== "skipped" && applicability !== "goal-unavailable" && applicability !== "team-member") return void 0;
1990
+ if (applicability !== "enforced" && applicability !== "disabled" && applicability !== "skipped" && applicability !== "goal-unavailable" && applicability !== "team-member" && applicability !== "goal-disabled") return void 0;
1477
1991
  const attempts = [];
1478
1992
  if (!Array.isArray(row.attempts)) return void 0;
1479
1993
  for (const entry of row.attempts) {
@@ -1482,6 +1996,8 @@ function normalizeVerification(value) {
1482
1996
  attempts.push(attempt);
1483
1997
  }
1484
1998
  if (typeof row.failedReason === "string" && row.failedReason === "") return void 0;
1999
+ const cleanup = readCleanupRecord(row.cleanup);
2000
+ if (row.cleanup !== void 0 && cleanup === void 0) return void 0;
1485
2001
  return {
1486
2002
  contract: {
1487
2003
  enabled: contractRow.enabled,
@@ -1496,7 +2012,98 @@ function normalizeVerification(value) {
1496
2012
  ...row.inFlight === true ? { inFlight: true } : {},
1497
2013
  applicability,
1498
2014
  ...typeof row.failedReason === "string" ? { failedReason: row.failedReason } : {},
1499
- ...typeof row.failedAt === "number" && Number.isFinite(row.failedAt) ? { failedAt: row.failedAt } : {}
2015
+ ...typeof row.failedAt === "number" && Number.isFinite(row.failedAt) ? { failedAt: row.failedAt } : {},
2016
+ ...cleanup === void 0 ? {} : { cleanup }
2017
+ };
2018
+ }
2019
+ /**
2020
+ * Repair the persisted cleanup record, or undefined when it is unusable. The
2021
+ * record is the audit trail the UI reads to tell "the judge gave its evidence
2022
+ * and it was cleaned up" apart from "the judge produced no evidence at all",
2023
+ * so a malformed one drops the whole block (fail closed).
2024
+ */
2025
+ function readCleanupRecord(value) {
2026
+ if (typeof value !== "object" || value === null) return void 0;
2027
+ const row = value;
2028
+ if (row.state !== "pending" && row.state !== "cleaned" && row.state !== "failed") return void 0;
2029
+ if (typeof row.attempts !== "number" || !Number.isInteger(row.attempts) || row.attempts < 0) return void 0;
2030
+ if (typeof row.startedAt !== "number" || !Number.isFinite(row.startedAt)) return void 0;
2031
+ if (row.cleanedAt !== void 0 && (typeof row.cleanedAt !== "number" || !Number.isFinite(row.cleanedAt))) return void 0;
2032
+ if (row.lastError !== void 0 && typeof row.lastError !== "string") return void 0;
2033
+ if (!Array.isArray(row.audit)) return void 0;
2034
+ const audit = [];
2035
+ for (const entry of row.audit) {
2036
+ const parsed = readAuditRecord(entry);
2037
+ if (parsed === void 0) return void 0;
2038
+ audit.push(parsed);
2039
+ }
2040
+ return {
2041
+ state: row.state,
2042
+ attempts: row.attempts,
2043
+ startedAt: row.startedAt,
2044
+ ...typeof row.cleanedAt === "number" ? { cleanedAt: row.cleanedAt } : {},
2045
+ ...typeof row.lastError === "string" ? { lastError: row.lastError } : {},
2046
+ audit
2047
+ };
2048
+ }
2049
+ /** Repair one persisted audit credential, or undefined when it is unusable. */
2050
+ function readAuditRecord(value) {
2051
+ if (typeof value !== "object" || value === null) return void 0;
2052
+ const row = value;
2053
+ if (row.stage !== "quality" && row.stage !== "exception" && row.stage !== "budget" && row.stage !== "invalid") return void 0;
2054
+ if (typeof row.passed !== "boolean") return void 0;
2055
+ if (typeof row.at !== "number" || !Number.isFinite(row.at)) return void 0;
2056
+ if (typeof row.score !== "number" || !Number.isFinite(row.score)) return void 0;
2057
+ if (typeof row.baseline !== "number" || !Number.isFinite(row.baseline)) return void 0;
2058
+ if (!Array.isArray(row.criteria)) return void 0;
2059
+ const criteria = [];
2060
+ for (const entry of row.criteria) {
2061
+ if (typeof entry !== "object" || entry === null) return void 0;
2062
+ const criterion = entry;
2063
+ if (typeof criterion.id !== "string" || typeof criterion.name !== "string") return void 0;
2064
+ if (typeof criterion.score !== "number" || !Number.isFinite(criterion.score)) return void 0;
2065
+ if (typeof criterion.baseline !== "number" || !Number.isFinite(criterion.baseline)) return void 0;
2066
+ if (typeof criterion.threshold !== "number" || !Number.isFinite(criterion.threshold)) return void 0;
2067
+ criteria.push({
2068
+ id: criterion.id,
2069
+ name: criterion.name,
2070
+ score: criterion.score,
2071
+ baseline: criterion.baseline,
2072
+ threshold: criterion.threshold,
2073
+ passed: criterion.passed === true
2074
+ });
2075
+ }
2076
+ if (typeof row.evidenceHash !== "string") return void 0;
2077
+ const route = row.route;
2078
+ if (typeof route !== "object" || route === null) return void 0;
2079
+ const routeRow = route;
2080
+ if (typeof routeRow.provider !== "string" || typeof routeRow.model !== "string") return void 0;
2081
+ const usage = row.usage;
2082
+ if (typeof usage !== "object" || usage === null) return void 0;
2083
+ const usageRow = usage;
2084
+ if (typeof usageRow.calls !== "number" || typeof usageRow.inputTokens !== "number" || typeof usageRow.outputTokens !== "number" || typeof usageRow.reasoningTokens !== "number") return void 0;
2085
+ return {
2086
+ stage: row.stage,
2087
+ passed: row.passed,
2088
+ at: row.at,
2089
+ score: row.score,
2090
+ baseline: row.baseline,
2091
+ criteria,
2092
+ evidenceHash: row.evidenceHash,
2093
+ route: {
2094
+ provider: routeRow.provider,
2095
+ model: routeRow.model,
2096
+ ...typeof routeRow.reasoningEffort === "string" ? { reasoningEffort: routeRow.reasoningEffort } : {}
2097
+ },
2098
+ ...typeof row.error === "string" ? { error: row.error } : {},
2099
+ ...typeof row.findingSummary === "string" ? { findingSummary: row.findingSummary } : {},
2100
+ usage: {
2101
+ calls: usageRow.calls,
2102
+ inputTokens: usageRow.inputTokens,
2103
+ outputTokens: usageRow.outputTokens,
2104
+ reasoningTokens: usageRow.reasoningTokens,
2105
+ ...usageRow.usageIncomplete === true ? { usageIncomplete: true } : {}
2106
+ }
1500
2107
  };
1501
2108
  }
1502
2109
  /**
@@ -1515,14 +2122,68 @@ function qualityFeedback(attempt, remaining) {
1515
2122
  "总分 " + percent(attempt.score) + "(阈值 " + percent(attempt.criteria[0]?.threshold ?? .65) + "),空工作基线 " + percent(attempt.baseline) + "。",
1516
2123
  "未达标判据:" + (failed.length === 0 ? "无(总分或基线比较未通过)" : failed.map((criterion) => criterion.name + " " + percent(criterion.score)).join("、"))
1517
2124
  ];
1518
- if (attempt.findings.length > 0) {
2125
+ const located = locatedFindingLines(attempt);
2126
+ if (located.length > 0) {
1519
2127
  lines.push("可定位问题:");
1520
- for (const finding of attempt.findings) lines.push("- " + finding);
2128
+ for (const line of located) lines.push("- " + line);
1521
2129
  } else lines.push("本次裁判没有报告可定位的问题;请直接对照任务要求核对证据中的真实输出。");
1522
2130
  lines.push("请按上述问题修复,然后再次调用 update_goal(action: complete) 触发第 " + (attempt.index + 1) + " 次验收;本执行周期的验收额度还剩 " + remaining + " 次。");
1523
2131
  return lines.join("\n");
1524
2132
  }
1525
2133
  /**
2134
+ * The feedback one INVALID acceptance returns.
2135
+ *
2136
+ * The claim is refused (the board still has no pass record, so completion may
2137
+ * not take effect) but the agent is NOT handed a repair instruction: there is
2138
+ * no located problem to repair, and telling it to "fix" an evidence-free veto
2139
+ * would be exactly the blind repair the requirement forbids. The text says what
2140
+ * was missing, what happens next, and what the agent should do instead (make
2141
+ * the missing output itself observable, or let a human look).
2142
+ * @param attempt - the invalid attempt.
2143
+ * @param remaining - invalid-acceptance attempts left after this one.
2144
+ * @returns the model-facing text.
2145
+ */
2146
+ function invalidAcceptanceFeedback(attempt, remaining) {
2147
+ const lines = [
2148
+ "[任务看板 · 验收无效] 第 " + attempt.index + " 次验收没有产生可用的质量判定。",
2149
+ "原因:" + invalidReasonText(attempt.invalidReason) + (attempt.error === void 0 || attempt.error === "" ? "" : "(" + attempt.error + ")"),
2150
+ "本次未消耗质量验收额度,也不把本次执行判为失败。"
2151
+ ];
2152
+ if (attempt.invalidReason === "insufficient-evidence") {
2153
+ lines.push("裁判看到的证据不完整(本次轨迹被截断 " + attempt.evidence.omittedCharacters + " 字符),无法据此否决;这属于验收无效,不代表执行者没有完成工作。");
2154
+ lines.push("请把关键结论的证据直接放进你能控制的输出里(例如把最终验证命令的输出贴出来),或说明还需要哪些证据;再次调用 update_goal(action: complete) 会重新验收。");
2155
+ } else {
2156
+ lines.push("验收没有拿到任务要求、实际观测、差异与可定位引用俱全的证据,因此本次否决无效。");
2157
+ lines.push("请不要据此盲目修改:先确认你的交付是否真的满足了任务要求,并确保关键产物/输出可见;再次调用 update_goal(action: complete) 会重新验收。");
2158
+ }
2159
+ if (remaining > 0) lines.push("无效验收重试额度还剩 " + remaining + " 次;用尽后看板会停止调用裁判并等待人工处理。");
2160
+ else lines.push("无效验收重试额度已用尽:看板将停止调用裁判并把本次执行留在等待人工处理的状态。");
2161
+ return lines.join("\n");
2162
+ }
2163
+ /**
2164
+ * The reason one execution records when its invalid-acceptance budget is spent.
2165
+ *
2166
+ * Deliberately NOT a quality failure: no veto was ever substantiated, so the
2167
+ * card stays open for a human and the reason says the acceptance is INVALID
2168
+ * rather than rejected.
2169
+ * @param used - invalid attempts spent.
2170
+ * @returns the user- and model-facing text.
2171
+ */
2172
+ function invalidHoldReason(used) {
2173
+ return "goal 验收无效(连续 " + used + " 次未产生可用判定):验收没有给出与任务要求对应、且可定位到本次证据的否决依据,本次执行未判失败、未消耗质量验收额度。看板已停止调用裁判,等待人工处理:修复验收环境或证据可见性后,用 task_board_manage(action=reset-verification) 清除记录的无效验收,再重新完成目标;也可以直接重跑该卡片。";
2174
+ }
2175
+ /** Human-readable text of one invalid reason. */
2176
+ function invalidReasonText(reason) {
2177
+ switch (reason) {
2178
+ case "missing-finding": return "未达标的判据没有对应的结构化问题反馈";
2179
+ case "unlocatable-quote": return "问题反馈引用的位置不存在于本次裁判所见的证据中";
2180
+ case "baseline-finding": return "问题反馈指向空工作基线,而不是本次执行的工作";
2181
+ case "vacuous-finding": return "问题反馈为空或空泛评价,没有说明实际观测与差异";
2182
+ case "insufficient-evidence": return "裁判看到的证据不足(例如轨迹被截断),无法支撑否决";
2183
+ default: return "未产生可用的质量判定";
2184
+ }
2185
+ }
2186
+ /**
1526
2187
  * The reason one exhausted acceptance cycle records as the execution's final
1527
2188
  * failure. Unlike {@link qualityFeedback} — which the fixing agent reads while
1528
2189
  * budget remains — this reason is also persisted on the ledger, echoed on every
@@ -1538,12 +2199,36 @@ function qualityFeedback(attempt, remaining) {
1538
2199
  function finalFailureReason(attempt, used, threshold) {
1539
2200
  const failed = attempt.criteria.filter((criterion) => !criterion.passed);
1540
2201
  const lines = ["goal 验收第 " + used + " 次仍未通过(总分 " + percent(attempt.score) + ",阈值 " + percent(threshold) + ",空工作基线 " + percent(attempt.baseline) + ";未达标判据:" + (failed.length === 0 ? "总分或基线比较未通过" : failed.map((criterion) => criterion.name + " " + percent(criterion.score)).join("、")) + "),本次 execution 判失败。"];
1541
- if (attempt.findings.length > 0) {
2202
+ const located = locatedFindingLines(attempt);
2203
+ if (located.length > 0) {
1542
2204
  lines.push("验收报告记录的可定位问题:");
1543
- for (const finding of attempt.findings) lines.push("- " + finding);
2205
+ for (const line of located) lines.push("- " + line);
1544
2206
  } else lines.push("本次验收没有记录可定位的问题;请对照任务要求与验收面板中的判据得分核对证据中的真实输出。");
1545
2207
  return lines.join("\n");
1546
2208
  }
2209
+ /**
2210
+ * The locatable problem lines of one attempt, structured findings first.
2211
+ *
2212
+ * A structured finding renders the requirement, the observed fact, the gap and
2213
+ * the citation in a fixed order, so the fixing agent reads WHAT was required,
2214
+ * WHAT was seen, WHY it falls short and WHERE to look — never a bare complaint.
2215
+ * @param attempt - the attempt whose findings are rendered.
2216
+ * @returns one line per finding, bounded.
2217
+ */
2218
+ function locatedFindingLines(attempt) {
2219
+ const structured = attempt.criterionFindings ?? [];
2220
+ if (structured.length > 0) return structured.map((finding) => {
2221
+ const locator = "[" + finding.criterionId + " " + finding.location + "] \"" + clip(finding.quote, 200) + "\"";
2222
+ const action = finding.action === void 0 || finding.action === "" ? "" : " → 建议核实:" + finding.action;
2223
+ return locator + " 要求:" + finding.requirement + " 观测:" + finding.observation + " 差异:" + finding.gap + action;
2224
+ });
2225
+ return [...attempt.findings];
2226
+ }
2227
+ /** Bound one text for a single-line rendering. */
2228
+ function clip(text, max) {
2229
+ const value = text.replace(/s+/g, " ").trim();
2230
+ return value.length <= max ? value : value.slice(0, max) + "…";
2231
+ }
1547
2232
  /** `0.42` style percentage. */
1548
2233
  function percent(value) {
1549
2234
  return (value * 100).toFixed(1) + "%";
@@ -1718,6 +2403,7 @@ function isTaskRecordShape(value) {
1718
2403
  if (record.parentId !== void 0 && typeof record.parentId !== "string") return false;
1719
2404
  if (record.workspaceId !== void 0 && typeof record.workspaceId !== "string") return false;
1720
2405
  if (record.mode !== void 0 && typeof record.mode !== "string") return false;
2406
+ if (record.reasoningEffort !== void 0 && typeof record.reasoningEffort !== "string") return false;
1721
2407
  if (record.permission !== void 0 && typeof record.permission !== "string") return false;
1722
2408
  if (record.integrations !== void 0 && (typeof record.integrations !== "object" || record.integrations === null || Array.isArray(record.integrations))) return false;
1723
2409
  if (record.hidden !== void 0 && typeof record.hidden !== "boolean") return false;
@@ -1737,6 +2423,7 @@ function isTaskRecordShape(value) {
1737
2423
  if (entry.initiatedBy !== void 0 && typeof entry.initiatedBy !== "string") return false;
1738
2424
  if (entry.frozenBy !== void 0 && typeof entry.frozenBy !== "string") return false;
1739
2425
  if (entry.frozenAt !== void 0 && typeof entry.frozenAt !== "number") return false;
2426
+ if (entry.external !== void 0 && typeof entry.external !== "boolean") return false;
1740
2427
  if (entry.verification !== void 0 && normalizeVerification(entry.verification) === void 0) return false;
1741
2428
  }
1742
2429
  return true;
@@ -1749,23 +2436,62 @@ function isTaskRecord(value) {
1749
2436
  function normalizeStatus(status) {
1750
2437
  return isTaskStatus(status) ? status : "todo";
1751
2438
  }
2439
+ /** A persisted ms timestamp, kept only when it is a usable finite number. */
2440
+ function optionalInstant(value) {
2441
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2442
+ }
2443
+ /** A persisted positive-integer run cap, or undefined for unlimited/repair. */
2444
+ function optionalRunCap(value) {
2445
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : void 0;
2446
+ }
2447
+ /** A persisted stop reason, kept only when it names a reason this build knows. */
2448
+ function optionalStopReason(value) {
2449
+ return typeof value === "string" && SCHEDULE_STOP_REASONS.includes(value) ? value : void 0;
2450
+ }
1752
2451
  /**
1753
- * Repair a persisted schedule rule: drop rules without a usable cron string,
1754
- * coerce booleans/numbers, and leave `nextRunAt`/`lastTriggeredAt` undefined
1755
- * when missing (a fresh recompute or the next tick fixes them).
2452
+ * Repair a persisted schedule rule. Both modes are repaired field by field and
2453
+ * a rule that carries no usable plan is dropped (the task row survives):
2454
+ * a recurring rule without a well-formed 5-field cron, or a one-shot without a
2455
+ * finite planned instant. A rule written before `mode` existed is a cron rule.
1756
2456
  */
1757
2457
  function normalizeSchedule(schedule) {
1758
2458
  if (typeof schedule !== "object" || schedule === null) return void 0;
1759
2459
  const rule = schedule;
2460
+ const timeZone = typeof rule.timeZone === "string" && isValidTimeZone(rule.timeZone) ? rule.timeZone : void 0;
2461
+ const runCountValue = rule.runCount;
2462
+ const runCount = typeof runCountValue === "number" && Number.isInteger(runCountValue) && runCountValue >= 0 ? runCountValue : 0;
2463
+ const endedAt = optionalInstant(rule.endedAt);
2464
+ const endedReason = optionalStopReason(rule.endedReason);
2465
+ const skippedAt = optionalInstant(rule.skippedAt);
2466
+ const skippedReason = optionalStopReason(rule.skippedReason);
2467
+ const common = {
2468
+ enabled: rule.enabled === true,
2469
+ ...timeZone === void 0 ? {} : { timeZone },
2470
+ nextRunAt: optionalInstant(rule.nextRunAt),
2471
+ lastTriggeredAt: optionalInstant(rule.lastTriggeredAt),
2472
+ runCount,
2473
+ ...endedAt === void 0 ? {} : { endedAt },
2474
+ ...endedReason === void 0 ? {} : { endedReason },
2475
+ ...skippedAt === void 0 ? {} : { skippedAt },
2476
+ ...skippedReason === void 0 ? {} : { skippedReason }
2477
+ };
2478
+ if (rule.mode === "once") {
2479
+ const at = optionalInstant(rule.at);
2480
+ if (at === void 0) return void 0;
2481
+ return {
2482
+ ...common,
2483
+ mode: "once",
2484
+ at
2485
+ };
2486
+ }
1760
2487
  if (typeof rule.cron !== "string") return void 0;
1761
2488
  if (rule.cron.trim() === "" || !isValidCron(rule.cron)) return void 0;
1762
- const timeZone = typeof rule.timeZone === "string" && isValidTimeZone(rule.timeZone) ? rule.timeZone : void 0;
2489
+ const maxRuns = optionalRunCap(rule.maxRuns);
1763
2490
  return {
1764
- enabled: rule.enabled === true,
2491
+ ...common,
2492
+ mode: "cron",
1765
2493
  cron: rule.cron,
1766
- ...timeZone === void 0 ? {} : { timeZone },
1767
- nextRunAt: typeof rule.nextRunAt === "number" ? rule.nextRunAt : void 0,
1768
- lastTriggeredAt: typeof rule.lastTriggeredAt === "number" ? rule.lastTriggeredAt : void 0
2494
+ ...maxRuns === void 0 ? {} : { maxRuns }
1769
2495
  };
1770
2496
  }
1771
2497
  /**
@@ -1842,14 +2568,16 @@ function parseLedger(raw) {
1842
2568
  runGroupId: normalizeTargetId(execution.runGroupId),
1843
2569
  ownResult: isExecutionOutcome(execution.ownResult) ? execution.ownResult : void 0,
1844
2570
  ownError: typeof execution.ownError === "string" ? execution.ownError : void 0,
1845
- verification: normalizeVerification(execution.verification)
2571
+ verification: normalizeVerification(execution.verification),
2572
+ external: execution.external === true ? true : void 0
1846
2573
  }));
1847
2574
  task.workspaceId = normalizeTargetId(row.workspaceId);
1848
2575
  task.mode = normalizeTargetId(row.mode);
2576
+ task.reasoningEffort = normalizeTargetId(row.reasoningEffort);
1849
2577
  task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
1850
2578
  task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
1851
2579
  task.reuseSession = row.reuseSession === true ? true : void 0;
1852
- task.goalRun = row.goalRun === false ? false : void 0;
2580
+ task.goalRun = row.goalRun === true ? true : void 0;
1853
2581
  task.skipVerification = row.skipVerification === true ? true : void 0;
1854
2582
  task.freeze = normalizeFreeze(row.freeze);
1855
2583
  task.handover = normalizeHandover(row.handover);
@@ -1946,6 +2674,57 @@ function applyRestoreTask(tasks, id, now, maxSubtaskDepth = 1) {
1946
2674
  };
1947
2675
  }
1948
2676
  //#endregion
2677
+ //#region src/core/card-defaults.ts
2678
+ /** Trim one optional target: blank text means "not set", like every stored pin. */
2679
+ function textOf(value) {
2680
+ return value?.trim() ?? "";
2681
+ }
2682
+ /**
2683
+ * Resolve the execution targets a new card starts from.
2684
+ * @param defaults - the board's configured new-card defaults, if any.
2685
+ * @param source - the card this one is created from (a duplicate or a parent), if any.
2686
+ * @param picked - what the caller chose explicitly; a non-blank value wins.
2687
+ * @returns the four targets, blank where nothing supplies a value.
2688
+ */
2689
+ function resolveCardTargets(defaults, source, picked) {
2690
+ const defaultMode = textOf(defaults?.mode);
2691
+ const defaultPermission = textOf(defaults?.permission);
2692
+ const defaultModel = textOf(defaults?.model);
2693
+ const defaultEffort = defaultModel === "" ? "" : textOf(defaults?.reasoningEffort);
2694
+ const chosen = {
2695
+ mode: textOf(picked?.mode),
2696
+ permission: textOf(picked?.permission),
2697
+ model: textOf(picked?.model),
2698
+ reasoningEffort: textOf(picked?.reasoningEffort)
2699
+ };
2700
+ const copied = source === void 0 ? void 0 : {
2701
+ mode: textOf(source.task.mode),
2702
+ permission: textOf(source.task.permission),
2703
+ model: textOf(source.task.model),
2704
+ reasoningEffort: textOf(source.task.reasoningEffort)
2705
+ };
2706
+ /** The value carried over from the source card, or the board default. */
2707
+ const inherited = (key) => {
2708
+ const fallback = key === "mode" ? defaultMode : key === "model" ? defaultModel : defaultPermission;
2709
+ if (copied === void 0 || key === "permission" && source?.kind === "parent") return fallback;
2710
+ return copied[key] === "" ? fallback : copied[key];
2711
+ };
2712
+ const mode = chosen.mode === "" ? inherited("mode") : chosen.mode;
2713
+ const permission = chosen.permission === "" ? inherited("permission") : chosen.permission;
2714
+ const model = chosen.model === "" ? inherited("model") : chosen.model;
2715
+ let reasoningEffort = chosen.reasoningEffort;
2716
+ if (reasoningEffort === "" && chosen.model === "") {
2717
+ const sourced = copied === void 0 ? "" : copied.reasoningEffort;
2718
+ reasoningEffort = sourced !== "" ? sourced : model !== "" && model === defaultModel ? defaultEffort : "";
2719
+ }
2720
+ return {
2721
+ mode,
2722
+ permission,
2723
+ model,
2724
+ reasoningEffort
2725
+ };
2726
+ }
2727
+ //#endregion
1949
2728
  //#region src/core/use-cases/task-create.ts
1950
2729
  /**
1951
2730
  * Create-task use case: mint a new task from user input, rejecting a blank
@@ -1958,18 +2737,22 @@ function applyRestoreTask(tasks, id, now, maxSubtaskDepth = 1) {
1958
2737
  * appended ledger, or the unchanged ledger when the title is blank or the
1959
2738
  * requested parent link is refused.
1960
2739
  *
1961
- * A subtask inherits the execution targets its parent already pins (workspace,
1962
- * agent preset, permission, model) unless the form supplied its own; the
1963
- * parent human permission confirmation travels with an inherited permission,
1964
- * so a subtask of an already-confirmed elevated card is runnable at once.
2740
+ * The execution targets resolve through {@link resolveCardTargets}: what the
2741
+ * caller sent wins, then the parent's own pins, then - only when the caller
2742
+ * opted in with `applyCardDefaults` - the deployment's configured new-card
2743
+ * defaults. Without the flag the resolution sees no defaults at all, which is
2744
+ * exactly the inline parent inheritance this path has always applied. The
2745
+ * workspace is not part of that resolution: a subtask still takes the parent's
2746
+ * workspace, and the defaults never name one.
1965
2747
  * @param tasks - current ledger.
1966
2748
  * @param input - raw user input (title/description/prompt + optional schedule/parent).
1967
2749
  * @param now - clock instant (ms epoch).
1968
2750
  * @param id - minted task id.
1969
2751
  * @param maxSubtaskDepth - deployment subtask depth limit.
1970
2752
  * @param hostTimeZone - zone a rule that stores no zone of its own follows.
2753
+ * @param cardDefaults - the deployment's new-card defaults, read live at creation.
1971
2754
  */
1972
- function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1, hostTimeZone = resolveHostTimeZone()) {
2755
+ function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1, hostTimeZone = resolveHostTimeZone(), cardDefaults) {
1973
2756
  if (input.title.trim() === "") return {
1974
2757
  task: void 0,
1975
2758
  tasks,
@@ -1995,28 +2778,67 @@ function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1, hostTimeZon
1995
2778
  error: `subtask depth limit (${limit}) exceeded`
1996
2779
  };
1997
2780
  }
1998
- let task = createTask(parent === void 0 ? input : {
2781
+ const seed = resolveCardTargets(input.applyCardDefaults === true ? cardDefaults : void 0, parent === void 0 ? void 0 : {
2782
+ kind: "parent",
2783
+ task: parent
2784
+ }, {
2785
+ mode: input.mode,
2786
+ permission: input.permission,
2787
+ model: input.model,
2788
+ reasoningEffort: input.reasoningEffort
2789
+ });
2790
+ let task = createTask({
1999
2791
  ...input,
2000
- workspaceId: input.workspaceId ?? parent.workspaceId,
2001
- mode: input.mode ?? parent.mode,
2002
- model: input.model ?? parent.model
2792
+ workspaceId: input.workspaceId ?? parent?.workspaceId,
2793
+ mode: seed.mode,
2794
+ permission: isTaskPermission(seed.permission) ? seed.permission : void 0,
2795
+ model: seed.model,
2796
+ reasoningEffort: seed.reasoningEffort
2003
2797
  }, now, id);
2004
2798
  const requested = input.schedule;
2005
- if (requested?.enabled === true && requested.cron.trim() !== "" && isValidCron(requested.cron)) {
2006
- const cron = requested.cron.trim();
2799
+ if (requested?.enabled === true) {
2007
2800
  const requestedZone = requested.timeZone;
2008
2801
  if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
2009
2802
  task: void 0,
2010
2803
  tasks,
2011
2804
  error: "invalid schedule time zone"
2012
2805
  };
2013
- const nextRunAt = nextRunAtMs(cron, now, requestedZone ?? hostTimeZone);
2014
- if (nextRunAt !== void 0) task = withSchedule(task, {
2015
- enabled: true,
2016
- cron,
2017
- timeZone: requestedZone,
2018
- nextRunAt
2019
- }, now);
2806
+ if (requested.mode === "once") {
2807
+ const at = requested.at;
2808
+ if (at === void 0 || !Number.isInteger(at) || at <= now) return {
2809
+ task: void 0,
2810
+ tasks,
2811
+ error: "invalid schedule instant"
2812
+ };
2813
+ task = withSchedule(task, {
2814
+ enabled: true,
2815
+ mode: "once",
2816
+ at,
2817
+ timeZone: requestedZone,
2818
+ nextRunAt: at,
2819
+ runCount: 0
2820
+ }, now);
2821
+ } else {
2822
+ const cron = (requested.cron ?? "").trim();
2823
+ if (cron !== "" && isValidCron(cron)) {
2824
+ const maxRuns = requested.maxRuns;
2825
+ if (maxRuns !== void 0 && (!Number.isInteger(maxRuns) || maxRuns < 1)) return {
2826
+ task: void 0,
2827
+ tasks,
2828
+ error: "invalid schedule run cap"
2829
+ };
2830
+ const nextRunAt = nextRunAtMs(cron, now, requestedZone ?? hostTimeZone);
2831
+ if (nextRunAt !== void 0) task = withSchedule(task, {
2832
+ enabled: true,
2833
+ mode: "cron",
2834
+ cron,
2835
+ maxRuns,
2836
+ timeZone: requestedZone,
2837
+ nextRunAt,
2838
+ runCount: 0
2839
+ }, now);
2840
+ }
2841
+ }
2020
2842
  }
2021
2843
  return {
2022
2844
  task,
@@ -2047,16 +2869,30 @@ function applyDeleteTask(tasks, selectedTaskId, id) {
2047
2869
  //#endregion
2048
2870
  //#region src/core/use-cases/task-schedule.ts
2049
2871
  /**
2050
- * Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
2051
- * Pure ledger transitions (no persistence or notify — the controller
2052
- * orchestrates those). Validation and next-run computation live here, sharing
2053
- * the core cron parser (schedule.ts) and the withSchedule transition.
2054
- */
2055
- /**
2056
- * Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
2057
- * archived task, or an unusable zone is rejected (state untouched); an enabled
2058
- * rule computes the next run instant immediately in the rule's own zone, a
2059
- * disabled one carries no next-run instant.
2872
+ * Schedule use cases: arm/disarm a task's scheduling rule, and record what a
2873
+ * due occurrence did to it. Pure ledger transitions (no persistence or notify
2874
+ * — the controller orchestrates those). Validation and next-run computation
2875
+ * live here, sharing the core cron parser (schedule.ts) and the withSchedule
2876
+ * transition.
2877
+ *
2878
+ * A rule is either a recurring cron rule or a single planned instant, and
2879
+ * either kind may be capped by a run budget. The counter (`runCount`) moves
2880
+ * only when the Host actually opened an execution; a skipped occurrence
2881
+ * (busy, unconfirmed permission, missed instant, unreachable plan) moves the
2882
+ * rule without consuming its budget.
2883
+ */
2884
+ /**
2885
+ * Set an on-board task's scheduling rule. An unknown or archived task, an
2886
+ * unusable zone, a blank or invalid cron, a one-shot with no usable instant,
2887
+ * a one-shot armed in the past, and a run cap that is not a positive integer
2888
+ * are all rejected (state untouched). An enabled rule computes its next run
2889
+ * instant immediately — a one-shot's next instant IS its planned instant — and
2890
+ * a disabled rule carries none.
2891
+ *
2892
+ * Arming a rule that was not enabled, or switching its kind, starts a fresh
2893
+ * budget: the run counter and any previous stop/skip record are cleared. Edits
2894
+ * to a live rule (expression, zone, cap) keep the consumed count, so an
2895
+ * already-spent budget can never silently reset and overrun.
2060
2896
  * @param tasks - current ledger.
2061
2897
  * @param id - the task to schedule.
2062
2898
  * @param patch - rule fields to change (absent fields keep their current value).
@@ -2070,47 +2906,168 @@ function applySetSchedule(tasks, id, patch, now, hostTimeZone) {
2070
2906
  applied: false
2071
2907
  };
2072
2908
  const current = task.schedule;
2909
+ const mode = patch.mode ?? current?.mode ?? "cron";
2910
+ const timeZone = patch.timeZone === void 0 ? current?.timeZone : patch.timeZone ?? void 0;
2911
+ if (timeZone !== void 0 && !isValidTimeZone(timeZone)) return {
2912
+ tasks,
2913
+ applied: false
2914
+ };
2915
+ const enabled = patch.enabled ?? current?.enabled ?? false;
2916
+ const restart = enabled && current?.enabled !== true || (current?.mode ?? "cron") !== mode;
2917
+ const runCount = restart ? 0 : current?.runCount ?? 0;
2918
+ const effectiveZone = timeZone ?? hostTimeZone;
2919
+ const cleared = restart ? {
2920
+ endedAt: void 0,
2921
+ endedReason: void 0,
2922
+ skippedAt: void 0,
2923
+ skippedReason: void 0
2924
+ } : {
2925
+ endedAt: current?.endedAt,
2926
+ endedReason: current?.endedReason,
2927
+ skippedAt: current?.skippedAt,
2928
+ skippedReason: current?.skippedReason
2929
+ };
2930
+ if (mode === "once") {
2931
+ const at = patch.at ?? current?.at;
2932
+ if (at === void 0 || !Number.isInteger(at)) return {
2933
+ tasks,
2934
+ applied: false
2935
+ };
2936
+ if (enabled && at <= now) return {
2937
+ tasks,
2938
+ applied: false
2939
+ };
2940
+ return {
2941
+ tasks: tasks.map((candidate) => candidate.id !== id ? candidate : withSchedule(candidate, {
2942
+ enabled,
2943
+ mode: "once",
2944
+ at,
2945
+ cron: void 0,
2946
+ maxRuns: void 0,
2947
+ timeZone: timeZone ?? void 0,
2948
+ nextRunAt: enabled ? at : void 0,
2949
+ runCount,
2950
+ ...cleared
2951
+ }, now)),
2952
+ applied: true
2953
+ };
2954
+ }
2073
2955
  const cron = (patch.cron ?? current?.cron ?? "").trim();
2074
2956
  if (cron === "" || !isValidCron(cron)) return {
2075
2957
  tasks,
2076
2958
  applied: false
2077
2959
  };
2078
- const requestedZone = patch.timeZone === void 0 ? current?.timeZone : patch.timeZone ?? void 0;
2079
- if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
2960
+ const maxRuns = ("maxRuns" in patch ? patch.maxRuns : current?.maxRuns) ?? void 0;
2961
+ if (maxRuns !== void 0 && (!Number.isInteger(maxRuns) || maxRuns < 1)) return {
2080
2962
  tasks,
2081
2963
  applied: false
2082
2964
  };
2083
- const enabled = patch.enabled ?? current?.enabled ?? false;
2084
- const nextRunAt = enabled ? nextRunAtMs(cron, now, requestedZone ?? hostTimeZone) : void 0;
2085
- if (enabled && nextRunAt === void 0) return {
2965
+ const spent = maxRuns !== void 0 && runCount >= maxRuns;
2966
+ const armed = enabled && !spent;
2967
+ const nextRunAt = armed ? nextRunAtMs(cron, now, effectiveZone) : void 0;
2968
+ if (armed && nextRunAt === void 0) return {
2086
2969
  tasks,
2087
2970
  applied: false
2088
2971
  };
2972
+ const exhausted = enabled && spent;
2089
2973
  return {
2090
- tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
2091
- enabled,
2974
+ tasks: tasks.map((candidate) => candidate.id !== id ? candidate : withSchedule(candidate, {
2975
+ enabled: armed,
2976
+ mode: "cron",
2092
2977
  cron,
2093
- timeZone: requestedZone ?? void 0,
2094
- nextRunAt
2095
- }, now) : candidate),
2978
+ at: void 0,
2979
+ maxRuns,
2980
+ timeZone: timeZone ?? void 0,
2981
+ nextRunAt,
2982
+ runCount,
2983
+ ...exhausted ? {
2984
+ endedAt: now,
2985
+ endedReason: "limit"
2986
+ } : cleared
2987
+ }, now)),
2096
2988
  applied: true
2097
2989
  };
2098
2990
  }
2099
2991
  /**
2100
- * Roll a task's schedule rule forward (scheduler callback): persist the next
2101
- * due instant and the trigger instant. No-op for tasks without a rule (deleted
2102
- * mid-tick, for example).
2992
+ * Refund the one scheduled run an occurrence consumed before its launch failed
2993
+ * without ever creating a session. Nothing ran, so the run budget is restored
2994
+ * and the rule is left able to serve its next occurrence — a launch failure
2995
+ * must never silently spend the user's limited budget.
2996
+ *
2997
+ * A recurring rule keeps its already-rolled-forward target; a fire that ended
2998
+ * the rule at its cap (no target left) is restored and re-armed at the next
2999
+ * occurrence, so a failed launch consumes nothing. A one-shot has no later
3000
+ * occurrence, so it is left stopped with `launch-failed` and its run returned.
3001
+ * A rule the user disabled in the meantime is not resurrected.
3002
+ * @param tasks - current ledger.
3003
+ * @param id - the task whose occurrence is being refunded.
3004
+ * @param now - clock instant (ms epoch).
3005
+ * @param hostTimeZone - zone a rule with no stored zone follows.
3006
+ * @returns the next ledger.
3007
+ */
3008
+ function applyScheduleRefund(tasks, id, now, hostTimeZone) {
3009
+ return tasks.map((task) => {
3010
+ if (task.id !== id || task.archivedAt !== void 0 || task.schedule === void 0) return task;
3011
+ const rule = task.schedule;
3012
+ const runCount = Math.max(rule.runCount - 1, 0);
3013
+ if (rule.mode === "once") return withSchedule(task, {
3014
+ enabled: false,
3015
+ runCount,
3016
+ nextRunAt: void 0,
3017
+ endedAt: now,
3018
+ endedReason: "launch-failed",
3019
+ skippedAt: void 0,
3020
+ skippedReason: void 0
3021
+ }, now);
3022
+ const refunded = withSchedule(task, {
3023
+ runCount,
3024
+ skippedAt: now,
3025
+ skippedReason: "launch-failed"
3026
+ }, now);
3027
+ if (rule.nextRunAt !== void 0 || rule.endedReason !== "limit") return refunded;
3028
+ const cron = rule.cron ?? "";
3029
+ const next = cron === "" ? void 0 : nextRunAtMs(cron, now, rule.timeZone ?? hostTimeZone);
3030
+ return withSchedule(refunded, next === void 0 ? {
3031
+ enabled: false,
3032
+ endedAt: now,
3033
+ endedReason: "no-target"
3034
+ } : {
3035
+ enabled: true,
3036
+ nextRunAt: next,
3037
+ endedAt: void 0,
3038
+ endedReason: void 0
3039
+ }, now);
3040
+ });
3041
+ }
3042
+ /**
3043
+ * Record what a due occurrence did to a task's rule: advance the run counter
3044
+ * by the executions it opened, set the next target (or stop the rule), and
3045
+ * note a terminal reason or a skipped occurrence. Nothing is written for a
3046
+ * task that has no rule or has been archived mid-tick.
2103
3047
  * @param tasks - current ledger.
2104
- * @param id - the task to roll forward.
2105
- * @param nextRunAt - next due instant (may be undefined to clear).
2106
- * @param lastTriggeredAt - the trigger instant of this run.
3048
+ * @param id - the task whose rule fired.
3049
+ * @param progress - the occurrence's effect.
2107
3050
  * @param now - clock instant (ms epoch).
3051
+ * @returns the next ledger.
2108
3052
  */
2109
- function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
2110
- return tasks.map((task) => task.id === id && task.archivedAt === void 0 && task.schedule !== void 0 ? withSchedule(task, {
2111
- nextRunAt,
2112
- lastTriggeredAt
2113
- }, now) : task);
3053
+ function applyScheduleProgress(tasks, id, progress, now) {
3054
+ return tasks.map((task) => {
3055
+ if (task.id !== id || task.archivedAt !== void 0 || task.schedule === void 0) return task;
3056
+ return withSchedule(task, {
3057
+ runCount: task.schedule.runCount + progress.consumed,
3058
+ nextRunAt: progress.nextRunAt,
3059
+ ...progress.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: progress.lastTriggeredAt },
3060
+ ...progress.endedReason === void 0 ? {} : {
3061
+ enabled: false,
3062
+ endedAt: now,
3063
+ endedReason: progress.endedReason
3064
+ },
3065
+ ...progress.skippedReason === void 0 ? {} : {
3066
+ skippedAt: now,
3067
+ skippedReason: progress.skippedReason
3068
+ }
3069
+ }, now);
3070
+ });
2114
3071
  }
2115
3072
  //#endregion
2116
3073
  //#region src/core/use-cases/task-parent.ts
@@ -2356,6 +3313,7 @@ function applyUpdateTask(tasks, id, patch, now) {
2356
3313
  const mode = "mode" in patch ? normalizeTargetId(patch.mode) : void 0;
2357
3314
  const permission = "permission" in patch ? normalizePermission(task.permission, patch.permission) : void 0;
2358
3315
  const model = "model" in patch ? normalizeTargetId(patch.model) : void 0;
3316
+ const reasoningEffort = "reasoningEffort" in patch ? normalizeTargetId(patch.reasoningEffort) : void 0;
2359
3317
  const next = {
2360
3318
  ...task,
2361
3319
  ...rest,
@@ -2375,27 +3333,31 @@ function applyUpdateTask(tasks, id, patch, now) {
2375
3333
  if ("permission" in patch && patch.permission !== void 0 && patch.permission !== task.permission || "handover" in patch) next.permissionConfirmedAt = void 0;
2376
3334
  if ("reuseSession" in patch) next.reuseSession = patch.reuseSession === true ? true : void 0;
2377
3335
  if ("teamRun" in patch) next.teamRun = patch.teamRun === true ? true : void 0;
2378
- if ("goalRun" in patch) next.goalRun = patch.goalRun === false ? false : void 0;
3336
+ if ("goalRun" in patch) next.goalRun = patch.goalRun === true ? true : void 0;
2379
3337
  if ("skipVerification" in patch) next.skipVerification = patch.skipVerification === true ? true : void 0;
2380
3338
  if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
2381
3339
  if (mode !== void 0 || "mode" in patch) next.mode = mode;
2382
3340
  if (permission !== void 0 || "permission" in patch) next.permission = permission;
2383
3341
  if (model !== void 0 || "model" in patch) next.model = model;
3342
+ if (reasoningEffort !== void 0 || "reasoningEffort" in patch) next.reasoningEffort = reasoningEffort;
2384
3343
  return next;
2385
3344
  });
2386
3345
  }
2387
3346
  /**
2388
- * Every older generation the loader migrates in place. v5 only ADDS the
2389
- * per-execution acceptance block, so the migration is the normalization pass
2390
- * that re-validates each row; a document from any listed generation upgrades
2391
- * losslessly.
3347
+ * Every older generation the loader migrates in place. v5 only added the
3348
+ * per-execution acceptance block and v6 only added the scheduling mode and run
3349
+ * budget, so the migration is the normalization pass that re-validates each
3350
+ * row; a document from any listed generation upgrades losslessly.
2392
3351
  */
2393
3352
  const TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS = [
3353
+ 5,
2394
3354
  4,
2395
3355
  3,
2396
3356
  2
2397
3357
  ];
2398
3358
  const TASK_BOARD_API_PREFIX = "/api/task-board";
3359
+ /** Longest accepted free-text summary on an external outcome record. */
3360
+ const EXTERNAL_SUMMARY_MAX_LENGTH = 1e3;
2399
3361
  /** Strict parse of a `/parse` request body; undefined rejects the request. */
2400
3362
  function parseTaskParseRequest(value) {
2401
3363
  if (typeof value !== "object" || value === null) return void 0;
@@ -2433,15 +3395,39 @@ function hasForbiddenImportField(value) {
2433
3395
  function optionalFiniteNumber(value) {
2434
3396
  return value === void 0 || typeof value === "number" && Number.isFinite(value);
2435
3397
  }
3398
+ /** Whether a stop-reason field names a reason this build understands. */
3399
+ function validStopReason(value) {
3400
+ return value === void 0 || typeof value === "string" && SCHEDULE_STOP_REASONS.includes(value);
3401
+ }
3402
+ /**
3403
+ * Whether an imported schedule rule is structurally usable. A one-shot must
3404
+ * carry its planned instant and a recurring rule its cron expression; the
3405
+ * counter and stop record are optional (a rule written before them simply has
3406
+ * none), but a value that IS present must be usable so the import cannot smuggle
3407
+ * a rule the scheduler would misread.
3408
+ */
3409
+ function validImportedSchedule(value) {
3410
+ const schedule = record(value);
3411
+ if (schedule === void 0 || typeof schedule.enabled !== "boolean") return false;
3412
+ if (schedule.mode !== void 0 && schedule.mode !== "cron" && schedule.mode !== "once") return false;
3413
+ if (schedule.mode === "once") {
3414
+ if (typeof schedule.at !== "number" || !Number.isFinite(schedule.at)) return false;
3415
+ } else if (typeof schedule.cron !== "string") return false;
3416
+ if (schedule.cron !== void 0 && typeof schedule.cron !== "string") return false;
3417
+ if (schedule.at !== void 0 && (typeof schedule.at !== "number" || !Number.isFinite(schedule.at))) return false;
3418
+ if (schedule.maxRuns !== void 0 && (typeof schedule.maxRuns !== "number" || !Number.isInteger(schedule.maxRuns) || schedule.maxRuns < 1)) return false;
3419
+ if (schedule.runCount !== void 0 && (typeof schedule.runCount !== "number" || !Number.isInteger(schedule.runCount) || schedule.runCount < 0)) return false;
3420
+ if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false;
3421
+ if (!optionalFiniteNumber(schedule.endedAt) || !optionalFiniteNumber(schedule.skippedAt)) return false;
3422
+ if (!validStopReason(schedule.endedReason) || !validStopReason(schedule.skippedReason)) return false;
3423
+ if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
3424
+ return true;
3425
+ }
2436
3426
  function validImportedKnownFields(value) {
2437
3427
  if (value.tags !== void 0 && !isTaskTagList(value.tags)) return false;
3428
+ if (value.model !== void 0 && typeof value.model !== "string") return false;
2438
3429
  if (value.integrations !== void 0 && normalizeTaskIntegrations(value.integrations) === void 0) return false;
2439
- if (value.schedule !== void 0) {
2440
- const schedule = record(value.schedule);
2441
- if (schedule === void 0 || typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
2442
- if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false;
2443
- if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
2444
- }
3430
+ if (value.schedule !== void 0 && !validImportedSchedule(value.schedule)) return false;
2445
3431
  if (value.executions !== void 0) {
2446
3432
  if (!Array.isArray(value.executions)) return false;
2447
3433
  for (const item of value.executions) {
@@ -2455,6 +3441,7 @@ function validImportedKnownFields(value) {
2455
3441
  "cancelled"
2456
3442
  ].includes(String(execution.result))) return false;
2457
3443
  if (execution.initiatedBy !== void 0 && typeof execution.initiatedBy !== "string") return false;
3444
+ if (execution.external !== void 0 && typeof execution.external !== "boolean") return false;
2458
3445
  if (execution.frozenBy !== void 0 && typeof execution.frozenBy !== "string") return false;
2459
3446
  if (execution.frozenAt !== void 0 && typeof execution.frozenAt !== "number") return false;
2460
3447
  if (execution.verification !== void 0 && normalizeVerification(execution.verification) === void 0) return false;
@@ -2483,20 +3470,17 @@ function importedTask(value) {
2483
3470
  result: execution.result,
2484
3471
  error: execution.error,
2485
3472
  ...execution.initiatedBy === void 0 ? {} : { initiatedBy: execution.initiatedBy },
3473
+ ...execution.external === true ? { external: true } : {},
2486
3474
  ...execution.frozenAt === void 0 ? {} : { frozenAt: execution.frozenAt },
2487
3475
  ...execution.frozenBy === void 0 ? {} : { frozenBy: execution.frozenBy }
2488
3476
  })),
2489
- ...task.schedule === void 0 ? {} : { schedule: {
2490
- enabled: task.schedule.enabled,
2491
- cron: task.schedule.cron,
2492
- ...task.schedule.timeZone === void 0 ? {} : { timeZone: task.schedule.timeZone },
2493
- nextRunAt: task.schedule.nextRunAt,
2494
- lastTriggeredAt: task.schedule.lastTriggeredAt
2495
- } },
3477
+ ...task.schedule === void 0 ? {} : { schedule: { ...task.schedule } },
2496
3478
  ...task.parentId === void 0 ? {} : { parentId: task.parentId },
2497
3479
  ...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
2498
3480
  ...task.mode === void 0 ? {} : { mode: task.mode },
3481
+ ...typeof task.model !== "string" || task.model.trim() === "" ? {} : { model: task.model },
2499
3482
  ...task.permission === void 0 ? {} : { permission: task.permission },
3483
+ ...task.reasoningEffort === void 0 ? {} : { reasoningEffort: task.reasoningEffort },
2500
3484
  ...task.reuseSession === void 0 ? {} : { reuseSession: task.reuseSession },
2501
3485
  ...task.goalRun === void 0 ? {} : { goalRun: task.goalRun },
2502
3486
  ...task.skipVerification === void 0 ? {} : { skipVerification: task.skipVerification },
@@ -2545,6 +3529,8 @@ function createInput(value) {
2545
3529
  "freeze",
2546
3530
  "handover",
2547
3531
  "model",
3532
+ "reasoningEffort",
3533
+ "applyCardDefaults",
2548
3534
  "reuseSession",
2549
3535
  "teamRun",
2550
3536
  "goalRun",
@@ -2554,7 +3540,8 @@ function createInput(value) {
2554
3540
  ])) return false;
2555
3541
  if (input.parentId !== void 0 && (typeof input.parentId !== "string" || input.parentId.trim() === "")) return false;
2556
3542
  if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
2557
- if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false;
3543
+ if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model) || !optionalString(input.reasoningEffort)) return false;
3544
+ if (input.applyCardDefaults !== void 0 && typeof input.applyCardDefaults !== "boolean") return false;
2558
3545
  if (input.reuseSession !== void 0 && typeof input.reuseSession !== "boolean") return false;
2559
3546
  if (input.teamRun !== void 0 && typeof input.teamRun !== "boolean") return false;
2560
3547
  if (input.goalRun !== void 0 && typeof input.goalRun !== "boolean") return false;
@@ -2568,10 +3555,17 @@ function createInput(value) {
2568
3555
  const schedule = record(input.schedule);
2569
3556
  if (schedule === void 0 || !exactKeys(schedule, [
2570
3557
  "enabled",
3558
+ "mode",
2571
3559
  "cron",
2572
- "timeZone"
3560
+ "at",
3561
+ "timeZone",
3562
+ "maxRuns"
2573
3563
  ])) return false;
2574
- if (typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
3564
+ if (typeof schedule.enabled !== "boolean") return false;
3565
+ if (schedule.mode !== void 0 && schedule.mode !== "cron" && schedule.mode !== "once") return false;
3566
+ if (schedule.cron !== void 0 && typeof schedule.cron !== "string") return false;
3567
+ if (schedule.at !== void 0 && (typeof schedule.at !== "number" || !Number.isInteger(schedule.at))) return false;
3568
+ if (schedule.maxRuns !== void 0 && (typeof schedule.maxRuns !== "number" || !Number.isInteger(schedule.maxRuns) || schedule.maxRuns < 1)) return false;
2575
3569
  if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
2576
3570
  }
2577
3571
  return true;
@@ -2588,6 +3582,7 @@ function updatePatch(value) {
2588
3582
  "freeze",
2589
3583
  "handover",
2590
3584
  "model",
3585
+ "reasoningEffort",
2591
3586
  "reuseSession",
2592
3587
  "teamRun",
2593
3588
  "goalRun",
@@ -2604,7 +3599,8 @@ function updatePatch(value) {
2604
3599
  "prompt",
2605
3600
  "workspaceId",
2606
3601
  "mode",
2607
- "model"
3602
+ "model",
3603
+ "reasoningEffort"
2608
3604
  ]) if (!optionalString(patch[key])) return false;
2609
3605
  if (patch.permission !== void 0 && !isTaskPermission(patch.permission)) return false;
2610
3606
  if (patch.tags !== void 0 && patch.tags !== null && !isTaskTagList(patch.tags)) return false;
@@ -2623,9 +3619,12 @@ function schedulePatch(value) {
2623
3619
  const patch = record(value);
2624
3620
  return patch !== void 0 && exactKeys(patch, [
2625
3621
  "enabled",
3622
+ "mode",
2626
3623
  "cron",
2627
- "timeZone"
2628
- ]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.cron === void 0 || typeof patch.cron === "string") && (patch.timeZone === void 0 || patch.timeZone === null || typeof patch.timeZone === "string" && isValidTimeZone(patch.timeZone));
3624
+ "at",
3625
+ "timeZone",
3626
+ "maxRuns"
3627
+ ]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.mode === void 0 || patch.mode === "cron" || patch.mode === "once") && (patch.cron === void 0 || typeof patch.cron === "string") && (patch.at === void 0 || typeof patch.at === "number" && Number.isInteger(patch.at)) && (patch.timeZone === void 0 || patch.timeZone === null || typeof patch.timeZone === "string" && isValidTimeZone(patch.timeZone)) && (patch.maxRuns === void 0 || patch.maxRuns === null || typeof patch.maxRuns === "number" && Number.isInteger(patch.maxRuns) && patch.maxRuns >= 1);
2629
3628
  }
2630
3629
  function parseActionEnvelope(value) {
2631
3630
  const parsed = parseEnvelopeAction(value);
@@ -2804,11 +3803,38 @@ function parseEnvelopeAction(value) {
2804
3803
  ...action.payload === void 0 ? {} : { payload: action.payload }
2805
3804
  }
2806
3805
  };
3806
+ case "record-external-outcome": {
3807
+ if (!exactKeys(action, [
3808
+ "kind",
3809
+ "taskId",
3810
+ "result",
3811
+ "initiatedBy",
3812
+ "summary"
3813
+ ])) return void 0;
3814
+ if (taskId === void 0) return void 0;
3815
+ if (action.result !== "succeeded" && action.result !== "failed") return void 0;
3816
+ if (typeof action.initiatedBy !== "string") return void 0;
3817
+ const initiatedBy = action.initiatedBy.trim();
3818
+ if (initiatedBy === "" || initiatedBy.length > 200) return void 0;
3819
+ if (action.summary !== void 0 && typeof action.summary !== "string") return void 0;
3820
+ const summary = typeof action.summary === "string" ? action.summary.trim().slice(0, EXTERNAL_SUMMARY_MAX_LENGTH) : "";
3821
+ return {
3822
+ requestId: envelope.requestId,
3823
+ action: {
3824
+ kind: "record-external-outcome",
3825
+ taskId,
3826
+ result: action.result,
3827
+ initiatedBy,
3828
+ ...summary === "" ? {} : { summary }
3829
+ }
3830
+ };
3831
+ }
2807
3832
  case "confirm-permission":
2808
3833
  case "delete":
2809
3834
  case "archive":
2810
3835
  case "restore":
2811
3836
  case "settle":
3837
+ case "reset-verification":
2812
3838
  case "run":
2813
3839
  case "rerun":
2814
3840
  if (!exactKeys(action, ["kind", "taskId"])) return void 0;
@@ -2836,8 +3862,25 @@ function timeZone() {
2836
3862
  function scheduleZone(schedule) {
2837
3863
  return schedule.timeZone ?? timeZone();
2838
3864
  }
2839
- function cloneTasks(tasks) {
2840
- return JSON.parse(JSON.stringify(tasks));
3865
+ /**
3866
+ * The task collection one board read hands out.
3867
+ *
3868
+ * A read must not hand a consumer the ledger's own array: the state route, every
3869
+ * action response and the agent tools all receive this value in-process, so an
3870
+ * in-place rewrite of that array (splice, sort, push, `length = 0`) would edit
3871
+ * the authoritative document from outside the write path. One level of copying
3872
+ * is what that contract needs, and it is all it needs: every write in this class
3873
+ * replaces the document's array (`this.document.tasks = ...`) and replaces the
3874
+ * records it changes rather than mutating them, so the records a caller receives
3875
+ * are stable values. Copying them as well only rebuilt the entire document on
3876
+ * every board read - measured at 0.43 ms per read for 400 cards with 2
3877
+ * executions each and 2.72 ms for 400 cards with 20 - and bought nothing a
3878
+ * consumer relies on. Callers still must not mutate a record in place; that is
3879
+ * what the shared record objects now make possible, and no consumer in this
3880
+ * package does it.
3881
+ */
3882
+ function tasksForRead(tasks) {
3883
+ return [...tasks];
2841
3884
  }
2842
3885
  /**
2843
3886
  * Process states that are dead but still occupy the PID table: `Z` (zombie)
@@ -3103,15 +4146,18 @@ function parseHostTasks(values) {
3103
4146
  const rawSchedule = rawById.get(task.id)?.schedule;
3104
4147
  if (typeof rawSchedule !== "object" || rawSchedule === null) return task;
3105
4148
  const schedule = rawSchedule;
4149
+ if (schedule.mode === "once") return task;
3106
4150
  if (typeof schedule.cron !== "string" || isValidCron(schedule.cron)) return task;
3107
4151
  return {
3108
4152
  ...task,
3109
4153
  schedule: {
3110
4154
  enabled: false,
4155
+ mode: "cron",
3111
4156
  cron: schedule.cron,
3112
4157
  ...typeof schedule.timeZone === "string" && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {},
3113
4158
  nextRunAt: void 0,
3114
- lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0
4159
+ lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0,
4160
+ runCount: typeof schedule.runCount === "number" && Number.isInteger(schedule.runCount) && schedule.runCount >= 0 ? schedule.runCount : 0
3115
4161
  }
3116
4162
  };
3117
4163
  });
@@ -3143,11 +4189,14 @@ var HostTaskLedger = class {
3143
4189
  * instead of remounting the row.
3144
4190
  */
3145
4191
  depthLimit;
4192
+ /** Live reader of the deployment's new-card defaults (a volatile config read). */
4193
+ cardDefaults;
3146
4194
  constructor(dir = join(dshHome(), "task-board"), now = Date.now, options = {}) {
3147
4195
  this.now = now;
3148
4196
  const baseline = options.sessionDefaultPermission;
3149
4197
  this.sessionDefault = typeof baseline === "function" ? baseline : () => baseline ?? "read-only";
3150
4198
  this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? 1);
4199
+ this.cardDefaults = options.cardDefaults ?? (() => ({}));
3151
4200
  mkdirSync(dir, { recursive: true });
3152
4201
  this.file = join(dir, "ledger-v2.json");
3153
4202
  this.lockFile = join(dir, "ledger-v2.lock");
@@ -3202,27 +4251,31 @@ var HostTaskLedger = class {
3202
4251
  const { revision, scheduler } = this.summary();
3203
4252
  return {
3204
4253
  revision,
3205
- tasks: cloneTasks(this.document.tasks),
4254
+ tasks: tasksForRead(this.document.tasks),
3206
4255
  scheduler
3207
4256
  };
3208
4257
  }
3209
4258
  /**
3210
- * Runtime-only projection for the 5 s Host poll. It copies just primitive
4259
+ * Runtime-only projection for the Host poll. It copies just primitive
3211
4260
  * identifiers and timestamps, never the complete task/execution history or
3212
4261
  * an authoritative mutable object from the ledger.
3213
4262
  */
3214
4263
  runtimeView() {
3215
4264
  let armedSchedules = 0;
4265
+ let runningCards = 0;
3216
4266
  const teamGroups = /* @__PURE__ */ new Set();
3217
4267
  for (const task of this.document.tasks) {
3218
4268
  if (task.teamRun !== true) continue;
3219
4269
  for (const execution of task.executions) if (execution.runGroupId !== void 0) teamGroups.add(execution.runGroupId);
3220
4270
  }
3221
4271
  const openExecutions = [];
4272
+ const openSessionIds = [];
3222
4273
  for (const task of this.document.tasks) {
3223
4274
  if (task.archivedAt === void 0 && task.schedule?.enabled === true) armedSchedules += 1;
4275
+ if (task.status === "running") runningCards += 1;
3224
4276
  for (const execution of task.executions) {
3225
4277
  if (execution.endedAt !== void 0) continue;
4278
+ if (execution.sessionId !== void 0) openSessionIds.push(execution.sessionId);
3226
4279
  if (execution.ownResult !== void 0) continue;
3227
4280
  openExecutions.push({
3228
4281
  taskId: task.id,
@@ -3235,7 +4288,9 @@ var HostTaskLedger = class {
3235
4288
  }
3236
4289
  return {
3237
4290
  armedSchedules,
3238
- openExecutions
4291
+ openExecutions,
4292
+ openSessionIds,
4293
+ needsSessionState: openExecutions.length > 0 || runningCards > 0
3239
4294
  };
3240
4295
  }
3241
4296
  /** Count armed, non-archived schedules without cloning task histories. */
@@ -3272,7 +4327,8 @@ var HostTaskLedger = class {
3272
4327
  if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
3273
4328
  due.push({
3274
4329
  taskId: task.id,
3275
- cron: schedule.cron,
4330
+ mode: schedule.mode,
4331
+ ...schedule.cron === void 0 ? {} : { cron: schedule.cron },
3276
4332
  timeZone: scheduleZone(schedule),
3277
4333
  nextRunAt: schedule.nextRunAt
3278
4334
  });
@@ -3315,41 +4371,144 @@ var HostTaskLedger = class {
3315
4371
  /**
3316
4372
  * Open the cascade one due schedule triggers: an empty array means nothing
3317
4373
  * ran (already running, or a participant whose elevated permission is still
3318
- * unconfirmed), and the rule rolls to its next occurrence either way.
4374
+ * unconfirmed, a one-shot whose instant was missed, or an exhausted budget),
4375
+ * and the rule's own state says which.
4376
+ *
4377
+ * The run budget is enforced HERE, inside the single ledger writer: a rule
4378
+ * whose counter already reached its cap is stopped instead of opened, so no
4379
+ * restart, repeated timer fire or re-arm can overrun it.
4380
+ * @param taskId - the task whose rule came due.
4381
+ * @param triggeredAt - the instant the occurrence came due.
4382
+ * @returns the executions this occurrence opened.
3319
4383
  */
3320
- openScheduled(taskId, nextRunAt, triggeredAt) {
4384
+ openScheduled(taskId, triggeredAt) {
3321
4385
  const task = this.document.tasks.find((item) => item.id === taskId);
3322
4386
  if (task === void 0 || task.archivedAt !== void 0) return [];
3323
- const rollForward = () => {
3324
- this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)];
3325
- this.commit();
3326
- };
4387
+ const schedule = task.schedule;
4388
+ if (schedule === void 0 || !schedule.enabled) return [];
4389
+ if (scheduleExhausted(schedule)) {
4390
+ this.progressSchedule(taskId, {
4391
+ consumed: 0,
4392
+ endedReason: schedule.mode === "once" ? "fired" : "limit"
4393
+ }, triggeredAt);
4394
+ return [];
4395
+ }
3327
4396
  const refusal = this.bindingRefusal(task);
3328
4397
  if (refusal !== void 0) {
3329
4398
  this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, "schedule")}`;
3330
- rollForward();
4399
+ this.skipOccurrence(taskId, "permission", triggeredAt);
3331
4400
  return [];
3332
4401
  }
3333
4402
  if (hasOpenExecution(task)) {
3334
- rollForward();
4403
+ this.skipOccurrence(taskId, "busy", triggeredAt);
3335
4404
  return [];
3336
4405
  }
3337
- return this.startCascade(task, triggeredAt, void 0, false, nextRunAt);
4406
+ if (schedule.mode === "once") return this.startCascade(task, triggeredAt, void 0, false, {
4407
+ consumed: 1,
4408
+ lastTriggeredAt: triggeredAt,
4409
+ endedReason: "fired"
4410
+ });
4411
+ const next = nextRunAtMs(schedule.cron ?? "", schedule.nextRunAt ?? triggeredAt, scheduleZone(schedule));
4412
+ if (next === void 0) {
4413
+ this.progressSchedule(taskId, {
4414
+ consumed: 0,
4415
+ endedReason: "no-target"
4416
+ }, triggeredAt);
4417
+ return [];
4418
+ }
4419
+ const consumed = schedule.runCount + 1;
4420
+ const budget = scheduleRunBudget(schedule);
4421
+ const spent = budget !== void 0 && consumed >= budget;
4422
+ return this.startCascade(task, triggeredAt, void 0, false, {
4423
+ consumed: 1,
4424
+ nextRunAt: spent ? void 0 : next,
4425
+ lastTriggeredAt: triggeredAt,
4426
+ ...spent ? { endedReason: "limit" } : {}
4427
+ });
4428
+ }
4429
+ /**
4430
+ * Record a due occurrence that opened no execution: roll a recurring rule to
4431
+ * its next occurrence (budget untouched), or stop it when there is none. A
4432
+ * one-shot has no second occurrence, so it always stops here with the reason.
4433
+ */
4434
+ skipOccurrence(taskId, reason, now) {
4435
+ const schedule = this.document.tasks.find((item) => item.id === taskId)?.schedule;
4436
+ if (schedule === void 0) return;
4437
+ if (schedule.mode === "once") {
4438
+ this.progressSchedule(taskId, {
4439
+ consumed: 0,
4440
+ endedReason: reason
4441
+ }, now);
4442
+ return;
4443
+ }
4444
+ const next = nextRunAtMs(schedule.cron ?? "", schedule.nextRunAt ?? now, scheduleZone(schedule));
4445
+ if (next === void 0) this.progressSchedule(taskId, {
4446
+ consumed: 0,
4447
+ endedReason: "no-target"
4448
+ }, now);
4449
+ else this.progressSchedule(taskId, {
4450
+ consumed: 0,
4451
+ nextRunAt: next,
4452
+ skippedReason: reason
4453
+ }, now);
4454
+ }
4455
+ /** Apply a schedule-occurrence transition and persist it atomically. */
4456
+ progressSchedule(taskId, progress, now) {
4457
+ this.document.tasks = [...applyScheduleProgress(this.document.tasks, taskId, progress, now)];
4458
+ this.commit();
3338
4459
  }
4460
+ /**
4461
+ * Refund the one scheduled run an occurrence consumed before its launch
4462
+ * failed without creating a session (see
4463
+ * {@link applyScheduleRefund}). Called by the service on the failed-launch
4464
+ * path only; a manual run and a launch that did reach a session are never
4465
+ * refunded. A no-op for a task with no rule, so a failed cascade member does
4466
+ * not touch anything.
4467
+ * @param taskId - the task whose occurrence is being refunded.
4468
+ * @param now - clock instant (ms epoch).
4469
+ */
4470
+ refundScheduledOccurrence(taskId, now) {
4471
+ const before = this.document.tasks.find((item) => item.id === taskId)?.schedule;
4472
+ if (before === void 0) return;
4473
+ const next = applyScheduleRefund(this.document.tasks, taskId, now, timeZone());
4474
+ const after = next.find((item) => item.id === taskId)?.schedule;
4475
+ if (JSON.stringify(before) === JSON.stringify(after)) return;
4476
+ this.document.tasks = [...next];
4477
+ this.commit();
4478
+ }
4479
+ /**
4480
+ * Boot / resume recovery: skip every occurrence that came due while the board
4481
+ * was not running through it. A recurring rule rolls to its next future
4482
+ * target; a one-shot whose instant has passed is stopped as missed, because
4483
+ * there is no later occurrence left to run. Rendering the missed occurrence
4484
+ * is deliberately not attempted: the ACL of a card that fired hours ago is
4485
+ * stale, and the board's own recovery contract is "missed triggers are
4486
+ * skipped, never replayed".
4487
+ */
3339
4488
  skipMissed(now) {
3340
4489
  let changed = false;
3341
4490
  this.document.tasks = this.document.tasks.map((task) => {
3342
4491
  const schedule = task.schedule;
3343
4492
  if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) return task;
3344
4493
  changed = true;
3345
- return {
3346
- ...task,
3347
- schedule: {
3348
- ...schedule,
3349
- nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
3350
- },
3351
- updatedAt: now
3352
- };
4494
+ if (schedule.mode === "once") return withSchedule(task, {
4495
+ enabled: false,
4496
+ nextRunAt: void 0,
4497
+ endedAt: now,
4498
+ endedReason: "missed"
4499
+ }, now);
4500
+ const next = nextRunAtMs(schedule.cron ?? "", now, scheduleZone(schedule));
4501
+ if (next === void 0) return withSchedule(task, {
4502
+ enabled: false,
4503
+ nextRunAt: void 0,
4504
+ endedAt: now,
4505
+ endedReason: "no-target"
4506
+ }, now);
4507
+ return withSchedule(task, {
4508
+ nextRunAt: next,
4509
+ skippedAt: now,
4510
+ skippedReason: "missed"
4511
+ }, now);
3353
4512
  });
3354
4513
  if (changed) this.commit();
3355
4514
  }
@@ -3434,7 +4593,7 @@ var HostTaskLedger = class {
3434
4593
  case "import": {
3435
4594
  const sources = new Set(this.document.scheduler.importedSources ?? []);
3436
4595
  if (sources.has(action.sourceId)) return { state: this.state() };
3437
- const invalidScheduleIds = action.tasks.filter((task) => task.schedule !== void 0 && !isValidCron(task.schedule.cron)).map((task) => task.id);
4596
+ const invalidScheduleIds = action.tasks.filter((task) => task.schedule !== void 0 && task.schedule.mode !== "once" && !isValidCron(task.schedule.cron ?? "")).map((task) => task.id);
3438
4597
  const incoming = parseHostTasks(action.tasks);
3439
4598
  const merged = new Map(this.document.tasks.map((task) => [task.id, task]));
3440
4599
  for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id), task) : task);
@@ -3448,7 +4607,8 @@ var HostTaskLedger = class {
3448
4607
  }
3449
4608
  case "create": {
3450
4609
  if (this.document.tasks.some((task) => task.id === action.id)) throw new Error("task id already exists");
3451
- if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || !isValidTimeZone(action.input.schedule.timeZone ?? timeZone()) || nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === void 0)) throw new Error("invalid schedule");
4610
+ const requested = action.input.schedule;
4611
+ if (requested?.enabled === true && !this.validScheduleRequest(requested, now)) throw new Error("invalid schedule");
3452
4612
  const input = action.input.freeze === void 0 || initiator === void 0 || initiator === "" ? action.input : {
3453
4613
  ...action.input,
3454
4614
  freeze: {
@@ -3456,7 +4616,7 @@ var HostTaskLedger = class {
3456
4616
  frozenBy: initiator
3457
4617
  }
3458
4618
  };
3459
- const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone());
4619
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone(), this.cardDefaults());
3460
4620
  if (result.task === void 0) throw new Error(result.error ?? "invalid task");
3461
4621
  this.document.tasks = [...result.tasks];
3462
4622
  break;
@@ -3507,6 +4667,29 @@ var HostTaskLedger = class {
3507
4667
  this.document.tasks = [...result.tasks];
3508
4668
  break;
3509
4669
  }
4670
+ case "record-external-outcome": {
4671
+ const task = this.document.tasks.find((item) => item.id === action.taskId);
4672
+ if (task === void 0) throw new Error("task not found");
4673
+ if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
4674
+ if (hasOpenExecution(task)) throw new Error("running task cannot receive an external outcome");
4675
+ const execution = {
4676
+ id: crypto.randomUUID(),
4677
+ sessionId: void 0,
4678
+ startedAt: now,
4679
+ endedAt: now,
4680
+ result: action.result,
4681
+ error: action.summary,
4682
+ initiatedBy: action.initiatedBy,
4683
+ external: true
4684
+ };
4685
+ this.document.tasks = this.document.tasks.map((item) => item.id !== action.taskId ? item : {
4686
+ ...item,
4687
+ status: settledStatus(item, action.result),
4688
+ updatedAt: now,
4689
+ executions: retainRecentExecutions([...item.executions, execution])
4690
+ });
4691
+ break;
4692
+ }
3510
4693
  case "settle": {
3511
4694
  const task = this.document.tasks.find((item) => item.id === action.taskId);
3512
4695
  if (task === void 0) throw new Error("task not found");
@@ -3520,6 +4703,26 @@ var HostTaskLedger = class {
3520
4703
  this.finalizeReadyRuns(false);
3521
4704
  break;
3522
4705
  }
4706
+ case "reset-verification": {
4707
+ const task = this.document.tasks.find((item) => item.id === action.taskId);
4708
+ if (task === void 0) throw new Error("task not found");
4709
+ if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
4710
+ const execution = [...task.executions].reverse().find((entry) => entry.endedAt === void 0);
4711
+ if (execution === void 0) throw new Error("task has no open execution");
4712
+ const verification = execution.verification;
4713
+ if (verification === void 0 || verification.applicability !== "enforced") throw new Error("this execution is not gated by task acceptance");
4714
+ const cleared = withoutAcceptanceAnomalies(verification);
4715
+ if (cleared === void 0) throw new Error("this execution has no acceptance anomaly to reset");
4716
+ this.document.tasks = this.document.tasks.map((item) => item.id !== action.taskId ? item : {
4717
+ ...item,
4718
+ updatedAt: now,
4719
+ executions: item.executions.map((entry) => entry.id === execution.id ? {
4720
+ ...entry,
4721
+ verification: cleared
4722
+ } : entry)
4723
+ });
4724
+ break;
4725
+ }
3523
4726
  case "restore": {
3524
4727
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error("running task cannot be restored");
3525
4728
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth);
@@ -3575,6 +4778,21 @@ var HostTaskLedger = class {
3575
4778
  };
3576
4779
  }
3577
4780
  /**
4781
+ * Whether a creation-time schedule request is usable. A one-shot must carry
4782
+ * a whole-millisecond instant in the future; a recurring rule must carry a
4783
+ * valid expression with a reachable occurrence and, when capped, a positive
4784
+ * whole run budget. An unusable zone is refused rather than reinterpreted as
4785
+ * the Host zone.
4786
+ */
4787
+ validScheduleRequest(request, now) {
4788
+ if (request.timeZone !== void 0 && !isValidTimeZone(request.timeZone)) return false;
4789
+ if (request.mode === "once") return typeof request.at === "number" && Number.isInteger(request.at) && request.at > now;
4790
+ const cron = (request.cron ?? "").trim();
4791
+ if (cron === "" || !isValidCron(cron)) return false;
4792
+ if (request.maxRuns !== void 0 && (!Number.isInteger(request.maxRuns) || request.maxRuns < 1)) return false;
4793
+ return nextRunAtMs(cron, now, request.timeZone ?? timeZone()) !== void 0;
4794
+ }
4795
+ /**
3578
4796
  * The binding that makes a run illegal, if any.
3579
4797
  *
3580
4798
  * A plain cascade launches one session per participant, so every participant
@@ -3628,17 +4846,20 @@ var HostTaskLedger = class {
3628
4846
  return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth).some((task) => hasOpenExecution(task));
3629
4847
  }
3630
4848
  /**
3631
- * Open one execution per cascade participant (the requested task and its
3632
- * on-board descendants, within the depth limit) under a single run group, and
3633
- * commit the ledger once. The returned runs are ordered root-first, so the
3634
- * Host launches the parent before its subtasks.
4849
+ * Open one execution per cascade participant that still has work (the
4850
+ * requested task and its on-board descendants, within the depth limit) under
4851
+ * a single run group, and commit the ledger once. A descendant in 'done' is
4852
+ * not a participant ({@link joinsCascadeRun}). The returned runs are ordered
4853
+ * root-first, so the Host launches the parent before its subtasks.
3635
4854
  * @param root - the task the user ran.
3636
4855
  * @param now - clock instant (ms epoch).
3637
4856
  * @param initiator - the DSH session that asked for the run (audit only).
3638
4857
  * @param rerun - true to reset the root to 'todo' before starting it.
3639
- * @param nextRunAt - when set, the root's schedule also rolls forward.
4858
+ * @param progress - when set, this run was a due occurrence and the root's
4859
+ * rule is advanced (counter, next target, stop/skip record) in the same
4860
+ * atomic commit that opened the executions.
3640
4861
  */
3641
- startCascade(root, now, initiator, rerun = false, nextRunAt) {
4862
+ startCascade(root, now, initiator, rerun = false, progress) {
3642
4863
  const before = this.document.tasks;
3643
4864
  const groupId = crypto.randomUUID();
3644
4865
  const team = root.teamRun === true;
@@ -3646,6 +4867,7 @@ var HostTaskLedger = class {
3646
4867
  const runs = [];
3647
4868
  for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
3648
4869
  if (task.archivedAt !== void 0) continue;
4870
+ if (!joinsCascadeRun(task, root.id)) continue;
3649
4871
  if (hasOpenExecution(task)) continue;
3650
4872
  const opened = startExecution(rerun && task.id === root.id ? withStatus(task, "todo", now) : task, now, crypto.randomUUID(), initiator, groupId);
3651
4873
  started.set(task.id, opened.task);
@@ -3657,7 +4879,7 @@ var HostTaskLedger = class {
3657
4879
  }
3658
4880
  if (runs.length === 0) return [];
3659
4881
  let tasks = before.map((task) => started.get(task.id) ?? task);
3660
- if (nextRunAt !== void 0) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now);
4882
+ if (progress !== void 0) tasks = applyScheduleProgress(tasks, root.id, progress, now);
3661
4883
  this.document.tasks = [...tasks];
3662
4884
  this.commit();
3663
4885
  return runs;
@@ -3779,6 +5001,14 @@ var HostTaskLedger = class {
3779
5001
  for (const memberId of members) if (this.settleCascade(memberId, groupId, now)) changed = true;
3780
5002
  return changed;
3781
5003
  }
5004
+ /**
5005
+ * Re-align every armed rule at load/import. A recurring rule is recomputed
5006
+ * from the current instant; a one-shot is armed at its planned instant when
5007
+ * that is still in the future, and stopped as missed once it has passed
5008
+ * (there is no later occurrence to run). A recurring rule recomputed past an
5009
+ * occurrence records the skip, so the board can show that a fire was passed
5010
+ * over rather than silently rewinding.
5011
+ */
3782
5012
  repairSchedules(skipPast, persist = true) {
3783
5013
  const now = this.now();
3784
5014
  let changed = false;
@@ -3786,30 +5016,49 @@ var HostTaskLedger = class {
3786
5016
  const schedule = task.schedule;
3787
5017
  if (schedule === void 0 || !schedule.enabled) return task;
3788
5018
  if (!skipPast && schedule.nextRunAt !== void 0) return task;
3789
- const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule));
5019
+ if (schedule.mode === "once") {
5020
+ const at = schedule.at;
5021
+ if (at === void 0) {
5022
+ changed = true;
5023
+ return withSchedule(task, {
5024
+ enabled: false,
5025
+ nextRunAt: void 0,
5026
+ endedAt: now,
5027
+ endedReason: "no-target"
5028
+ }, now);
5029
+ }
5030
+ if (at <= now) {
5031
+ changed = true;
5032
+ return withSchedule(task, {
5033
+ enabled: false,
5034
+ nextRunAt: void 0,
5035
+ endedAt: now,
5036
+ endedReason: "missed"
5037
+ }, now);
5038
+ }
5039
+ if (schedule.nextRunAt === at) return task;
5040
+ changed = true;
5041
+ return withSchedule(task, { nextRunAt: at }, now);
5042
+ }
5043
+ const next = nextRunAtMs(schedule.cron ?? "", now, scheduleZone(schedule));
3790
5044
  if (next === void 0) {
3791
5045
  changed = true;
3792
- this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`;
3793
- return {
3794
- ...task,
3795
- schedule: {
3796
- ...schedule,
3797
- enabled: false,
3798
- nextRunAt: void 0
3799
- },
3800
- updatedAt: now
3801
- };
5046
+ this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`;
5047
+ return withSchedule(task, {
5048
+ enabled: false,
5049
+ nextRunAt: void 0,
5050
+ endedAt: now,
5051
+ endedReason: "no-target"
5052
+ }, now);
3802
5053
  }
3803
- if (schedule.nextRunAt === next) return task;
5054
+ const passed = schedule.nextRunAt !== void 0 && schedule.nextRunAt <= now;
5055
+ if (schedule.nextRunAt === next && !passed) return task;
3804
5056
  changed = true;
3805
- return {
3806
- ...task,
3807
- schedule: {
3808
- ...schedule,
3809
- nextRunAt: next
3810
- },
3811
- updatedAt: now
3812
- };
5057
+ return withSchedule(task, passed ? {
5058
+ nextRunAt: next,
5059
+ skippedAt: now,
5060
+ skippedReason: "missed"
5061
+ } : { nextRunAt: next }, now);
3813
5062
  });
3814
5063
  if (changed && persist) this.commit();
3815
5064
  }
@@ -3854,7 +5103,7 @@ var HostTaskLedger = class {
3854
5103
  */
3855
5104
  migrateLegacyDocument(parsed) {
3856
5105
  if (!Array.isArray(parsed.tasks) || !parsed.tasks.every((row) => isTaskRecord(row))) throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`);
3857
- const rows = parsed.tasks.map((value) => {
5106
+ const rows = typeof parsed.schemaVersion === "number" && parsed.schemaVersion <= 4 ? parsed.tasks.map((value) => {
3858
5107
  const schedule = value.schedule;
3859
5108
  if (typeof schedule !== "object" || schedule === null) return value;
3860
5109
  if (typeof schedule.timeZone === "string") return value;
@@ -3865,7 +5114,7 @@ var HostTaskLedger = class {
3865
5114
  timeZone: timeZone()
3866
5115
  }
3867
5116
  };
3868
- });
5117
+ }) : parsed.tasks;
3869
5118
  return this.normalizeDocument({
3870
5119
  ...parsed,
3871
5120
  tasks: rows
@@ -3931,10 +5180,10 @@ var HostTaskLedger = class {
3931
5180
  if (typeof parsed.schemaVersion === "number" && TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) try {
3932
5181
  return this.migrateLegacyDocument(parsed);
3933
5182
  } catch (error) {
3934
- throw new Error(`ledger v${String(parsed.schemaVersion)} to v5 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
5183
+ throw new Error(`ledger v${String(parsed.schemaVersion)} to v6 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
3935
5184
  }
3936
5185
  try {
3937
- if (parsed.schemaVersion !== 5 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
5186
+ if (parsed.schemaVersion !== 6 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
3938
5187
  return this.normalizeDocument(parsed);
3939
5188
  } catch (error) {
3940
5189
  return this.recoverCorrupt(dir, existed, error);
@@ -3949,14 +5198,15 @@ var HostTaskLedger = class {
3949
5198
  if (typeof value !== "object" || value === null) return [];
3950
5199
  const row = value;
3951
5200
  if (typeof row.schedule !== "object" || row.schedule === null) return [];
3952
- const cron = row.schedule.cron;
3953
- return typeof cron !== "string" || !isValidCron(cron) ? [typeof row.id === "string" ? row.id : "unknown"] : [];
5201
+ const rule = row.schedule;
5202
+ if (rule.mode === "once") return typeof rule.at === "number" && Number.isFinite(rule.at) ? [] : [typeof row.id === "string" ? row.id : "unknown"];
5203
+ return typeof rule.cron !== "string" || !isValidCron(rule.cron) ? [typeof row.id === "string" ? row.id : "unknown"] : [];
3954
5204
  });
3955
5205
  const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === "number" ? parsed.scheduler.lastTickAt : void 0;
3956
5206
  const sidecarLastTickAt = this.readSchedulerSidecar();
3957
5207
  const lastTickAt = sidecarLastTickAt === void 0 || documentLastTickAt !== void 0 && documentLastTickAt >= sidecarLastTickAt ? documentLastTickAt : sidecarLastTickAt;
3958
5208
  return {
3959
- schemaVersion: 5,
5209
+ schemaVersion: 6,
3960
5210
  revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
3961
5211
  tasks,
3962
5212
  scheduler: {
@@ -3982,7 +5232,7 @@ var HostTaskLedger = class {
3982
5232
  if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`);
3983
5233
  mkdirSync(dir, { recursive: true });
3984
5234
  return {
3985
- schemaVersion: 5,
5235
+ schemaVersion: 6,
3986
5236
  revision: 0,
3987
5237
  tasks: [],
3988
5238
  scheduler: {
@@ -4171,7 +5421,96 @@ function mostRecentWorkspaceId(items) {
4171
5421
  return best?.id ?? items[0]?.id;
4172
5422
  }
4173
5423
  //#endregion
5424
+ //#region src/core/reasoning-effort.ts
5425
+ /** Trim one optional effort: blank text means "not set", like every other pinned target. */
5426
+ function normalizeReasoningEffort(value) {
5427
+ const trimmed = value?.trim();
5428
+ return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
5429
+ }
5430
+ /**
5431
+ * Complete one route through the host catalog: a qualified route must match its
5432
+ * provider group, and a bare model id matches the first group carrying it —
5433
+ * which is the provider the request then names.
5434
+ * @param catalog - the host model catalog, when this deployment serves one.
5435
+ * @param route - the route as the card pins it.
5436
+ * @returns the completed route with its reasoning metadata, or undefined when
5437
+ * the catalog declares no such model.
5438
+ */
5439
+ function declaredRoute(catalog, route) {
5440
+ const groups = catalog?.groups ?? [];
5441
+ const matched = route.provider === void 0 || route.provider === "" ? groups.find((group) => group.models.some((model) => model.id === route.model)) : groups.find((group) => group.id === route.provider);
5442
+ const model = matched?.models.find((candidate) => candidate.id === route.model);
5443
+ if (matched === void 0 || model === void 0) return void 0;
5444
+ return {
5445
+ provider: matched.id,
5446
+ model: model.id,
5447
+ ...model.reasoning === void 0 ? {} : { reasoning: model.reasoning }
5448
+ };
5449
+ }
5450
+ /**
5451
+ * Resolve the effort one execution sends.
5452
+ * @param requested - the card's stored effort (absent or blank = inherit).
5453
+ * @param catalog - the host model catalog, when this deployment serves one.
5454
+ * @param route - the route the execution will actually run at.
5455
+ * @returns the effort to send, or the dropped value that will not be sent.
5456
+ */
5457
+ function resolveReasoningEffort(requested, catalog, route) {
5458
+ const effort = normalizeReasoningEffort(requested);
5459
+ if (effort === void 0) return {};
5460
+ if (route === void 0) return { dropped: effort };
5461
+ return declaredRoute(catalog, route)?.reasoning?.efforts.some((option) => option.id === effort) === true ? { effort } : { dropped: effort };
5462
+ }
5463
+ //#endregion
4174
5464
  //#region src/host-runner.ts
5465
+ /**
5466
+ * Mint the in-process owner peer this plugin's Host calls speak for.
5467
+ *
5468
+ * The 0.2.1 gateway made `peer` mandatory on every request, and the
5469
+ * authorization layer reads only `peer.principal`: a principal of
5470
+ * `{ kind: 'owner' }` is how host-local code states that the call IS the local
5471
+ * user driving their own Host, so the gateway answers it directly instead of
5472
+ * running a remote-access decision (only non-owner principals are judged).
5473
+ * That is exactly the task board's position — it creates, renames, prompts,
5474
+ * and reads sessions as the Host itself, which before 0.2.1 was the implicit
5475
+ * privilege of every in-process caller. The board claims owner explicitly, and
5476
+ * only ever inside its own Host process: no wire field can set a principal,
5477
+ * and this plugin never forwards a browser-supplied one.
5478
+ *
5479
+ * `interactions` stays `'none'`: the board only invokes methods and opens the
5480
+ * short `session/follow` stream. The gateway's interactions check gates
5481
+ * exactly the forwarded-event endpoints (opening an event stream, answering a
5482
+ * waterfall), which this caller never touches; an `'answer'` peer would
5483
+ * additionally receive forwarded Host events, a capability the board neither
5484
+ * needs nor wants.
5485
+ *
5486
+ * The peer's signal aborts once `ctx` disposes, so streams opened for it end
5487
+ * with the plugin fiber, and `dispose()` is idempotent with racing calls
5488
+ * sharing one completion. The id carries no credential — the protocol fixes
5489
+ * `principal` as the only identity persisted in a session log — so a fresh
5490
+ * unique id per mount is sufficient.
5491
+ */
5492
+ function createOwnerPeer(ctx) {
5493
+ const controller = new AbortController();
5494
+ let completion;
5495
+ const peer = {
5496
+ id: `task-board-owner:${crypto.randomUUID()}`,
5497
+ ctx,
5498
+ principal: { kind: "owner" },
5499
+ signal: controller.signal,
5500
+ interactions: "none",
5501
+ dispose() {
5502
+ if (completion === void 0) {
5503
+ controller.abort();
5504
+ completion = Promise.resolve();
5505
+ }
5506
+ return completion;
5507
+ }
5508
+ };
5509
+ ctx.effect(() => () => {
5510
+ peer.dispose();
5511
+ }, "task-board owner peer");
5512
+ return peer;
5513
+ }
4175
5514
  function sessionAddress(sessionId) {
4176
5515
  return {
4177
5516
  kind: "session",
@@ -4261,16 +5600,19 @@ function peerPromptPreamble(context) {
4261
5600
  return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join("\n")}`;
4262
5601
  }
4263
5602
  /**
4264
- * The scheduled-run section: this execution was triggered by the card's cron
4265
- * rule rather than by a person. It states the firing instant, the rule's zone,
4266
- * and the rule text, so the run's own clock is unambiguous — the board's
5603
+ * The scheduled-run section: this execution was triggered by the card's
5604
+ * schedule rather than by a person. It states the firing instant, the rule's
5605
+ * zone, and the rule text, so the run's own clock is unambiguous — the board's
4267
5606
  * scheduler fires on a wall clock in a specific IANA zone, which is not
4268
- * necessarily the zone of whatever session the run lands in.
5607
+ * necessarily the zone of whatever session the run lands in. A one-shot says
5608
+ * so explicitly, so the run does not expect a recurrence.
4269
5609
  */
4270
5610
  function schedulePromptPreamble(context) {
4271
5611
  const schedule = context.schedule;
4272
5612
  if (schedule === void 0) return void 0;
4273
- return `本次执行由任务看板的定时规则自动触发:触发时间 ${new Date(schedule.triggeredAt).toISOString()}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
5613
+ const stamp = new Date(schedule.triggeredAt).toISOString();
5614
+ if (schedule.mode === "once") return `本次执行由任务看板的单次定时自动触发:计划时间 ${stamp}(UTC),时区 ${schedule.timeZone}。这是一次性计划,本次执行之后该卡片不会再自动执行。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
5615
+ return `本次执行由任务看板的定时规则自动触发:触发时间 ${stamp}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
4274
5616
  }
4275
5617
  /**
4276
5618
  * Build the execution prompt for one task.
@@ -4369,27 +5711,39 @@ function invokeWireArgs(namespace, method, request) {
4369
5711
  if (namespace === "session" && method === "list") return { _request: request };
4370
5712
  return { request };
4371
5713
  }
4372
- var HostExecutionRunner = class {
5714
+ var HostExecutionRunner = class HostExecutionRunner {
4373
5715
  gateway;
4374
5716
  commands;
4375
5717
  workspaceRegistry;
4376
- /** Newest scanned event sequence per session with no matching execution end. */
5718
+ /**
5719
+ * Newest event sequence per session whose COMPLETE history walk (reaching
5720
+ * the execution boundary) found no matching execution end. A later walk may
5721
+ * trust everything at or below it and examine only newer records.
5722
+ */
4377
5723
  scanMemos = /* @__PURE__ */ new Map();
4378
5724
  unavailableAttempts;
4379
5725
  unavailableBackoffMs;
5726
+ /**
5727
+ * The peer every request speaks for. Production passes the peer bound to the
5728
+ * plugin fiber; a bare runner (unit tests around fake gateways) gets one
5729
+ * bound to a fresh root context instead.
5730
+ */
5731
+ ownerPeer;
4380
5732
  unsupportedSessionListWarned = false;
4381
- constructor(gateway, commands, workspaceRegistry, unavailableRetry) {
5733
+ constructor(gateway, commands, workspaceRegistry, unavailableRetry, ownerPeer) {
4382
5734
  this.gateway = gateway;
4383
5735
  this.commands = commands;
4384
5736
  this.workspaceRegistry = workspaceRegistry;
4385
5737
  this.unavailableAttempts = unavailableRetry?.attempts ?? SERVICE_UNAVAILABLE_ATTEMPTS;
4386
5738
  this.unavailableBackoffMs = unavailableRetry?.backoffMs ?? SERVICE_UNAVAILABLE_BACKOFF_MS;
5739
+ this.ownerPeer = ownerPeer ?? createOwnerPeer(new Context());
4387
5740
  }
4388
5741
  invoke(namespace, method, request, signal) {
4389
5742
  return this.gateway.invoke({
4390
5743
  namespace,
4391
5744
  method,
4392
5745
  args: invokeWireArgs(namespace, method, request),
5746
+ peer: this.ownerPeer,
4393
5747
  ...signal === void 0 ? {} : { signal }
4394
5748
  });
4395
5749
  }
@@ -4399,6 +5753,7 @@ var HostExecutionRunner = class {
4399
5753
  namespace,
4400
5754
  method,
4401
5755
  args: { request },
5756
+ peer: this.ownerPeer,
4402
5757
  ...signal === void 0 ? {} : { signal }
4403
5758
  });
4404
5759
  }
@@ -4440,7 +5795,7 @@ var HostExecutionRunner = class {
4440
5795
  try {
4441
5796
  options.onSession?.(reused);
4442
5797
  await this.assertReusedPreset(reused, mode);
4443
- await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed);
5798
+ await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true, options.catalog);
4444
5799
  } catch (error) {
4445
5800
  throw new SessionLaunchError(reused, error);
4446
5801
  }
@@ -4456,7 +5811,7 @@ var HostExecutionRunner = class {
4456
5811
  sessionId,
4457
5812
  title: task.title
4458
5813
  });
4459
- await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed);
5814
+ await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true, options.catalog);
4460
5815
  } catch (error) {
4461
5816
  throw new SessionLaunchError(sessionId, error);
4462
5817
  }
@@ -4483,30 +5838,85 @@ var HostExecutionRunner = class {
4483
5838
  throw new Error("reused session " + sessionId + " was composed from agent preset " + (typeof recorded === "string" ? "\"" + recorded + "\"" : "an unreadable value") + ", but the task pins \"" + pinned + "\"");
4484
5839
  }
4485
5840
  /**
5841
+ * Split a `provider/model` pin. A pin without a provider keeps only the model
5842
+ * id: the provider is the one the host catalog declares for it, resolved at
5843
+ * launch because the session wire contract requires a provider.
5844
+ */
5845
+ static pinnedRoute(model) {
5846
+ const raw = model?.trim();
5847
+ if (raw === void 0 || raw === "") return void 0;
5848
+ const slash = raw.indexOf("/");
5849
+ if (slash < 0) return { model: raw };
5850
+ const provider = raw.slice(0, slash).trim();
5851
+ const modelId = raw.slice(slash + 1).trim();
5852
+ if (provider === "" || modelId === "") return void 0;
5853
+ return {
5854
+ provider,
5855
+ model: modelId
5856
+ };
5857
+ }
5858
+ /**
5859
+ * The route a card that pins no model actually runs at: the session's own
5860
+ * recorded selection (the durable fold the GUI reads; `next` is the pending
5861
+ * user choice, `lastUsed` what the last request consumed), or — for a session
5862
+ * that has selected none yet, which is exactly a session that would start at
5863
+ * the deployment default — the host default from the catalog.
5864
+ *
5865
+ * A route that cannot be READ is undefined and the effort is not sent:
5866
+ * falling back to the host default there would silently switch the model of a
5867
+ * session this run only meant to continue.
5868
+ */
5869
+ async sessionRoute(sessionId, catalog) {
5870
+ let selection;
5871
+ try {
5872
+ const projections = await this.invoke("session", "projections", { sessionId });
5873
+ selection = projections?.values?.modelSelection?.next ?? projections?.values?.modelSelection?.lastUsed;
5874
+ } catch (error) {
5875
+ console.warn("[dsh-task-board] session/projections failed while resolving the execution model route", error);
5876
+ return;
5877
+ }
5878
+ if (typeof selection?.provider === "string" && selection.provider !== "" && typeof selection.model === "string" && selection.model !== "") return {
5879
+ provider: selection.provider,
5880
+ model: selection.model
5881
+ };
5882
+ return catalog?.default === void 0 ? void 0 : {
5883
+ provider: catalog.default.provider,
5884
+ model: catalog.default.model
5885
+ };
5886
+ }
5887
+ /**
4486
5888
  * Re-assert the pinned execution contract on a session and queue the task
4487
5889
  * prompt. Shared by the fresh-session and reuse paths so both apply exactly
4488
- * the same permission/model pins before the prompt.
5890
+ * the same permission/model/effort pins before the prompt. A session that
5891
+ * refuses a selection carrying a catalog-confirmed effort throws here, so the
5892
+ * prompt is never queued behind a run at the wrong level.
4489
5893
  */
4490
- async pinAndPrompt(sessionId, task, permission, context = {}, onGoalArmed) {
5894
+ async pinAndPrompt(sessionId, task, permission, context = {}, onGoalArmed, goalEnabled = false, catalog) {
4491
5895
  if (permission !== void 0) {
4492
5896
  if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
4493
5897
  const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
4494
5898
  if (command === void 0) throw new Error("permission command was not acknowledged");
4495
5899
  if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
4496
5900
  }
4497
- if (task.model !== void 0 && task.model.trim() !== "") {
4498
- const rawModel = task.model.trim();
4499
- const slashIdx = rawModel.indexOf("/");
4500
- const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
4501
- const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
5901
+ const requestedEffort = normalizeReasoningEffort(task.reasoningEffort);
5902
+ const route = HostExecutionRunner.pinnedRoute(task.model) ?? (requestedEffort === void 0 ? void 0 : await this.sessionRoute(sessionId, catalog));
5903
+ if (route !== void 0) {
5904
+ const routeLabel = `${route.provider === void 0 ? "" : route.provider + "/"}${route.model}`;
5905
+ const decision = resolveReasoningEffort(requestedEffort, catalog, route);
5906
+ if (decision.dropped !== void 0) console.warn(`[dsh-task-board] model route ${routeLabel} does not declare reasoning effort "${decision.dropped}"; session ${sessionId} runs at the model default`);
5907
+ const declared = declaredRoute(catalog, route);
5908
+ const provider = declared?.provider ?? route.provider;
5909
+ const modelId = declared?.model ?? route.model;
4502
5910
  try {
4503
5911
  await this.invoke("session", "selectModel", {
4504
5912
  sessionId,
4505
- ...provider ? { provider } : {},
4506
- model: modelId
5913
+ ...provider === void 0 ? {} : { provider },
5914
+ model: modelId,
5915
+ ...decision.effort === void 0 ? {} : { reasoningEffort: decision.effort }
4507
5916
  });
4508
5917
  } catch (modelError) {
4509
- console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
5918
+ if (decision.effort !== void 0) throw new Error(`session ${sessionId} refused the model selection "${provider === void 0 ? modelId : provider + "/" + modelId}" with reasoning effort "${decision.effort}": ` + (modelError instanceof Error ? modelError.message : String(modelError)));
5919
+ console.warn(`[dsh-task-board] failed to select model "${provider === void 0 ? modelId : provider + "/" + modelId}" for session ${sessionId}, falling back to default:`, modelError);
4510
5920
  }
4511
5921
  }
4512
5922
  await this.invoke("session", "prompt", {
@@ -4518,7 +5928,7 @@ var HostExecutionRunner = class {
4518
5928
  text: promptText(task, context)
4519
5929
  }]
4520
5930
  });
4521
- const armed = await this.armGoal(sessionId, task, context);
5931
+ const armed = await this.armGoal(sessionId, task, context, goalEnabled);
4522
5932
  onGoalArmed?.(armed);
4523
5933
  }
4524
5934
  /**
@@ -4544,14 +5954,25 @@ var HostExecutionRunner = class {
4544
5954
  * dsh's goal-round driver keeps starting continuation rounds until the agent
4545
5955
  * marks it complete (see {@link goalVerdict} for how the run settles).
4546
5956
  *
4547
- * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
5957
+ * Two switches gate the arming, and BOTH must ask for it:
5958
+ * - the GLOBAL native-/goal switch (`goalEnabled`, default OFF), frozen when
5959
+ * this execution started; and
5960
+ * - the task's own `goalRun` (absent means off, only an explicit true opts
5961
+ * the card into a goal run).
5962
+ *
4548
5963
  * A refusal (no command dispatcher, no `/goal` command in this cohort, an
4549
- * objective the command rejects) is reported and the run continues as that
4550
- * plain turn: the task was asked to run, and a missing goal mode must not
4551
- * lose the work.
5964
+ * objective the command rejects) is reported and the run continues as a plain
5965
+ * turn: the task was asked to run, and a missing goal mode must not lose the
5966
+ * work.
5967
+ * @param sessionId - the session to arm in.
5968
+ * @param task - the task being run.
5969
+ * @param context - the run's prompt context.
5970
+ * @param goalEnabled - the GLOBAL switch, frozen for this execution.
5971
+ * @returns whether `/goal` was really armed.
4552
5972
  */
4553
- async armGoal(sessionId, task, context) {
4554
- if (task.goalRun === false) return false;
5973
+ async armGoal(sessionId, task, context, goalEnabled) {
5974
+ if (!goalEnabled) return false;
5975
+ if (task.goalRun !== true) return false;
4555
5976
  if (this.commands === void 0) {
4556
5977
  console.warn("[dsh-task-board] no command dispatcher is available; task " + task.id + " runs without /goal");
4557
5978
  return false;
@@ -4710,10 +6131,13 @@ var HostExecutionRunner = class {
4710
6131
  }
4711
6132
  const openingEvents = opening.records.map((record) => ({ event: recordEvent(record) }));
4712
6133
  const newestSeq = openingEvents.reduce((newest, entry) => newest === void 0 ? entry.event.seq : Math.max(newest, entry.event.seq), void 0);
4713
- if (newestSeq !== void 0 && this.scanMemos.get(sessionId) === newestSeq) return { outcome: "pending" };
6134
+ const scannedThrough = this.scanMemos.get(sessionId);
6135
+ if (newestSeq !== void 0 && scannedThrough === newestSeq) return { outcome: "pending" };
6136
+ const oldestOpeningSeq = openingEvents.reduce((oldest, entry) => oldest === void 0 ? entry.event.seq : Math.min(oldest, entry.event.seq), void 0);
6137
+ const coveredThrough = scannedThrough !== void 0 && oldestOpeningSeq !== void 0 && oldestOpeningSeq <= scannedThrough;
4714
6138
  const events = [...openingEvents];
4715
6139
  let beforeSeq;
4716
- let reachedExecutionBoundary = !opening.hasMore;
6140
+ let reachedExecutionBoundary = !opening.hasMore || coveredThrough;
4717
6141
  for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
4718
6142
  let history;
4719
6143
  try {
@@ -4734,11 +6158,11 @@ var HostExecutionRunner = class {
4734
6158
  const pageEntries = pageEvents(history);
4735
6159
  events.push(...pageEntries);
4736
6160
  const oldestTime = pageEntries.reduce((oldest, entry) => oldest === void 0 ? entry.event.time : Math.min(oldest, entry.event.time), void 0);
4737
- if (!history.hasMore || oldestTime !== void 0 && oldestTime <= startedAt) {
6161
+ const oldestSeq = pageEntries.reduce((oldest, entry) => oldest === void 0 ? entry.event.seq : Math.min(oldest, entry.event.seq), void 0);
6162
+ if (!history.hasMore || oldestTime !== void 0 && oldestTime <= startedAt || scannedThrough !== void 0 && oldestSeq !== void 0 && oldestSeq <= scannedThrough) {
4738
6163
  reachedExecutionBoundary = true;
4739
6164
  break;
4740
6165
  }
4741
- const oldestSeq = pageEntries.reduce((oldest, entry) => oldest === void 0 ? entry.event.seq : Math.min(oldest, entry.event.seq), void 0);
4742
6166
  if (oldestSeq === void 0 || oldestSeq === beforeSeq) return { outcome: "pending" };
4743
6167
  beforeSeq = oldestSeq;
4744
6168
  }
@@ -5473,8 +6897,8 @@ var TaskBoardExtensionRegistry = class {
5473
6897
  };
5474
6898
  //#endregion
5475
6899
  //#region src/host-service.ts
5476
- /** Session-roster poll cadence — the one recurring Host timer this service still holds. */
5477
- const SESSION_POLL_MS = 5e3;
6900
+ /** Ceiling for the roster-poll failure backoff: a broken session tree is retried at most once a minute. */
6901
+ const POLL_FAILURE_MAX_BACKOFF_MS = 6e4;
5478
6902
  /**
5479
6903
  * How late an armed schedule fire may be before it counts as a resume rather
5480
6904
  * than a normal occurrence. The schedule timer is armed AT the next due
@@ -5495,10 +6919,51 @@ const MAX_TIMER_DELAY_MS = 2147483647;
5495
6919
  */
5496
6920
  const UNREADABLE_SETTLE_POLLS = 24;
5497
6921
  /**
5498
- * Actions that can move an armed trigger. Only these re-arm the native timer;
5499
- * an unrelated card edit leaves the pending fire untouched.
6922
+ * Why a member of a run never started: the Host stopped while its launch was
6923
+ * still waiting for an admission slot. Recorded rather than left open, so the
6924
+ * card leaves the running column instead of waiting for the next Host start's
6925
+ * interrupted-start recovery.
6926
+ */
6927
+ const ABANDONED_LAUNCH_REASON = "host stopped before this execution started";
6928
+ /**
6929
+ * Why an enforced goal execution settled failed while its acceptance DID run:
6930
+ * attempts were recorded and none of them passed. The judge answered about the
6931
+ * work and the work did not pass.
6932
+ */
6933
+ const NO_MATCHING_PASS_VERIFICATION_REASON = "goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。";
6934
+ /**
6935
+ * Why an enforced goal execution settled failed with ZERO acceptance records:
6936
+ * the gate was never opened, so the judge never ran and nothing was ever judged
6937
+ * (issue #1837). The root cause is a session that declared completion in prose
6938
+ * instead of calling `update_goal(action: complete)` — a tool-adherence problem,
6939
+ * NOT a quality verdict on the delivery — so this reason says so and keeps the
6940
+ * two investigation directions apart.
6941
+ */
6942
+ const NEVER_INVOKED_VERIFICATION_REASON = "goal 验收未触发:本次执行没有任何验收记录,验收门从未打开——执行会话没有调用 update_goal(action: complete) 来完成目标(常见于只在回复里宣告完成)。这不是质量判负:交付从未被裁判评估。";
6943
+ /**
6944
+ * Why an enforced goal execution settled failed while its ONLY acceptance
6945
+ * records are INVALID: the judge answered but could not locate its own
6946
+ * rejection, so the board stopped calling it. This is NOT an evidence-backed
6947
+ * quality veto — the requirement is explicit that such a run must not be
6948
+ * described as one — but the execution still has no matching pass, so it ends
6949
+ * for lack of one, and the reason names that precisely.
6950
+ */
6951
+ const INVALID_ONLY_VERIFICATION_REASON = "goal 验收无效:本次执行只记录了无效验收(裁判未能给出与任务要求对应、且可定位到本次证据的否决依据),看板已停止调用裁判。这**不是**有证据的质量判负:验收本身不可用,本次运行因没有匹配的通过记录而结束。请检查验收环境与证据可见性,或用 task_board_manage(action=reset-verification) 清除记录的无效验收后重新完成目标。";
6952
+ /**
6953
+ * Actions that can change the instant the schedule timer is armed at. Only
6954
+ * these re-arm the native timer; an unrelated card edit leaves the pending fire
6955
+ * untouched.
6956
+ *
6957
+ * The set is the write surface of a rule itself rather than a guess at the UI
6958
+ * flows that reach it: `create` and `import` land a card that already carries
6959
+ * an enabled rule (the new-task dialog and the one-time browser import),
6960
+ * `set-schedule` edits one, and `delete`/`archive` drop one. `update` is
6961
+ * deliberately absent — the update patch cannot carry a schedule — and neither
6962
+ * can the run, move, lineage, permission, verification or label actions.
5500
6963
  */
5501
6964
  const SCHEDULE_WRITE_ACTIONS = /* @__PURE__ */ new Set([
6965
+ "create",
6966
+ "import",
5502
6967
  "set-schedule",
5503
6968
  "delete",
5504
6969
  "archive"
@@ -5518,6 +6983,26 @@ const PROCESS_TIMERS = {
5518
6983
  };
5519
6984
  }
5520
6985
  };
6986
+ /**
6987
+ * The new-card defaults a snapshot publishes, or undefined when this
6988
+ * deployment configures none. A target the board configures nothing for is
6989
+ * omitted instead of published blank, so the browser can tell "no default
6990
+ * model" from "a blank one" (only a hand-edited settings document can express
6991
+ * the latter).
6992
+ */
6993
+ function cardDefaultsOf(defaults) {
6994
+ const mode = normalizeTargetId(defaults.mode);
6995
+ const permission = normalizeTargetId(defaults.permission);
6996
+ const model = normalizeTargetId(defaults.model);
6997
+ const reasoningEffort = normalizeTargetId(defaults.reasoningEffort);
6998
+ const published = {
6999
+ ...mode === void 0 ? {} : { mode },
7000
+ ...isTaskPermission(permission) ? { permission } : {},
7001
+ ...model === void 0 ? {} : { model },
7002
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort }
7003
+ };
7004
+ return Object.keys(published).length === 0 ? void 0 : published;
7005
+ }
5521
7006
  var TaskBoardHostService = class {
5522
7007
  ledger;
5523
7008
  runner;
@@ -5525,6 +7010,12 @@ var TaskBoardHostService = class {
5525
7010
  listeners = /* @__PURE__ */ new Set();
5526
7011
  /** The one recurring timer: the session-roster poll. */
5527
7012
  pollTimer;
7013
+ /** Cadence of the roster poll, in seconds (a settings field; see the config schema). */
7014
+ sessionPollSeconds = 5;
7015
+ /** Pending delay that follows a failed poll pass, if one is armed. */
7016
+ pollBackoffTimer;
7017
+ /** Consecutive failed poll passes; doubles the next retry delay. */
7018
+ consecutivePollFailures = 0;
5528
7019
  /** The armed schedule timer, if a trigger is pending. */
5529
7020
  scheduleTimer;
5530
7021
  /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
@@ -5533,12 +7024,11 @@ var TaskBoardHostService = class {
5533
7024
  pollInFlight = false;
5534
7025
  active = true;
5535
7026
  /**
5536
- * Ids the last roster poll saw as present and idle; undefined while the
5537
- * roster is unknown. Session reuse (issue #1419) requires this positive
5538
- * evidence, so a launch before the first successful poll mints a fresh
5539
- * conversation instead of prompting into a session it cannot see.
7027
+ * Admission control for the session-creating half of a run: one board action
7028
+ * opens a whole subtree, and this is what keeps the Host from asking DSH for
7029
+ * every member's session at once (see {@link LaunchAdmissionQueue}).
5540
7030
  */
5541
- idleSessionIds;
7031
+ launches;
5542
7032
  /**
5543
7033
  * Consecutive unreadable-history polls per open execution (see
5544
7034
  * {@link noteUnreadableInspection}). Cleared as soon as an inspection
@@ -5550,6 +7040,16 @@ var TaskBoardHostService = class {
5550
7040
  verificationCatalog;
5551
7041
  team;
5552
7042
  timers;
7043
+ /** Live reader of the global native-/goal switch; frozen per execution. */
7044
+ goalRunEnabled;
7045
+ /** Live reader of the deployment's new-card defaults (published and applied per create). */
7046
+ cardDefaults;
7047
+ /**
7048
+ * Live reader of the run-launch admission width: how many launches may be in
7049
+ * flight at once. Read at every admission, so a settings edit bounds the next
7050
+ * launch without remounting the row. Absent means the default.
7051
+ */
7052
+ runLaunchWidth;
5553
7053
  lastPowerJson = "";
5554
7054
  now;
5555
7055
  /** The deployment's workspaces, consulted to resolve a card that pins none. */
@@ -5563,14 +7063,19 @@ var TaskBoardHostService = class {
5563
7063
  constructor(gateway, options = {}) {
5564
7064
  this.ledger = options.ledger ?? new HostTaskLedger(void 0, void 0, {
5565
7065
  sessionDefaultPermission: options.sessionDefaultPermission,
5566
- maxSubtaskDepth: options.maxSubtaskDepth
7066
+ maxSubtaskDepth: options.maxSubtaskDepth,
7067
+ cardDefaults: options.cardDefaults
5567
7068
  });
5568
- this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry);
7069
+ this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry, void 0, options.ownerPeer);
5569
7070
  this.workspaceRegistry = options.workspaceRegistry;
5570
7071
  this.team = options.team;
5571
7072
  this.timers = options.timers ?? PROCESS_TIMERS;
5572
7073
  this.power = options.power ?? new PowerInhibitor();
5573
7074
  this.now = options.now ?? Date.now;
7075
+ this.goalRunEnabled = options.goalRunEnabled ?? (() => false);
7076
+ this.cardDefaults = options.cardDefaults ?? (() => ({}));
7077
+ this.runLaunchWidth = options.runLaunchWidth ?? (() => 3);
7078
+ this.launches = new LaunchAdmissionQueue(() => normalizeRunLaunchWidth(this.runLaunchWidth()));
5574
7079
  this.verificationSettings = options.verificationSettings ?? (() => ({
5575
7080
  enabled: false,
5576
7081
  model: "",
@@ -5599,14 +7104,35 @@ var TaskBoardHostService = class {
5599
7104
  this.syncPowerReasons();
5600
7105
  this.pollTimer = this.timers.interval(() => {
5601
7106
  this.schedulePoll();
5602
- }, SESSION_POLL_MS);
7107
+ }, sessionPollMs(this.sessionPollSeconds));
5603
7108
  this.schedulePoll();
7109
+ this.cleanupPendingAcceptanceDetail();
5604
7110
  this.recoverSchedule();
5605
7111
  }
5606
- setConfiguration(active, preventIdleSleep) {
7112
+ /**
7113
+ * Apply the live configuration. The roster cadence is re-read on every
7114
+ * commit, so a settings edit takes effect without a restart; the poll is
7115
+ * scheduled at the new cadence immediately rather than waiting out the old
7116
+ * interval.
7117
+ * @param active - the board's master switch.
7118
+ * @param preventIdleSleep - whether the board holds an idle-sleep assertion.
7119
+ * @param sessionPollSeconds - roster-poll cadence in seconds (clamped).
7120
+ */
7121
+ setConfiguration(active, preventIdleSleep, sessionPollSeconds) {
5607
7122
  const resumed = !this.active && active;
5608
7123
  this.active = active;
5609
7124
  this.preventIdleSleep = preventIdleSleep;
7125
+ const cadence = normalizeSessionPollSeconds(sessionPollSeconds);
7126
+ const retimed = this.pollTimer !== void 0 && cadence !== this.sessionPollSeconds;
7127
+ this.sessionPollSeconds = cadence;
7128
+ if (retimed) {
7129
+ this.pollTimer?.();
7130
+ this.pollTimer = this.timers.interval(() => {
7131
+ this.schedulePoll();
7132
+ }, sessionPollMs(this.sessionPollSeconds));
7133
+ this.clearPollBackoff();
7134
+ this.schedulePoll();
7135
+ }
5610
7136
  if (resumed) {
5611
7137
  const current = this.power.snapshot();
5612
7138
  this.power.updateReasons({
@@ -5626,8 +7152,9 @@ var TaskBoardHostService = class {
5626
7152
  snapshot() {
5627
7153
  const state = this.ledger.state();
5628
7154
  const published = this.extensions.published();
7155
+ const cardDefaults = cardDefaultsOf(this.cardDefaults());
5629
7156
  return {
5630
- schemaVersion: 5,
7157
+ schemaVersion: 6,
5631
7158
  revision: state.revision,
5632
7159
  tasks: state.tasks,
5633
7160
  scheduler: state.scheduler,
@@ -5635,6 +7162,8 @@ var TaskBoardHostService = class {
5635
7162
  sessionDefaultPermission: this.ledger.sessionDefaultPermission,
5636
7163
  maxSubtaskDepth: this.ledger.maxSubtaskDepth,
5637
7164
  teamRunAvailable: this.team !== void 0,
7165
+ goalRunEnabled: this.goalRunEnabled(),
7166
+ ...cardDefaults === void 0 ? {} : { cardDefaults },
5638
7167
  ...Object.keys(published).length === 0 ? {} : { extensions: published }
5639
7168
  };
5640
7169
  }
@@ -5731,7 +7260,7 @@ var TaskBoardHostService = class {
5731
7260
  this.emitStatusChanges(before, initiator);
5732
7261
  if (action.kind === "delete") this.extensions.emitTaskDeleted({ taskId: action.taskId });
5733
7262
  return {
5734
- schemaVersion: 5,
7263
+ schemaVersion: 6,
5735
7264
  revision: result.state.revision,
5736
7265
  tasks: result.state.tasks,
5737
7266
  scheduler: result.state.scheduler,
@@ -5767,18 +7296,23 @@ var TaskBoardHostService = class {
5767
7296
  }
5768
7297
  dispose() {
5769
7298
  this.disposed = true;
7299
+ const abandoned = this.launches.drain();
7300
+ if (abandoned.length > 0) safeConsoleError(`[dsh-task-board] ${abandoned.length} run member(s) were still waiting for a launch slot when the Host stopped; each is settled as cancelled`);
5770
7301
  this.extensions.dispose();
5771
7302
  this.clearScheduleTimer();
7303
+ this.clearPollBackoff();
5772
7304
  this.pollTimer?.();
5773
7305
  this.pollTimer = void 0;
5774
7306
  this.power.dispose();
5775
7307
  this.ledger.dispose();
5776
7308
  this.listeners.clear();
5777
7309
  }
5778
- async launch(opened, others = [], schedule) {
7310
+ async launch(opened, others = [], schedule, freezeContract) {
7311
+ /** The session this launch attached to the execution, once it exists. */
7312
+ let attached;
5779
7313
  try {
5780
7314
  const team = opened.task.teamRun === true;
5781
- const reuseSessionId = team ? void 0 : reusableSessionId(opened.task, this.idleSessionIds);
7315
+ const reuseSessionId = team ? void 0 : await this.reuseSessionFor(opened.task);
5782
7316
  const peers = others.length === 0 ? void 0 : others.map((other) => ({
5783
7317
  id: other.task.id,
5784
7318
  title: other.task.title,
@@ -5789,41 +7323,84 @@ var TaskBoardHostService = class {
5789
7323
  ...team ? { team: true } : {},
5790
7324
  ...schedule === void 0 ? {} : { schedule }
5791
7325
  };
5792
- const settings = this.verificationSettings();
7326
+ const { settings, globalGoalEnabled, catalog } = await (freezeContract ?? (() => this.freezeRunContract()))();
7327
+ const goalEnabled = globalGoalEnabled && opened.task.goalRun === true;
5793
7328
  const skippedBy = opened.task.skipVerification === true ? "skipped" : "disabled";
5794
7329
  const contract = resolveContract({
5795
7330
  ...settings,
5796
7331
  enabled: settings.enabled && opened.task.skipVerification !== true
5797
- }, await this.verificationCatalog());
7332
+ }, catalog);
7333
+ const notArmed = globalGoalEnabled ? "goal-unavailable" : "goal-disabled";
5798
7334
  const initial = {
5799
7335
  contract,
5800
7336
  attempts: [],
5801
- applicability: team ? "team-member" : !contract.enabled ? skippedBy : "goal-unavailable"
7337
+ applicability: team ? "team-member" : !contract.enabled ? skippedBy : notArmed
5802
7338
  };
5803
7339
  this.ledger.setVerification(opened.task.id, opened.execution.id, initial);
5804
- let attached;
5805
7340
  const sessionId = await this.runner.launch(opened.task, {
5806
7341
  ...reuseSessionId === void 0 ? {} : { reuseSessionId },
5807
7342
  ...promptContext === void 0 ? {} : { promptContext },
7343
+ goalEnabled,
7344
+ catalog,
5808
7345
  onSession: (id) => {
5809
7346
  attached = id;
5810
7347
  this.ledger.attachSession(opened.task.id, opened.execution.id, id);
5811
7348
  },
5812
7349
  onGoalArmed: (armed) => {
5813
- this.setApplicability(opened.task.id, opened.execution.id, team ? "team-member" : !contract.enabled ? skippedBy : armed ? "enforced" : "goal-unavailable");
7350
+ this.setApplicability(opened.task.id, opened.execution.id, team ? "team-member" : !contract.enabled ? skippedBy : armed ? "enforced" : notArmed);
5814
7351
  }
5815
7352
  });
5816
7353
  if (attached === void 0) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
5817
7354
  if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId);
5818
7355
  } catch (error) {
5819
- if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
7356
+ const launchedSession = error instanceof SessionLaunchError ? error.sessionId : void 0;
7357
+ if (launchedSession !== void 0) this.ledger.attachSession(opened.task.id, opened.execution.id, launchedSession);
7358
+ if (schedule !== void 0 && attached === void 0 && launchedSession === void 0) this.refundUnstartedSchedule(opened.task.id);
5820
7359
  this.settleAndNotify(opened.task.id, opened.execution.id, "failed", error instanceof Error ? error.message : String(error));
5821
7360
  }
5822
7361
  }
7362
+ /**
7363
+ * Read the acceptance contract one run group freezes, in the order the board
7364
+ * has always read it: the acceptance settings, then the global native-/goal
7365
+ * switch, then the model catalog that both resolves the contract's route and
7366
+ * decides the reasoning effort the session may be given.
7367
+ */
7368
+ async freezeRunContract() {
7369
+ return {
7370
+ settings: this.verificationSettings(),
7371
+ globalGoalEnabled: this.goalRunEnabled() === true,
7372
+ catalog: await this.verificationCatalog()
7373
+ };
7374
+ }
7375
+ /**
7376
+ * A {@link RunContractFreezer} that reads once and hands the SAME value to
7377
+ * every later caller of one run group. A read that fails is not cached: the
7378
+ * next member reads again, exactly as a per-execution read would have.
7379
+ */
7380
+ contractFreezerForRunGroup() {
7381
+ let pending;
7382
+ return () => {
7383
+ pending ??= this.freezeRunContract().catch((error) => {
7384
+ pending = void 0;
7385
+ throw error;
7386
+ });
7387
+ return pending;
7388
+ };
7389
+ }
7390
+ /**
7391
+ * Give back the occurrence a launch consumed, for a launch that never reached
7392
+ * a session: a failed launch, or one the Host abandoned while it was still
7393
+ * waiting for an admission slot. A manual run, and a launch that DID reach a
7394
+ * session, are never refunded.
7395
+ */
7396
+ refundUnstartedSchedule(taskId) {
7397
+ this.ledger.refundScheduledOccurrence(taskId, this.now());
7398
+ this.refreshSchedule();
7399
+ }
5823
7400
  scheduleTeammate(opened, leadSessionId) {
5824
- this.spawnTeammate(opened, leadSessionId).catch((error) => {
7401
+ this.enqueueLaunch(opened, () => this.spawnTeammate(opened, leadSessionId).catch((error) => {
5825
7402
  safeConsoleError("[dsh-task-board] teammate spawn settlement failed", error);
5826
- });
7403
+ }));
5827
7404
  }
5828
7405
  /**
5829
7406
  * Spawn one teammate inside the Lead session and attach the teammate's
@@ -5854,31 +7431,70 @@ var TaskBoardHostService = class {
5854
7431
  this.settleAndNotify(opened.task.id, opened.execution.id, "failed", error instanceof Error ? error.message : String(error));
5855
7432
  }
5856
7433
  }
7434
+ /**
7435
+ * One roster pass, and whether it could read the session tree.
7436
+ *
7437
+ * The board reads the DSH session roster only while it has something the
7438
+ * roster can decide: an execution to inspect, or a card parked in the running
7439
+ * column whose verdict may arrive from a settle this process never saw. That
7440
+ * gate is what removes the cost the reporter measured — `session/list`
7441
+ * rebuilds every persisted session row (string conversion, object allocation,
7442
+ * a stat per record), and an idle board with no tasks was paying it every few
7443
+ * seconds forever.
7444
+ * @returns whether the roster was readable (an idle pass counts as readable).
7445
+ */
5857
7446
  async pollSessions() {
5858
- if (this.disposed) return;
5859
- if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return;
7447
+ if (this.disposed) return true;
7448
+ const runtime = this.ledger.runtimeView();
7449
+ if (!this.active) {
7450
+ if (runtime.openExecutions.length === 0 && runtime.armedSchedules === 0) return true;
7451
+ } else if (!runtime.needsSessionState) return true;
5860
7452
  const running = await this.runner.listRunning();
5861
7453
  const previous = this.power.snapshot();
5862
7454
  if (!running.known) {
5863
- this.idleSessionIds = void 0;
5864
7455
  this.power.updateReasons({
5865
7456
  runningSessions: previous.runningSessions,
5866
- armedSchedules: this.ledger.armedScheduleCount(),
7457
+ armedSchedules: runtime.armedSchedules,
5867
7458
  sessionStateKnown: false
5868
7459
  });
5869
- return;
7460
+ return false;
5870
7461
  }
5871
- this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
5872
7462
  const foldedBefore = this.statusMap();
5873
7463
  this.ledger.finalizeReadyRuns();
5874
7464
  this.emitStatusChanges(foldedBefore);
5875
- const runtime = this.ledger.runtimeView();
7465
+ const current = this.ledger.runtimeView();
5876
7466
  this.power.updateReasons({
5877
7467
  runningSessions: running.count,
5878
- armedSchedules: runtime.armedSchedules,
7468
+ armedSchedules: current.armedSchedules,
5879
7469
  sessionStateKnown: true
5880
7470
  });
5881
- await this.reconcileExecutions(running.items, runtime.openExecutions);
7471
+ await this.reconcileExecutions(running.items, current.openExecutions);
7472
+ return true;
7473
+ }
7474
+ /**
7475
+ * The session a run may continue in, for a card that opted into reuse
7476
+ * (issue #1419). Reuse requires positive evidence that the previous session
7477
+ * is present and idle, and that evidence must be read for THIS launch: an
7478
+ * idle board no longer polls, so a roster cached from an earlier pass could
7479
+ * be hours old — either refusing a session that has been idle all along, or
7480
+ * prompting into one that started running since. A card that did not opt in
7481
+ * reads no roster at all, and a read that fails mints a fresh conversation
7482
+ * exactly as an unknown roster always did.
7483
+ * @param task - the task about to run.
7484
+ * @returns the session id to continue in, or undefined for a fresh session.
7485
+ */
7486
+ async reuseSessionFor(task) {
7487
+ if (task.reuseSession !== true) return void 0;
7488
+ let running;
7489
+ try {
7490
+ running = await this.runner.listRunning();
7491
+ } catch (error) {
7492
+ this.notePollFailure();
7493
+ safeConsoleError("[dsh-task-board] session roster read for session reuse failed; starting a fresh session", error);
7494
+ return;
7495
+ }
7496
+ if (!running.known) return void 0;
7497
+ return reusableSessionId(task, new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId)));
5882
7498
  }
5883
7499
  /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
5884
7500
  async reconcileExecutions(sessions, executions) {
@@ -5898,7 +7514,7 @@ var TaskBoardHostService = class {
5898
7514
  }
5899
7515
  this.unreadablePolls.delete(execution.executionId);
5900
7516
  if (result.outcome === "succeeded" && verificationRequired(verification) && passedAttempt(verification) === void 0) {
5901
- this.settleAndNotify(execution.taskId, execution.executionId, "failed", "goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。");
7517
+ this.settleAndNotify(execution.taskId, execution.executionId, "failed", verificationNeverInvoked(verification) ? NEVER_INVOKED_VERIFICATION_REASON : verificationOnlyInvalid(verification) ? INVALID_ONLY_VERIFICATION_REASON : NO_MATCHING_PASS_VERIFICATION_REASON);
5902
7518
  continue;
5903
7519
  }
5904
7520
  this.settleAndNotify(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
@@ -5936,6 +7552,59 @@ var TaskBoardHostService = class {
5936
7552
  outcome,
5937
7553
  ...error === void 0 ? {} : { error }
5938
7554
  });
7555
+ if (outcome === "succeeded") this.cleanupAcceptanceDetail(taskId, executionId);
7556
+ }
7557
+ /**
7558
+ * Remove one execution's acceptance-only detail after it PASSED and settled.
7559
+ *
7560
+ * Deliberately narrow: only the acceptance mechanism's own per-attempt
7561
+ * material (findings, invalid diagnostics, the bulky judge side) is removed,
7562
+ * and only for THIS execution, keyed by its own id in the ledger it belongs
7563
+ * to. No user file, task artifact, raw session history or other execution's
7564
+ * record is read or written. Idempotent by the record's own state, and it
7565
+ * clearly never touches a run that did not pass (the caller's guard) — a
7566
+ * failed or invalid execution keeps everything a repair or a review needs.
7567
+ * @param taskId - the task whose execution settled.
7568
+ * @param executionId - the settled execution.
7569
+ */
7570
+ cleanupAcceptanceDetail(taskId, executionId) {
7571
+ const verification = (this.ledger.getTask(taskId)?.executions.find((entry) => entry.id === executionId))?.verification;
7572
+ if (verification === void 0 || passedAttempt(verification) === void 0) return;
7573
+ if (verification.cleanup?.state === "cleaned") return;
7574
+ try {
7575
+ const cleaned = withAcceptanceDetailCleared(verification, this.now());
7576
+ if (cleaned === void 0) return;
7577
+ this.ledger.setVerification(taskId, executionId, cleaned);
7578
+ } catch (error) {
7579
+ const attempts = (verification.cleanup?.attempts ?? 0) + 1;
7580
+ this.ledger.setVerification(taskId, executionId, {
7581
+ ...verification,
7582
+ cleanup: {
7583
+ state: "failed",
7584
+ attempts,
7585
+ startedAt: verification.cleanup?.startedAt ?? this.now(),
7586
+ lastError: error instanceof Error ? error.message : String(error),
7587
+ audit: verification.cleanup?.audit ?? []
7588
+ }
7589
+ });
7590
+ safeConsoleError("[dsh-task-board] acceptance-detail cleanup failed; it is retried on the next Host start", error);
7591
+ }
7592
+ }
7593
+ /**
7594
+ * Catch-up pass: clean the acceptance detail of every execution that PASSED
7595
+ * and settled but whose cleanup did not finish (a crash, a failed write, a
7596
+ * Host killed mid-cleanup).
7597
+ *
7598
+ * The same bounds as any other cleanup apply — only a real pass record on a
7599
+ * settled execution is touched — so this can never delete material belonging
7600
+ * to an execution that did not pass, and never a user file.
7601
+ */
7602
+ cleanupPendingAcceptanceDetail() {
7603
+ for (const task of this.ledger.allTasks()) for (const execution of task.executions) {
7604
+ if (execution.endedAt === void 0) continue;
7605
+ if (execution.result !== "succeeded") continue;
7606
+ this.cleanupAcceptanceDetail(task.id, execution.id);
7607
+ }
5939
7608
  }
5940
7609
  noteUnreadableInspection(execution, reason) {
5941
7610
  const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1;
@@ -6003,14 +7672,12 @@ var TaskBoardHostService = class {
6003
7672
  return;
6004
7673
  }
6005
7674
  const before = this.statusMap();
6006
- for (const schedule of this.ledger.dueSchedules(now)) {
6007
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone);
6008
- this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now), {
6009
- triggeredAt: now,
6010
- timeZone: schedule.timeZone,
6011
- cron: schedule.cron
6012
- });
6013
- }
7675
+ for (const schedule of this.ledger.dueSchedules(now)) this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, now), {
7676
+ triggeredAt: now,
7677
+ timeZone: schedule.timeZone,
7678
+ mode: schedule.mode,
7679
+ ...schedule.cron === void 0 ? {} : { cron: schedule.cron }
7680
+ });
6014
7681
  this.emitStatusChanges(before);
6015
7682
  this.armSchedule();
6016
7683
  }
@@ -6023,13 +7690,21 @@ var TaskBoardHostService = class {
6023
7690
  * member then launches on its own so one refused participant cannot hold the
6024
7691
  * others back. A team run's members are spawned inside the root's Lead
6025
7692
  * session instead, once that session exists.
7693
+ *
7694
+ * Every launch of one run set goes through the admission queue, which admits
7695
+ * at most `runLaunchWidth` of them at a time — the whole subtree still opens,
7696
+ * members just enter the running column in batches. Order inside the group is
7697
+ * the order they are enqueued here, so the root's session always exists before
7698
+ * any member's, and one member's launch settling (or failing) is what admits
7699
+ * the next. All members share the one contract this group freezes.
6026
7700
  */
6027
7701
  dispatchRuns(runs, schedule) {
6028
7702
  if (runs.length === 0) return;
6029
7703
  const root = runs.find((run) => run.dispatch !== "teammate") ?? runs[0];
6030
7704
  const others = runs.filter((run) => run !== root);
6031
- this.scheduleLaunch(root, others, schedule);
6032
- for (const run of others) if (run.dispatch !== "teammate") this.scheduleLaunch(run, [], schedule);
7705
+ const freezeContract = this.contractFreezerForRunGroup();
7706
+ this.scheduleLaunch(root, others, schedule, freezeContract);
7707
+ for (const run of others) if (run.dispatch !== "teammate") this.scheduleLaunch(run, [], schedule, freezeContract);
6033
7708
  }
6034
7709
  /**
6035
7710
  * Move one execution's applicability without touching its frozen contract or
@@ -6043,21 +7718,92 @@ var TaskBoardHostService = class {
6043
7718
  applicability
6044
7719
  });
6045
7720
  }
6046
- scheduleLaunch(opened, others = [], schedule) {
6047
- this.launch(opened, others, schedule).catch((error) => {
7721
+ scheduleLaunch(opened, others = [], schedule, freezeContract) {
7722
+ this.enqueueLaunch(opened, () => this.launch(opened, others, schedule, freezeContract).catch((error) => {
6048
7723
  safeConsoleError("[dsh-task-board] execution launch settlement failed", error);
6049
- });
7724
+ }), schedule);
7725
+ }
7726
+ /**
7727
+ * Hand one launch to the admission queue, or abandon it on the spot when the
7728
+ * Host is already stopping: a queue entry created after {@link dispose} would
7729
+ * otherwise never be drained, and its card would sit in the running column
7730
+ * until the next Host start recovered it.
7731
+ */
7732
+ enqueueLaunch(opened, start, schedule) {
7733
+ const launch = {
7734
+ groupId: opened.execution.runGroupId ?? opened.task.id,
7735
+ start: async () => {
7736
+ if (!this.isExecutionOpen(opened)) return;
7737
+ await start();
7738
+ },
7739
+ abandon: () => this.abandonQueuedRun(opened, schedule)
7740
+ };
7741
+ if (this.disposed) {
7742
+ launch.abandon();
7743
+ return;
7744
+ }
7745
+ this.launches.enqueue(launch);
7746
+ }
7747
+ /** Whether one opened execution is still open in the ledger. */
7748
+ isExecutionOpen(opened) {
7749
+ const execution = this.ledger.getTask(opened.task.id)?.executions.find((entry) => entry.id === opened.execution.id);
7750
+ return execution !== void 0 && execution.endedAt === void 0;
7751
+ }
7752
+ /**
7753
+ * Take one launch that never started out of the running column.
7754
+ *
7755
+ * Settled as CANCELLED, not failed: nothing about the run's work went wrong —
7756
+ * the Host stopped before the launch got a slot, which is exactly what the
7757
+ * next start's interrupted-start recovery records for an execution that never
7758
+ * reached a session. A scheduled occurrence that never reached a session is
7759
+ * refunded on the same terms as any other pre-session failure, so a run the
7760
+ * Host never started cannot silently spend the user's run budget.
7761
+ */
7762
+ abandonQueuedRun(opened, schedule) {
7763
+ if (schedule !== void 0) this.refundUnstartedSchedule(opened.task.id);
7764
+ this.settleAndNotify(opened.task.id, opened.execution.id, "cancelled", ABANDONED_LAUNCH_REASON);
6050
7765
  }
6051
7766
  schedulePoll() {
6052
7767
  if (this.pollInFlight || this.disposed) return;
6053
7768
  this.pollInFlight = true;
6054
- this.pollSessions().catch((error) => {
7769
+ this.pollSessions().then((rosterReadable) => {
7770
+ if (rosterReadable) this.consecutivePollFailures = 0;
7771
+ else this.notePollFailure();
7772
+ }).catch((error) => {
7773
+ this.notePollFailure();
6055
7774
  safeConsoleError("[dsh-task-board] session polling failed", error);
6056
7775
  }).finally(() => {
6057
7776
  this.pollInFlight = false;
6058
7777
  });
6059
7778
  }
6060
7779
  /**
7780
+ * Count one failed pass and arm the retry that follows it.
7781
+ *
7782
+ * A session tree that is down is retried on a doubling delay instead of
7783
+ * hammering it on the fixed cadence: the heartbeat would otherwise repeat
7784
+ * every failure — and every retry inside {@link HostExecutionRunner.listRunning}
7785
+ * — at exactly the interval the reporter measured.
7786
+ */
7787
+ notePollFailure() {
7788
+ this.consecutivePollFailures += 1;
7789
+ this.armPollBackoff();
7790
+ }
7791
+ /** Arm the retry that follows a failed pass, doubling up to the ceiling. */
7792
+ armPollBackoff() {
7793
+ if (this.disposed) return;
7794
+ this.pollBackoffTimer?.();
7795
+ const delay = Math.min(sessionPollMs(this.sessionPollSeconds) * 2 ** Math.min(this.consecutivePollFailures, 8), POLL_FAILURE_MAX_BACKOFF_MS);
7796
+ this.pollBackoffTimer = this.timers.timeout(() => {
7797
+ this.pollBackoffTimer = void 0;
7798
+ this.schedulePoll();
7799
+ }, delay);
7800
+ }
7801
+ /** Drop a pending retry; the next regular tick takes over. */
7802
+ clearPollBackoff() {
7803
+ this.pollBackoffTimer?.();
7804
+ this.pollBackoffTimer = void 0;
7805
+ }
7806
+ /**
6061
7807
  * Re-arm from the ledger's current targets. Callers that just changed a
6062
7808
  * schedule (the host routes, the agent tools) invoke this after the write
6063
7809
  * commits, so a new or edited trigger arms without waiting for the next fire.
@@ -6314,6 +8060,64 @@ async function parseTaskDraft(llm, request, signal) {
6314
8060
  signal?.removeEventListener("abort", abortFromCaller);
6315
8061
  }
6316
8062
  }
8063
+ /**
8064
+ * Total ceiling of one acceptance attempt, covering evidence rendering and
8065
+ * every judge call including retries. Derived from the per-call default rather
8066
+ * than pinned, because a total budget smaller than
8067
+ * {@link ACCEPTANCE_JUDGE_CALLS} times the per-call ceiling guarantees that no
8068
+ * acceptance can ever finish.
8069
+ */
8070
+ const DEFAULT_VERIFICATION_BUDGET_SECONDS = 150 * (CODING_CRITERIA.length * 2 + 2);
8071
+ /** Longest accepted total budget, thirty minutes. */
8072
+ const VERIFICATION_BUDGET_MAX_SECONDS = 1800;
8073
+ /** The default total budget in milliseconds, the face the abort timer takes. */
8074
+ const DEFAULT_VERIFICATION_BUDGET_MS = DEFAULT_VERIFICATION_BUDGET_SECONDS * 1e3;
8075
+ /**
8076
+ * Clamp a configured ceiling into the supported range. A value the schema
8077
+ * would refuse (non-finite, below the floor, above the ceiling) still leaves
8078
+ * the board judging: an out-of-range setting never disables acceptance.
8079
+ * @param value - configured seconds, or undefined for the default.
8080
+ * @param fallback - the default to use when nothing usable was configured.
8081
+ * @param min - shortest accepted value.
8082
+ * @param max - longest accepted value.
8083
+ * @returns the effective value in seconds.
8084
+ */
8085
+ function clampSeconds(value, fallback, min, max) {
8086
+ if (value === void 0 || !Number.isFinite(value)) return fallback;
8087
+ return Math.min(Math.max(Math.trunc(value), min), max);
8088
+ }
8089
+ /**
8090
+ * The effective per-judge-call ceiling.
8091
+ * @param seconds - configured seconds, or undefined for the default.
8092
+ * @returns the effective ceiling in seconds.
8093
+ */
8094
+ function normalizeVerificationCallTimeoutSeconds(seconds) {
8095
+ return clampSeconds(seconds, 150, 30, 600);
8096
+ }
8097
+ /**
8098
+ * The effective per-judge-call ceiling the timer face takes.
8099
+ * @param seconds - configured seconds, or undefined for the default.
8100
+ * @returns the effective ceiling in milliseconds.
8101
+ */
8102
+ function verificationCallTimeoutMs(seconds) {
8103
+ return normalizeVerificationCallTimeoutSeconds(seconds) * 1e3;
8104
+ }
8105
+ /**
8106
+ * The effective total budget of one acceptance attempt.
8107
+ * @param seconds - configured seconds, or undefined for the default.
8108
+ * @returns the effective budget in seconds.
8109
+ */
8110
+ function normalizeVerificationBudgetSeconds(seconds) {
8111
+ return clampSeconds(seconds, DEFAULT_VERIFICATION_BUDGET_SECONDS, 120, VERIFICATION_BUDGET_MAX_SECONDS);
8112
+ }
8113
+ /**
8114
+ * The effective total budget the abort face takes.
8115
+ * @param seconds - configured seconds, or undefined for the default.
8116
+ * @returns the effective budget in milliseconds.
8117
+ */
8118
+ function verificationBudgetMs(seconds) {
8119
+ return normalizeVerificationBudgetSeconds(seconds) * 1e3;
8120
+ }
6317
8121
  //#endregion
6318
8122
  //#region src/host/agent-tools.ts
6319
8123
  /** Registered tool names, in registration order. */
@@ -6364,10 +8168,18 @@ function callingSessionId(exec) {
6364
8168
  function scheduleView(schedule) {
6365
8169
  return {
6366
8170
  enabled: schedule.enabled,
6367
- cron: schedule.cron,
8171
+ mode: schedule.mode,
8172
+ ...schedule.cron === void 0 ? {} : { cron: schedule.cron },
8173
+ ...schedule.at === void 0 ? {} : { at: schedule.at },
6368
8174
  ...schedule.timeZone === void 0 ? {} : { timeZone: schedule.timeZone },
6369
8175
  ...schedule.nextRunAt === void 0 ? {} : { nextRunAt: schedule.nextRunAt },
6370
- ...schedule.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }
8176
+ ...schedule.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: schedule.lastTriggeredAt },
8177
+ runCount: schedule.runCount,
8178
+ ...schedule.maxRuns === void 0 ? {} : { maxRuns: schedule.maxRuns },
8179
+ ...schedule.endedAt === void 0 ? {} : { endedAt: schedule.endedAt },
8180
+ ...schedule.endedReason === void 0 ? {} : { endedReason: schedule.endedReason },
8181
+ ...schedule.skippedAt === void 0 ? {} : { skippedAt: schedule.skippedAt },
8182
+ ...schedule.skippedReason === void 0 ? {} : { skippedReason: schedule.skippedReason }
6371
8183
  };
6372
8184
  }
6373
8185
  function tagView(tag) {
@@ -6385,6 +8197,7 @@ function executionView(execution) {
6385
8197
  ...execution.result === void 0 ? {} : { result: execution.result },
6386
8198
  ...execution.error === void 0 ? {} : { error: execution.error },
6387
8199
  ...execution.initiatedBy === void 0 ? {} : { initiatedBy: execution.initiatedBy },
8200
+ ...execution.external === true ? { external: true } : {},
6388
8201
  ...execution.runGroupId === void 0 ? {} : { cascade: true },
6389
8202
  ...execution.ownResult === void 0 ? {} : {
6390
8203
  ownResult: execution.ownResult,
@@ -6413,9 +8226,10 @@ function taskSummary(task, tasks, sessionDefault) {
6413
8226
  ...task.mode === void 0 ? {} : { mode: task.mode },
6414
8227
  ...task.permission === void 0 ? {} : { permission: task.permission },
6415
8228
  ...task.model === void 0 ? {} : { model: task.model },
8229
+ ...task.reasoningEffort === void 0 ? {} : { reasoningEffort: task.reasoningEffort },
6416
8230
  ...task.reuseSession === true ? { reuseSession: true } : {},
6417
8231
  ...task.teamRun === true ? { teamRun: true } : {},
6418
- ...task.goalRun === false ? { goalRun: false } : {},
8232
+ ...task.goalRun === true ? { goalRun: true } : {},
6419
8233
  ...task.skipVerification === true ? { skipVerification: true } : {},
6420
8234
  ...task.schedule === void 0 ? {} : { schedule: scheduleView(task.schedule) },
6421
8235
  ...task.freeze === void 0 ? {} : { continuationCard: true },
@@ -6510,7 +8324,7 @@ function buildSetParentTool(host) {
6510
8324
  "Attach an existing task under a parent (making it a subtask) or detach it back to the root board by setting its parentId.",
6511
8325
  "Pass an empty parentId to detach.",
6512
8326
  "The Host refuses the link when the parent is unknown or archived, when the moved task is one of its own descendants, when the resulting depth exceeds the deployment subtask limit (default one level, maximum three), or while the task has an unsettled execution (its link may still be a running cascade parent pointer).",
6513
- "Running a task runs its whole subtree concurrently; a parent card settles only after its own turn and every direct subtask settle."
8327
+ "Running a task opens one run group over its subtree concurrently and leaves a subtask that already sits in the done column alone; a parent card settles only after its own turn and every direct subtask it opened have settled."
6514
8328
  ].join(" "),
6515
8329
  parameters: {
6516
8330
  taskId: {
@@ -6562,7 +8376,7 @@ function buildRunTool(host) {
6562
8376
  taskId: {
6563
8377
  type: "string",
6564
8378
  required: true,
6565
- description: "The task to run; running a root task runs its whole subtree."
8379
+ description: "The task to run; running a root task runs its whole subtree, minus the subtasks that are already in the done column."
6566
8380
  },
6567
8381
  rerun: {
6568
8382
  type: "boolean",
@@ -6610,7 +8424,9 @@ function buildManageTool(host) {
6610
8424
  "Move, archive, restore, delete, or settle one task board card.",
6611
8425
  "move-backlog, move-todo, move-running, move-done and move-failed are the manual column moves and cover every column; a card the runner is executing (one with an open execution) cannot be moved.",
6612
8426
  "A manual move writes the card column only and never fabricates an execution record, so the card reports a declaration rather than evidence of a run: done/failed declare work finished (or failed) outside a Host-run execution — human work, an external system, a decision made elsewhere — and running says the work is under way without a tracked session. Use task_board_run when the work should actually run in a session here.",
8427
+ "record-external-outcome is the record-backed version of move-done and move-failed: use it when YOU, or another agent outside this Host, actually finished or failed the card with your own model, so the terminal column and the run history both derive from one execution record. It takes result (succeeded or failed) and initiatedBy (who completed it, for example your own agent name) plus an optional summary, and it is refused while the card still has an open execution: the Host stays the only authority over a run it is executing. It cannot record running or cancelled — those remain Host-only.",
6613
8428
  "settle force-closes the open execution of a card the board can no longer observe (a stuck running card) and records it cancelled with the caller as the reason, so the card returns to the todo column and can be run again.",
8429
+ "reset-verification clears the acceptance anomalies (timeouts, authentication failures, an unresolvable judge route, or an attempt the time budget ended) recorded on a card open execution, so the next update_goal(action: complete) runs a fresh acceptance. It is refused unless that execution really carries such anomalies, it never clears a quality verdict, and a spent anomaly budget otherwise HOLDS the card open instead of failing it: tell the user the acceptance environment is unusable and that this action is how they clear it once they have fixed it.",
6614
8430
  "archive takes the whole subtask tree off the board and is refused while any member has an unsettled execution; restore brings the task, its ancestors and its subtree back; delete removes one card and is refused while it still has subtasks (detach or delete them first) or while it runs.",
6615
8431
  "It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI."
6616
8432
  ].join(" "),
@@ -6632,9 +8448,24 @@ function buildManageTool(host) {
6632
8448
  "archive",
6633
8449
  "restore",
6634
8450
  "delete",
6635
- "settle"
8451
+ "settle",
8452
+ "reset-verification",
8453
+ "record-external-outcome"
6636
8454
  ],
6637
8455
  description: "The lifecycle operation to perform."
8456
+ },
8457
+ result: {
8458
+ type: "string",
8459
+ enum: ["succeeded", "failed"],
8460
+ description: "record-external-outcome only: the verdict the outside agent actually reached."
8461
+ },
8462
+ initiatedBy: {
8463
+ type: "string",
8464
+ description: "record-external-outcome only: who finished the work outside this Host (for example your own agent name)."
8465
+ },
8466
+ summary: {
8467
+ type: "string",
8468
+ description: "record-external-outcome only: optional free-text note stored with the recorded outcome."
6638
8469
  }
6639
8470
  },
6640
8471
  output: {
@@ -6643,6 +8474,31 @@ function buildManageTool(host) {
6643
8474
  },
6644
8475
  async execute(args, exec) {
6645
8476
  const initiator = callingSessionId(exec);
8477
+ if (args.action === "record-external-outcome") {
8478
+ const result = args.result;
8479
+ const by = typeof args.initiatedBy === "string" ? args.initiatedBy.trim() : "";
8480
+ if (result !== "succeeded" && result !== "failed") return refused("missing-result", "record-external-outcome needs result=succeeded or result=failed");
8481
+ if (by === "") return refused("missing-initiator", "record-external-outcome needs initiatedBy naming who completed the work outside this Host");
8482
+ if (!host.snapshot().tasks.some((task) => task.id === args.taskId)) return refused("task-not-found", "no task with id " + args.taskId);
8483
+ try {
8484
+ const snapshot = await host.apply(crypto.randomUUID(), {
8485
+ kind: "record-external-outcome",
8486
+ taskId: args.taskId,
8487
+ result,
8488
+ initiatedBy: by,
8489
+ ...typeof args.summary === "string" && args.summary.trim() !== "" ? { summary: args.summary } : {}
8490
+ }, initiator);
8491
+ const task = snapshot.tasks.find((item) => item.id === args.taskId);
8492
+ return json({
8493
+ ok: true,
8494
+ action: args.action,
8495
+ ...task === void 0 ? {} : { task: taskSummary(task, snapshot.tasks, snapshot.sessionDefaultPermission) },
8496
+ note: "The outcome is recorded as its own execution, so the terminal column and the run history agree. Read it back with task_board_get."
8497
+ });
8498
+ } catch (error) {
8499
+ return refused("refused", messageOf(error));
8500
+ }
8501
+ }
6646
8502
  const action = {
6647
8503
  "move-todo": {
6648
8504
  kind: "move",
@@ -6677,6 +8533,10 @@ function buildManageTool(host) {
6677
8533
  kind: "settle",
6678
8534
  taskId: args.taskId
6679
8535
  },
8536
+ "reset-verification": {
8537
+ kind: "reset-verification",
8538
+ taskId: args.taskId
8539
+ },
6680
8540
  restore: {
6681
8541
  kind: "restore",
6682
8542
  taskId: args.taskId
@@ -6712,11 +8572,12 @@ function buildScheduleTool(host) {
6712
8572
  return defineTool({
6713
8573
  name: "task_board_schedule",
6714
8574
  description: [
6715
- "Arm, change, or disarm a task scheduled runs (5-field cron; day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND).",
6716
- "The cron wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.",
6717
- "A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.",
6718
- "A schedule whose tree contains an unconfirmed above-default permission is refused and rolls to its next occurrence; the reason is reported in the list board summary as schedulerError.",
6719
- "Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence."
8575
+ "Arm, change, or disarm a task scheduled runs. Two kinds of plan exist: a recurring 5-field cron rule (day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND), and a one-shot that runs once at a future instant.",
8576
+ "For a one-shot pass mode \"once\" with at (the planned instant, ms since epoch UTC). For a recurring rule pass mode \"cron\" (or omit mode) with a cron expression, optionally capped by maxRuns (a positive whole number of scheduled executions; omit for unlimited, pass 0 to clear a cap).",
8577
+ "The wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.",
8578
+ "A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree minus the subtasks already in the done column.",
8579
+ "The scheduler counts executions it actually starts; once a rule reaches its run budget it stops itself in the ledger, and a manual run never consumes the budget. A schedule whose tree contains an unconfirmed above-default permission is refused and does not consume the budget; the reason is reported in the list board summary as schedulerError.",
8580
+ "Missed occurrences during Host downtime are skipped, never queued (a one-shot whose instant passed while the Host was down is stopped as missed); a task that is already running skips its occurrence without consuming the budget."
6720
8581
  ].join(" "),
6721
8582
  parameters: {
6722
8583
  taskId: {
@@ -6728,13 +8589,26 @@ function buildScheduleTool(host) {
6728
8589
  type: "boolean",
6729
8590
  description: "Arm (true) or disarm (false) the schedule."
6730
8591
  },
8592
+ mode: {
8593
+ type: "string",
8594
+ enum: ["cron", "once"],
8595
+ description: "The kind of plan: \"cron\" for a recurring rule, \"once\" for a single future instant. Omit to keep the stored kind."
8596
+ },
6731
8597
  cron: {
6732
8598
  type: "string",
6733
- description: "5-field cron expression: minute hour day-of-month month day-of-week."
8599
+ description: "5-field cron expression of a recurring rule: minute hour day-of-month month day-of-week."
8600
+ },
8601
+ at: {
8602
+ type: "integer",
8603
+ description: "Planned instant of a one-shot, in milliseconds since the Unix epoch (UTC). Must be in the future."
6734
8604
  },
6735
8605
  timeZone: {
6736
8606
  type: "string",
6737
- description: "IANA zone the cron wall clock is read in (for example Asia/Shanghai). Omit to keep the stored zone, or pass an empty string to clear it back to the Host zone."
8607
+ description: "IANA zone the wall clock is read in (for example Asia/Shanghai). Omit to keep the stored zone, or pass an empty string to clear it back to the Host zone."
8608
+ },
8609
+ maxRuns: {
8610
+ type: "integer",
8611
+ description: "Maximum scheduled executions of a recurring rule (positive whole number). Omit to keep the stored cap; pass 0 to clear it back to unlimited."
6738
8612
  }
6739
8613
  },
6740
8614
  output: {
@@ -6742,12 +8616,15 @@ function buildScheduleTool(host) {
6742
8616
  render: renderJson
6743
8617
  },
6744
8618
  async execute(args, exec) {
6745
- if (args.enabled === void 0 && args.cron === void 0 && args.timeZone === void 0) return refused("nothing-to-change", "pass enabled and/or cron and/or timeZone");
8619
+ if (args.enabled === void 0 && args.mode === void 0 && args.cron === void 0 && args.at === void 0 && args.timeZone === void 0 && args.maxRuns === void 0) return refused("nothing-to-change", "pass enabled, mode, cron, at, timeZone and/or maxRuns");
6746
8620
  if (!host.snapshot().tasks.some((task) => task.id === args.taskId)) return refused("task-not-found", "no task with id " + args.taskId);
6747
8621
  const patch = {
6748
8622
  ...args.enabled === void 0 ? {} : { enabled: args.enabled },
8623
+ ...args.mode === void 0 ? {} : { mode: args.mode },
6749
8624
  ...args.cron === void 0 ? {} : { cron: args.cron },
6750
- ...args.timeZone === void 0 ? {} : { timeZone: args.timeZone === "" ? null : args.timeZone }
8625
+ ...args.at === void 0 ? {} : { at: args.at },
8626
+ ...args.timeZone === void 0 ? {} : { timeZone: args.timeZone === "" ? null : args.timeZone },
8627
+ ...args.maxRuns === void 0 ? {} : { maxRuns: args.maxRuns <= 0 ? null : args.maxRuns }
6751
8628
  };
6752
8629
  try {
6753
8630
  const snapshot = await host.apply(crypto.randomUUID(), {
@@ -6879,7 +8756,7 @@ function buildCreateTool(host) {
6879
8756
  name: "task_board_create",
6880
8757
  description: [
6881
8758
  "Create a task on the DSH task board, or a subtask by passing parentId.",
6882
- "A subtask inherits every execution target it does not override (workspace, agent preset, permission, model) from its parent, and inherits the parent human permission confirmation together with an inherited binding.",
8759
+ "A subtask inherits every execution target it does not override (workspace, agent preset, permission, model, reasoning effort) from its parent, and inherits the parent human permission confirmation together with an inherited binding.",
6883
8760
  "The deployment caps subtask depth (default one level, maximum three): a subtask of a subtask is refused unless the board is configured deeper.",
6884
8761
  "The task lands in the todo column and does NOT run; call task_board_run to execute it."
6885
8762
  ].join(" "),
@@ -6907,16 +8784,20 @@ function buildCreateTool(host) {
6907
8784
  },
6908
8785
  mode: {
6909
8786
  type: "string",
6910
- description: "Agent preset id the execution session is composed from; omit for the deployment default or the parent value."
8787
+ description: "Agent preset id the execution session is composed from; omit to adopt the board new-card default, and the deployment default when none is configured."
6911
8788
  },
6912
8789
  permission: {
6913
8790
  type: "string",
6914
8791
  enum: [...TASK_PERMISSIONS],
6915
- description: "Permission preset for the execution session. Omit to inherit the parent binding; a value above the session default needs a human confirmation in the board UI before the card can run."
8792
+ description: "Permission preset for the execution session; omit to adopt the board new-card default. An omitted permission is never taken from a parent: the launch resolves the parent binding instead. A value above the session default needs a human confirmation in the board UI before the card can run."
6916
8793
  },
6917
8794
  model: {
6918
8795
  type: "string",
6919
- description: "Pinned model as provider/model (or a model id); omit for the host default or the parent value."
8796
+ description: "Pinned model as provider/model (or a model id); omit to adopt the board new-card default, and the host default when none is configured."
8797
+ },
8798
+ reasoningEffort: {
8799
+ type: "string",
8800
+ description: "Reasoning effort id for that model (an adapter-owned level such as \"high\"); omit to adopt the board new-card default for that model, and the model own default when none applies. Only an effort the target model declares is sent; an empty string clears it back to the default."
6920
8801
  },
6921
8802
  reuseSession: {
6922
8803
  type: "boolean",
@@ -6928,7 +8809,7 @@ function buildCreateTool(host) {
6928
8809
  },
6929
8810
  goalRun: {
6930
8811
  type: "boolean",
6931
- description: "Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Default true (omit to keep it); pass false for a single plain turn."
8812
+ description: "Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Off by default: omit (or pass false) for a single plain turn, and pass true to opt this card in. It only takes effect while the deployment global native /goal switch is on."
6932
8813
  },
6933
8814
  skipVerification: {
6934
8815
  type: "boolean",
@@ -6956,17 +8837,29 @@ function buildCreateTool(host) {
6956
8837
  schedule: {
6957
8838
  type: "object",
6958
8839
  additionalProperties: false,
6959
- description: "Arm a 5-field cron schedule at creation time (Host local time zone).",
8840
+ description: "Arm a schedule at creation time. Use mode \"once\" with at for a single future run, or mode \"cron\" (or omit mode) with a 5-field cron expression, optionally capped by maxRuns.",
6960
8841
  properties: {
6961
8842
  enabled: {
6962
8843
  type: "boolean",
6963
8844
  required: true,
6964
8845
  description: "Whether the schedule is armed."
6965
8846
  },
8847
+ mode: {
8848
+ type: "string",
8849
+ enum: ["cron", "once"],
8850
+ description: "The kind of plan: \"cron\" for a recurring rule, \"once\" for a single future instant."
8851
+ },
6966
8852
  cron: {
6967
8853
  type: "string",
6968
- required: true,
6969
- description: "5-field cron: minute hour day-of-month month day-of-week."
8854
+ description: "5-field cron of a recurring rule: minute hour day-of-month month day-of-week."
8855
+ },
8856
+ at: {
8857
+ type: "integer",
8858
+ description: "Planned instant of a one-shot, in milliseconds since the Unix epoch (UTC). Must be in the future."
8859
+ },
8860
+ maxRuns: {
8861
+ type: "integer",
8862
+ description: "Maximum scheduled executions of a recurring rule (positive whole number); omit for unlimited."
6970
8863
  }
6971
8864
  }
6972
8865
  }
@@ -6991,14 +8884,19 @@ function buildCreateTool(host) {
6991
8884
  ...args.mode === void 0 || args.mode === "" ? {} : { mode: args.mode },
6992
8885
  ...args.permission === void 0 ? {} : { permission: args.permission },
6993
8886
  ...args.model === void 0 || args.model === "" ? {} : { model: args.model },
8887
+ ...args.reasoningEffort === void 0 || args.reasoningEffort === "" ? {} : { reasoningEffort: args.reasoningEffort },
8888
+ applyCardDefaults: true,
6994
8889
  ...args.reuseSession === true ? { reuseSession: true } : {},
6995
8890
  ...args.teamRun === true ? { teamRun: true } : {},
6996
- ...args.goalRun === false ? { goalRun: false } : {},
8891
+ ...args.goalRun === true ? { goalRun: true } : {},
6997
8892
  ...args.skipVerification === true ? { skipVerification: true } : {},
6998
8893
  ...tags.length === 0 ? {} : { tags },
6999
8894
  ...args.schedule === void 0 ? {} : { schedule: {
7000
8895
  enabled: args.schedule.enabled,
7001
- cron: args.schedule.cron
8896
+ ...args.schedule.mode === void 0 ? {} : { mode: args.schedule.mode },
8897
+ ...args.schedule.cron === void 0 ? {} : { cron: args.schedule.cron },
8898
+ ...args.schedule.at === void 0 ? {} : { at: args.schedule.at },
8899
+ ...args.schedule.maxRuns === void 0 ? {} : { maxRuns: args.schedule.maxRuns }
7002
8900
  } }
7003
8901
  };
7004
8902
  try {
@@ -7023,7 +8921,7 @@ function buildUpdateTool(host) {
7023
8921
  return defineTool({
7024
8922
  name: "task_board_update",
7025
8923
  description: [
7026
- "Edit a task board card: title, description, prompt, labels, and the execution targets (workspace, agent preset, permission, model, session reuse).",
8924
+ "Edit a task board card: title, description, prompt, labels, and the execution targets (workspace, agent preset, permission, model, reasoning effort, session reuse).",
7027
8925
  "Only the fields you pass change; pass an empty string to clear a target, or an empty labels array to clear the labels.",
7028
8926
  "Title, description and prompt freeze once the task has started executing; the execution targets stay editable because they only affect future runs.",
7029
8927
  "Changing the pinned permission re-arms the permission confirmation gate."
@@ -7068,6 +8966,10 @@ function buildUpdateTool(host) {
7068
8966
  type: "string",
7069
8967
  description: "New pinned model; an empty string clears it."
7070
8968
  },
8969
+ reasoningEffort: {
8970
+ type: "string",
8971
+ description: "New reasoning effort id for the pinned model; an empty string clears it back to the model own default. Only an effort the target model declares is sent at run time."
8972
+ },
7071
8973
  reuseSession: {
7072
8974
  type: "boolean",
7073
8975
  description: "Continue later runs in the previous execution session."
@@ -7078,7 +8980,7 @@ function buildUpdateTool(host) {
7078
8980
  },
7079
8981
  goalRun: {
7080
8982
  type: "boolean",
7081
- description: "Whether each run starts with dsh built-in /goal (default true). Pass false to run one plain turn instead."
8983
+ description: "Whether each run starts with dsh built-in /goal (default false, one plain turn). Pass true to opt this card in, or false to return it to the default."
7082
8984
  },
7083
8985
  skipVerification: {
7084
8986
  type: "boolean",
@@ -7116,10 +9018,11 @@ function buildUpdateTool(host) {
7116
9018
  if (has(args, "workspaceId") && typeof args.workspaceId === "string") patch.workspaceId = args.workspaceId;
7117
9019
  if (has(args, "mode") && typeof args.mode === "string") patch.mode = args.mode;
7118
9020
  if (has(args, "model") && typeof args.model === "string") patch.model = args.model;
9021
+ if (has(args, "reasoningEffort") && typeof args.reasoningEffort === "string") patch.reasoningEffort = args.reasoningEffort;
7119
9022
  if (has(args, "permission") && typeof args.permission === "string") patch.permission = args.permission === "" ? void 0 : args.permission;
7120
9023
  if (has(args, "reuseSession")) patch.reuseSession = args.reuseSession === true;
7121
9024
  if (has(args, "teamRun")) patch.teamRun = args.teamRun === true;
7122
- if (has(args, "goalRun")) patch.goalRun = args.goalRun === false ? false : true;
9025
+ if (has(args, "goalRun")) patch.goalRun = args.goalRun === true ? true : false;
7123
9026
  if (has(args, "skipVerification")) patch.skipVerification = args.skipVerification === true ? true : false;
7124
9027
  if (has(args, "tags")) {
7125
9028
  const tags = args.tags ?? [];
@@ -7702,8 +9605,11 @@ function mountOnce(packageName, fn) {
7702
9605
  const VERIFICATION_MAX_TRACE_CHARS = 8e4;
7703
9606
  /** Largest single trace entry; one huge tool result must not eat the budget. */
7704
9607
  const VERIFICATION_MAX_ENTRY_CHARS = 4e3;
7705
- /** One judge request's ceiling and its retry budget. */
7706
- const VERIFICATION_CALL_TIMEOUT_MS = 12e4;
9608
+ /** Largest verbatim citation kept from one finding. */
9609
+ const VERIFICATION_MAX_QUOTE_CHARS = 600;
9610
+ /** Largest requirement/observation/gap statement kept from one finding. */
9611
+ const VERIFICATION_MAX_PROBLEM_CHARS = 400;
9612
+ const VERIFICATION_MAX_FINDINGS = 12;
7707
9613
  const VERIFICATION_MAX_TOKENS = 16384;
7708
9614
  const VERIFICATION_TEMPERATURE = .2;
7709
9615
  /** Redaction patterns, mirrored from the verifier's defaults. */
@@ -7858,8 +9764,29 @@ function collectEvidence(session, problem, startedAt) {
7858
9764
  hash
7859
9765
  };
7860
9766
  }
7861
- /** Parse the optional `<finding ...>` lines out of one judge answer. */
7862
- function parseFindings(text, criterion) {
9767
+ /** How one cited slot maps onto the caller's own evidence under a swapped round. */
9768
+ function locationOfSlot(slot, round) {
9769
+ return slot === workSlotOfRound(round) ? "trajectory" : "baseline";
9770
+ }
9771
+ /** Resolve one `location` (or legacy `evidence`) attribute into an evidence location. */
9772
+ function readLocation(raw, round) {
9773
+ const value = raw.trim().toUpperCase();
9774
+ if (value === "TASK") return "task";
9775
+ if (value === "CONTEXT" || value === "WORKSPACE") return "workspace";
9776
+ if (value === "TRAJECTORY_A" || value === "A") return locationOfSlot("A", round);
9777
+ if (value === "TRAJECTORY_B" || value === "B") return locationOfSlot("B", round);
9778
+ }
9779
+ /**
9780
+ * Parse the `<finding ...>` elements out of one judge answer.
9781
+ *
9782
+ * A finding must name the criterion this prompt offered and cite an evidence
9783
+ * location the prompt actually rendered; a finding for another criterion, a
9784
+ * citation of a location that was never offered, and an empty body are dropped
9785
+ * here rather than echoed back as if the judge had located something. The
9786
+ * quote is carried verbatim: whether it really occurs in the evidence it names
9787
+ * is decided by the caller, which is the only side holding the evidence text.
9788
+ */
9789
+ function parseFindings(text, criterion, round) {
7863
9790
  const pattern = /<finding\s+([^>]*)>([\s\S]*?)<\/finding>/giu;
7864
9791
  const attributes = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/gu;
7865
9792
  const found = [];
@@ -7869,25 +9796,35 @@ function parseFindings(text, criterion) {
7869
9796
  for (let attribute = attributes.exec(match[1] ?? ""); attribute !== null; attribute = attributes.exec(match[1] ?? "")) if ([
7870
9797
  "criterion",
7871
9798
  "evidence",
9799
+ "location",
9800
+ "requirement",
9801
+ "observation",
9802
+ "gap",
9803
+ "quote",
7872
9804
  "action"
7873
9805
  ].includes(attribute[1] ?? "")) attrs.set(attribute[1] ?? "", attribute[2] ?? "");
7874
9806
  const named = attrs.get("criterion") ?? "";
7875
9807
  if (named !== "" && named.toLowerCase() !== criterion.name.toLowerCase() && named.toLowerCase() !== criterion.id.toLowerCase()) continue;
7876
- const evidence = (attrs.get("evidence") ?? "").trim().toUpperCase();
7877
- if (![
7878
- "TASK",
7879
- "A",
7880
- "B"
7881
- ].includes(evidence)) continue;
9808
+ const location = readLocation(attrs.get("location") ?? attrs.get("evidence") ?? "", round);
9809
+ if (location === void 0) continue;
9810
+ const quote = (attrs.get("quote") ?? "").trim().slice(0, VERIFICATION_MAX_QUOTE_CHARS);
9811
+ const requirement = (attrs.get("requirement") ?? "").trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS);
9812
+ const observation = (attrs.get("observation") ?? "").trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS);
9813
+ const gap = (attrs.get("gap") ?? "").trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS);
7882
9814
  const body = (match[2] ?? "").trim().slice(0, 400);
7883
- if (body === "") continue;
9815
+ if (body === "" && quote === "") continue;
7884
9816
  const action = (attrs.get("action") ?? "").trim().slice(0, 300);
7885
- const key = named + "|" + evidence + "|" + body;
9817
+ const key = criterion.id + "|" + location + "|" + quote + "|" + body;
7886
9818
  if (seen.has(key)) continue;
7887
9819
  seen.add(key);
7888
9820
  found.push({
7889
- criterion: criterion.name,
7890
- evidence,
9821
+ criterionId: criterion.id,
9822
+ criterionName: criterion.name,
9823
+ location,
9824
+ quote,
9825
+ requirement,
9826
+ observation,
9827
+ gap,
7891
9828
  body,
7892
9829
  ...action === "" ? {} : { action }
7893
9830
  });
@@ -7896,20 +9833,31 @@ function parseFindings(text, criterion) {
7896
9833
  }
7897
9834
  /** Render one finding as the single feedback line the agent reads. */
7898
9835
  function renderFinding(finding) {
7899
- const locator = "[" + (finding.criterion ?? "finding") + " evidence=" + finding.evidence + "]";
9836
+ const locator = "[" + finding.criterionName + " " + finding.location + "]";
7900
9837
  const action = finding.action === void 0 ? "" : " → 建议核实:" + finding.action;
7901
- return locator + " " + finding.body + action;
9838
+ const detail = finding.body !== "" ? finding.body : finding.quote;
9839
+ return locator + " " + detail + action;
7902
9840
  }
7903
- /** One judge request's outcome. */
7904
- async function judgeOnce(llm, route, prompt, signal) {
9841
+ /**
9842
+ * One judge request's outcome.
9843
+ *
9844
+ * Every attempt is admitted against the acceptance budget first: a call whose
9845
+ * own ceiling no longer fits in the time left is refused before it is opened,
9846
+ * so a slow route cannot spend the whole acceptance budget on calls the outer
9847
+ * abort is going to kill anyway.
9848
+ */
9849
+ async function judgeOnce(llm, route, prompt, signal, budget) {
7905
9850
  const usage = emptyUsage();
7906
9851
  let lastError;
9852
+ /** The outer abort is the acceptance budget running out, not a cancellation. */
9853
+ const stopped = () => budget.now() >= budget.deadline ? new VerificationError("budget", "the acceptance budget ran out before the judge answered", usage) : new VerificationError("aborted", "the acceptance was cancelled", usage);
7907
9854
  for (let attempt = 0; attempt < 3; attempt++) {
7908
- if (signal.aborted) throw new VerificationError("aborted", "the acceptance was cancelled before the judge answer", usage);
9855
+ if (signal.aborted) throw stopped();
9856
+ if (budget.deadline - budget.now() <= budget.callTimeoutMs) throw new VerificationError("budget", "the acceptance budget cannot fit another judge call", usage);
7909
9857
  const timeout = new AbortController();
7910
9858
  const timer = setTimeout(() => {
7911
9859
  timeout.abort(/* @__PURE__ */ new Error("task-board verification: the judge request timed out"));
7912
- }, VERIFICATION_CALL_TIMEOUT_MS);
9860
+ }, budget.callTimeoutMs);
7913
9861
  const forward = () => {
7914
9862
  timeout.abort(signal.reason);
7915
9863
  };
@@ -7944,7 +9892,7 @@ async function judgeOnce(llm, route, prompt, signal) {
7944
9892
  usage.reasoningTokens += typeof reported.reasoningTokens === "number" ? reported.reasoningTokens : 0;
7945
9893
  } else if (row.type === "finish") finishReason = chunk.type === "finish" ? chunk.reason : void 0;
7946
9894
  }
7947
- if (signal.aborted) throw new VerificationError("aborted", "the acceptance was cancelled", usage);
9895
+ if (signal.aborted) throw stopped();
7948
9896
  if (timeout.signal.aborted) throw new VerificationError("timeout", "the judge request timed out", usage);
7949
9897
  if (finishReason?.kind === "error" || finishReason?.kind === "aborted") {
7950
9898
  const failure = finishReason.failure;
@@ -7970,8 +9918,8 @@ async function judgeOnce(llm, route, prompt, signal) {
7970
9918
  } catch (error) {
7971
9919
  lastError = error;
7972
9920
  if (error instanceof VerificationError && (error.kind === "aborted" || error.kind === "auth")) throw error;
7973
- if (signal.aborted) throw new VerificationError("aborted", "the acceptance was cancelled", usage);
7974
- if (timeout.signal.aborted) lastError = new VerificationError("timeout", "the judge request timed out after " + Math.round(VERIFICATION_CALL_TIMEOUT_MS / 1e3) + "s", usage);
9921
+ if (signal.aborted) throw stopped();
9922
+ if (timeout.signal.aborted) lastError = new VerificationError("timeout", "the judge request timed out after " + Math.round(budget.callTimeoutMs / 1e3) + "s", usage);
7975
9923
  const parseFailure = lastError instanceof VerificationError && lastError.kind === "parse";
7976
9924
  if (attempt + 1 >= 3 || !parseFailure && !retryable(lastError)) break;
7977
9925
  if (!parseFailure) usage.usageIncomplete = true;
@@ -7992,11 +9940,17 @@ async function judgeOnce(llm, route, prompt, signal) {
7992
9940
  * Run one acceptance: every criterion is judged twice, the odd round with the
7993
9941
  * A/B slots swapped, and the per-round scores are mapped back and averaged per
7994
9942
  * criterion.
7995
- * @param input - the judge route, evidence, threshold and cancellation.
7996
- * @returns the recorded attempt (quality verdict or anomaly).
9943
+ * @param input - the judge route, evidence, threshold, time budget and cancellation.
9944
+ * @returns the recorded attempt (quality verdict, anomaly, or budget stop).
7997
9945
  */
7998
9946
  async function runAcceptance(input) {
7999
- const startedAt = (input.now ?? Date.now)();
9947
+ const now = input.now ?? Date.now;
9948
+ const startedAt = now();
9949
+ const budget = {
9950
+ deadline: input.deadline ?? startedAt + DEFAULT_VERIFICATION_BUDGET_MS,
9951
+ callTimeoutMs: input.callTimeoutMs ?? 15e4,
9952
+ now
9953
+ };
8000
9954
  const usage = emptyUsage();
8001
9955
  const evidenceSummary = {
8002
9956
  chars: input.evidence.trace.length,
@@ -8009,6 +9963,13 @@ async function runAcceptance(input) {
8009
9963
  };
8010
9964
  const findings = [];
8011
9965
  const seenFindings = /* @__PURE__ */ new Set();
9966
+ const structured = [];
9967
+ const seenStructured = /* @__PURE__ */ new Set();
9968
+ const evidenceText = {
9969
+ task: input.evidence.problem,
9970
+ trajectory: input.evidence.trace,
9971
+ workspace: input.context ?? ""
9972
+ };
8012
9973
  try {
8013
9974
  const perCriterion = /* @__PURE__ */ new Map();
8014
9975
  for (const criterion of CODING_CRITERIA) perCriterion.set(criterion.id, {
@@ -8020,8 +9981,8 @@ async function runAcceptance(input) {
8020
9981
  const swapped = repeat % 2 === 1;
8021
9982
  const candidateA = swapped ? EMPTY_WORK_BASELINE : input.evidence.trace;
8022
9983
  const candidateB = swapped ? input.evidence.trace : EMPTY_WORK_BASELINE;
8023
- const prompt = buildAcceptancePrompt(input.evidence.problem, candidateA, candidateB, criterion, void 0, input.context);
8024
- const answer = await judgeOnce(input.llm, input.route, prompt, input.signal);
9984
+ const prompt = buildAcceptancePrompt(input.evidence.problem, candidateA, candidateB, criterion, void 0, input.context, workSlotOfRound(repeat));
9985
+ const answer = await judgeOnce(input.llm, input.route, prompt, input.signal, budget);
8025
9986
  const scoreA = extractScore(answer.text, "score_A");
8026
9987
  const scoreB = extractScore(answer.text, "score_B");
8027
9988
  if (answer.usage.usageIncomplete === true) usage.usageIncomplete = true;
@@ -8032,14 +9993,26 @@ async function runAcceptance(input) {
8032
9993
  const bucket = perCriterion.get(criterion.id);
8033
9994
  bucket.work.push(swapped ? scoreB : scoreA);
8034
9995
  bucket.baseline.push(swapped ? scoreA : scoreB);
8035
- for (const finding of parseFindings(answer.text, criterion)) {
8036
- const line = renderFinding(swapped && (finding.evidence === "A" || finding.evidence === "B") ? {
8037
- ...finding,
8038
- evidence: finding.evidence === "A" ? "B" : "A"
8039
- } : finding);
9996
+ for (const finding of parseFindings(answer.text, criterion, repeat)) {
9997
+ if (structured.length < VERIFICATION_MAX_FINDINGS) {
9998
+ const key = finding.criterionId + "|" + finding.location + "|" + finding.quote + "|" + finding.body;
9999
+ if (!seenStructured.has(key)) {
10000
+ seenStructured.add(key);
10001
+ structured.push({
10002
+ criterionId: finding.criterionId,
10003
+ requirement: finding.requirement !== "" ? finding.requirement : finding.criterionName,
10004
+ observation: finding.observation !== "" ? finding.observation : finding.body,
10005
+ gap: finding.gap !== "" ? finding.gap : finding.body,
10006
+ quote: finding.quote,
10007
+ location: finding.location,
10008
+ ...finding.action === void 0 ? {} : { action: finding.action }
10009
+ });
10010
+ }
10011
+ }
10012
+ const line = renderFinding(finding);
8040
10013
  if (seenFindings.has(line)) continue;
8041
10014
  seenFindings.add(line);
8042
- if (findings.length < 6) findings.push(line);
10015
+ if (findings.length < VERIFICATION_MAX_FINDINGS) findings.push(line);
8043
10016
  }
8044
10017
  }
8045
10018
  const criteria = CODING_CRITERIA.map((criterion) => {
@@ -8058,11 +10031,17 @@ async function runAcceptance(input) {
8058
10031
  const score = average(criteria.map((criterion) => criterion.score));
8059
10032
  const baseline = average(criteria.map((criterion) => criterion.baseline));
8060
10033
  const passed = acceptancePassed(score, baseline, criteria, input.threshold);
10034
+ const validity = passed ? { valid: true } : assessAcceptanceValidity({
10035
+ criteria,
10036
+ findings: structured,
10037
+ evidence: evidenceText,
10038
+ omittedCharacters: input.evidence.omittedCharacters
10039
+ });
8061
10040
  return { attempt: {
8062
10041
  index: input.index,
8063
10042
  at: startedAt,
8064
- stage: "quality",
8065
- passed,
10043
+ stage: validity.valid ? "quality" : "invalid",
10044
+ passed: validity.valid ? passed : false,
8066
10045
  score,
8067
10046
  baseline,
8068
10047
  criteria,
@@ -8071,7 +10050,12 @@ async function runAcceptance(input) {
8071
10050
  evidence: evidenceSummary,
8072
10051
  route: input.route,
8073
10052
  channel: "explicit-tag",
8074
- rounds: 2
10053
+ rounds: 2,
10054
+ ...structured.length === 0 ? {} : { criterionFindings: structured },
10055
+ ...validity.valid ? {} : {
10056
+ invalidReason: validity.reason,
10057
+ error: classifyInvalid(validity)
10058
+ }
8075
10059
  } };
8076
10060
  } catch (error) {
8077
10061
  const failure = error instanceof Error ? error : new Error(String(error));
@@ -8080,7 +10064,7 @@ async function runAcceptance(input) {
8080
10064
  return { attempt: {
8081
10065
  index: input.index,
8082
10066
  at: startedAt,
8083
- stage: "exception",
10067
+ stage: kind === "budget" ? "budget" : "exception",
8084
10068
  passed: false,
8085
10069
  score: 0,
8086
10070
  baseline: 0,
@@ -8110,6 +10094,16 @@ function mergeUsage(target, source) {
8110
10094
  ...target.usageIncomplete === true || source.usageIncomplete === true ? { usageIncomplete: true } : {}
8111
10095
  };
8112
10096
  }
10097
+ /** Prefix one invalid-acceptance message with its class so the report stays actionable. */
10098
+ function classifyInvalid(validity) {
10099
+ return {
10100
+ "missing-finding": "验收无效(未达标判据没有对应的结构化问题反馈)",
10101
+ "unlocatable-quote": "验收无效(问题反馈引用不存在于本次裁判所见的证据中)",
10102
+ "baseline-finding": "验收无效(问题反馈指向空工作基线而非本次工作)",
10103
+ "vacuous-finding": "验收无效(问题反馈空泛,未说明实际观测与差异)",
10104
+ "insufficient-evidence": "验收无效(裁判所见证据不足,无法支撑否决)"
10105
+ }[validity.reason] + ": " + validity.detail;
10106
+ }
8113
10107
  /** Prefix one anomaly message with its class so the report stays actionable. */
8114
10108
  function classifyMessage(kind, message) {
8115
10109
  return {
@@ -8118,7 +10112,8 @@ function classifyMessage(kind, message) {
8118
10112
  auth: "验收异常(鉴权失败)",
8119
10113
  parse: "验收异常(裁判回答无法解析)",
8120
10114
  request: "验收异常(裁判请求失败)",
8121
- aborted: "验收异常(验收被取消)"
10115
+ aborted: "验收异常(验收被取消)",
10116
+ budget: "验收未完成(时间预算耗尽,未产生任何判定,不计入异常额度)"
8122
10117
  }[kind] + ": " + message;
8123
10118
  }
8124
10119
  /** Character ceiling for one file's rendered comparison. */
@@ -8329,6 +10324,45 @@ function createGoalVerificationGate(deps) {
8329
10324
  blockGoal(agent, reason);
8330
10325
  return deny(bounded("[任务看板 · goal 验收判定失败] " + reason + "\n本次执行不会完成;请如实向用户报告验收未通过、已记录的分数与问题,不要再尝试标记完成。"), "TASK_BOARD_VERIFICATION_FAILED", reason);
8331
10326
  };
10327
+ /**
10328
+ * Hold the cycle open after its non-quality budget ran out.
10329
+ *
10330
+ * A judge route that times out, cannot authenticate or cannot be resolved is
10331
+ * the ENVIRONMENT failing, not the work, so this records the attempt and
10332
+ * refuses the completion claim without writing a `failedReason` and without
10333
+ * blocking the goal (issue #1828). The execution therefore survives for a
10334
+ * user who fixes the environment and clears the recorded anomalies, instead
10335
+ * of carrying a verdict that only the clock produced.
10336
+ */
10337
+ const hold = (taskId, executionId, verification, message, detail, code) => {
10338
+ write(taskId, executionId, {
10339
+ ...verification,
10340
+ inFlight: void 0
10341
+ });
10342
+ return deny(bounded(message), code, detail);
10343
+ };
10344
+ /** The refusal one recorded anomaly returns to the fixing agent. */
10345
+ const anomalyRefusal = (spend, error) => deny(bounded("[任务看板 · 验收异常] " + (error ?? "unknown") + "\n这是验收异常而非质量判定,本次未消耗质量验收额度(已用 " + exceptionAttempts(spend).length + "/2)。请修复环境后再次调用 update_goal(action: complete)。"), "TASK_BOARD_VERIFICATION_ANOMALY", error);
10346
+ /**
10347
+ * The refusal one recorded INVALID acceptance returns.
10348
+ *
10349
+ * The claim is still refused (no pass record exists), but the agent is told
10350
+ * the veto itself was unusable and is explicitly NOT asked to repair anything
10351
+ * on that basis. The reason and scores stay recorded for review; the quality
10352
+ * budget is untouched.
10353
+ */
10354
+ const invalidRefusal = (spend, attempt) => deny(bounded(invalidAcceptanceFeedback(attempt, 2 - invalidAttempts(spend).length)), "TASK_BOARD_VERIFICATION_INVALID", attempt.error);
10355
+ /**
10356
+ * Hold a cycle whose invalid-acceptance budget is spent.
10357
+ *
10358
+ * No usable veto was ever produced, so the board neither passes nor fails the
10359
+ * card: it stops calling the judge and leaves the execution for a human. Only
10360
+ * an explicit user action (or a rerun) resumes it, which is what bounds the
10361
+ * retries.
10362
+ */
10363
+ const invalidHeld = (taskId, executionId, spend) => hold(taskId, executionId, spend, "[任务看板 · 验收无效] " + invalidHoldReason(invalidAttempts(spend).length), "acceptance produced no usable veto; a user action must clear the recorded invalid acceptances", "TASK_BOARD_VERIFICATION_INVALID");
10364
+ /** The refusal a spent anomaly budget returns: no verdict, and no card failure. */
10365
+ const anomalyHeld = (taskId, executionId, spend) => hold(taskId, executionId, spend, "[任务看板 · 验收异常] 验收异常额度已用尽(2/2):本执行周期不再调用裁判模型,也不把这次执行判为失败。\n这是环境异常而非质量判定:请如实告诉用户验收环境当前不可用,由用户修复环境后用 task_board_manage(action=reset-verification) 清除已记录的异常,再重新完成目标。", "acceptance anomaly budget exhausted; a user action must clear the recorded anomalies", "TASK_BOARD_VERIFICATION_ANOMALY");
8332
10366
  /** Record one attempt and decide whether the completion claim may proceed. */
8333
10367
  const settle = async (input) => {
8334
10368
  const { task, execution, verification, agent, objective } = input;
@@ -8373,9 +10407,9 @@ function createGoalVerificationGate(deps) {
8373
10407
  attempts,
8374
10408
  inFlight: void 0
8375
10409
  };
8376
- if (exceptionAttempts(spend).length >= 2) return finalize(task.id, execution.id, spend, "验收异常达到上限:" + attempt.error, agent);
10410
+ if (exceptionAttempts(spend).length >= 2) return anomalyHeld(task.id, execution.id, spend);
8377
10411
  write(task.id, execution.id, spend);
8378
- return deny(bounded("[任务看板 · 验收异常] " + attempt.error + "\n这是验收异常而非质量判定,本次未消耗质量验收额度(已用 " + exceptionAttempts(spend).length + "/2)。请修复环境后再次调用 update_goal(action: complete)。"), "TASK_BOARD_VERIFICATION_ANOMALY", attempt.error);
10412
+ return anomalyRefusal(spend, attempt.error);
8379
10413
  }
8380
10414
  write(task.id, execution.id, baselines);
8381
10415
  const llm = deps.llm?.();
@@ -8411,13 +10445,16 @@ function createGoalVerificationGate(deps) {
8411
10445
  attempts: [...verification.attempts, attempt],
8412
10446
  inFlight: void 0
8413
10447
  };
8414
- if (exceptionAttempts(spend).length >= 2) return finalize(task.id, execution.id, spend, "验收异常达到上限:" + attempt.error, agent);
10448
+ if (exceptionAttempts(spend).length >= 2) return anomalyHeld(task.id, execution.id, spend);
8415
10449
  write(task.id, execution.id, spend);
8416
10450
  return deny(bounded("[任务看板 · 验收异常] " + attempt.error), "TASK_BOARD_VERIFICATION_ANOMALY", attempt.error);
8417
10451
  }
8418
10452
  const session = agent.session;
8419
10453
  const evidence = collectEvidence(session, objective, execution.startedAt);
8420
- const acceptance = AbortSignal.timeout(6e5);
10454
+ const callTimeout = deps.callTimeoutMs?.() ?? verificationCallTimeoutMs(void 0);
10455
+ const budget = deps.budgetMs?.() ?? DEFAULT_VERIFICATION_BUDGET_MS;
10456
+ const deadline = now() + budget;
10457
+ const acceptance = AbortSignal.timeout(budget);
8421
10458
  const source = deps.workspaceChanges?.();
8422
10459
  const workspace = source === void 0 ? {
8423
10460
  text: "",
@@ -8437,6 +10474,8 @@ function createGoalVerificationGate(deps) {
8437
10474
  context: workspace.text === "" ? void 0 : workspace.text,
8438
10475
  workspaceFiles: workspace.files,
8439
10476
  signal: acceptance,
10477
+ deadline,
10478
+ callTimeoutMs: callTimeout,
8440
10479
  now
8441
10480
  })).attempt;
8442
10481
  const attempts = [...verification.attempts, attempt];
@@ -8445,10 +10484,16 @@ function createGoalVerificationGate(deps) {
8445
10484
  attempts,
8446
10485
  inFlight: void 0
8447
10486
  };
10487
+ if (attempt.stage === "budget") return hold(task.id, execution.id, spend, "[任务看板 · 验收未完成] " + (attempt.error ?? "时间预算耗尽") + "\n本次没有产生任何质量判定,也不消耗质量或异常额度。请稍后重试;若裁判路由确实很慢,可调大验收的单次调用上限或总时间预算。", attempt.error ?? "the acceptance time budget ran out before a verdict", "TASK_BOARD_VERIFICATION_BUDGET");
8448
10488
  if (attempt.stage === "exception") {
8449
- if (exceptionAttempts(spend).length >= 2) return finalize(task.id, execution.id, spend, "验收异常达到上限:" + (attempt.error ?? "unknown"), agent);
10489
+ if (exceptionAttempts(spend).length >= 2) return anomalyHeld(task.id, execution.id, spend);
10490
+ write(task.id, execution.id, spend);
10491
+ return anomalyRefusal(spend, attempt.error);
10492
+ }
10493
+ if (attempt.stage === "invalid") {
10494
+ if (invalidAttempts(spend).length >= 2) return invalidHeld(task.id, execution.id, spend);
8450
10495
  write(task.id, execution.id, spend);
8451
- return deny(bounded("[任务看板 · 验收异常] " + (attempt.error ?? "unknown") + "\n这是验收异常而非质量判定,本次未消耗质量验收额度(已用 " + exceptionAttempts(spend).length + "/2)。请修复环境后再次调用 update_goal(action: complete)。"), "TASK_BOARD_VERIFICATION_ANOMALY", attempt.error);
10496
+ return invalidRefusal(spend, attempt);
8452
10497
  }
8453
10498
  if (attempt.passed) {
8454
10499
  write(task.id, execution.id, spend);
@@ -8457,7 +10502,7 @@ function createGoalVerificationGate(deps) {
8457
10502
  const used = qualityAttempts(spend).length;
8458
10503
  if (used >= 2) return finalize(task.id, execution.id, spend, finalFailureReason(attempt, used, verification.contract.threshold), agent);
8459
10504
  write(task.id, execution.id, spend);
8460
- return deny(bounded(qualityFeedback(attempt, 2 - used)), "TASK_BOARD_VERIFICATION_FAILED", attempt.findings.join("\n"));
10505
+ return deny(bounded(qualityFeedback(attempt, 2 - used)), "TASK_BOARD_VERIFICATION_FAILED", locatedFindingDetail(attempt));
8461
10506
  };
8462
10507
  /** Evaluate one tool call; undefined means "not this gate's business". */
8463
10508
  const evaluate = async (exec) => {
@@ -8472,7 +10517,9 @@ function createGoalVerificationGate(deps) {
8472
10517
  if (verification.applicability !== "enforced") return void 0;
8473
10518
  if (passedAttempt(verification) !== void 0) return { kind: "allow" };
8474
10519
  if (verification.failedReason !== void 0) return deny(bounded("[任务看板 · goal 验收判定失败] " + verification.failedReason + "\n本次执行已被判失败,不会再运行新的验收。请如实报告结果。"), "TASK_BOARD_VERIFICATION_FAILED", verification.failedReason);
8475
- if (!hasQualityBudget(verification) && !hasExceptionBudget(verification)) return finalize(binding.task.id, binding.execution.id, verification, "goal 验收额度已耗尽,本次 execution 判失败。", exec.agent);
10520
+ if (!hasInvalidBudget(verification)) return invalidHeld(binding.task.id, binding.execution.id, verification);
10521
+ if (!hasQualityBudget(verification)) return finalize(binding.task.id, binding.execution.id, verification, "goal 验收额度已耗尽,本次 execution 判失败。", exec.agent);
10522
+ if (!hasExceptionBudget(verification)) return anomalyHeld(binding.task.id, binding.execution.id, verification);
8476
10523
  const key = binding.execution.id;
8477
10524
  const running = inFlight.get(key);
8478
10525
  if (running !== void 0) return await running;
@@ -8514,6 +10561,17 @@ function createGoalVerificationGate(deps) {
8514
10561
  }
8515
10562
  };
8516
10563
  }
10564
+ /**
10565
+ * The structured detail of one quality refusal: the judge's own findings, in
10566
+ * the order the acceptance recorded them, so the denial carries exactly the
10567
+ * located evidence the report shows.
10568
+ */
10569
+ function locatedFindingDetail(attempt) {
10570
+ const lines = [];
10571
+ for (const finding of attempt.criterionFindings ?? []) lines.push("[" + finding.criterionId + " " + finding.location + "] \"" + finding.quote + "\"");
10572
+ if (lines.length === 0) lines.push(...attempt.findings);
10573
+ return lines.join("\n");
10574
+ }
8517
10575
  /** The bare execution prompt, used when the goal service carries no objective. */
8518
10576
  function taskPrompt(task) {
8519
10577
  return task.prompt.trim() !== "" ? task.prompt : task.title;
@@ -8547,14 +10605,29 @@ const Config = z.object({
8547
10605
  proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
8548
10606
  sessionDefaultPermission: z.union(TASK_PERMISSIONS),
8549
10607
  maxSubtaskDepth: z.number().min(1).max(3).default(1).volatile(),
10608
+ sessionPollSeconds: z.number().min(1).max(300).default(5).volatile(),
10609
+ runLaunchWidth: z.number().min(1).max(16).default(3).volatile(),
8550
10610
  teamProvider: z.string().min(1).default(DEFAULT_TEAM_PROVIDER),
10611
+ goalRunEnabled: z.boolean().default(false).volatile(),
10612
+ cardDefaultMode: z.string().default("").volatile(),
10613
+ cardDefaultPermission: z.union([...TASK_PERMISSIONS, ""]).default("").volatile(),
10614
+ cardDefaultModel: z.string().default("").volatile(),
10615
+ cardDefaultReasoningEffort: z.string().default("").volatile(),
8551
10616
  goalVerification: z.boolean().default(true).volatile(),
8552
10617
  goalVerificationModel: z.string().default("").volatile(),
8553
- goalVerificationReasoningEffort: z.string().default("").volatile()
10618
+ goalVerificationReasoningEffort: z.string().default("").volatile(),
10619
+ goalVerificationCallTimeoutSeconds: z.number().min(30).max(600).default(150).volatile(),
10620
+ goalVerificationBudgetSeconds: z.number().min(120).max(VERIFICATION_BUDGET_MAX_SECONDS).default(DEFAULT_VERIFICATION_BUDGET_SECONDS).volatile()
8554
10621
  });
8555
10622
  /** Schema default for the goal-acceptance switch, re-read for hand-built contexts. */
8556
10623
  const DEFAULT_GOAL_VERIFICATION = true;
8557
10624
  /**
10625
+ * Schema default for the GLOBAL native-/goal switch, re-read for hand-built
10626
+ * contexts. Deliberately false: a deployment that never configured it (and
10627
+ * every card written before the option existed) runs plain single turns.
10628
+ */
10629
+ const DEFAULT_GOAL_RUN = false;
10630
+ /**
8558
10631
  * Read one config field's current value.
8559
10632
  *
8560
10633
  * The Loader hands schema-volatile fields as stable references it commits in
@@ -8722,6 +10795,50 @@ function resolveLlmRuntime(ctx) {
8722
10795
  }
8723
10796
  }
8724
10797
  /**
10798
+ * Resolve the optional `sessionController` service. Like the tool registry and
10799
+ * the llm runtime, it is read dynamically so capture-only test contexts that do
10800
+ * not mount the full session controller still activate the board.
10801
+ * @param ctx - the plugin context.
10802
+ * @returns the session controller face, or undefined when unavailable.
10803
+ */
10804
+ function resolveSessionController(ctx) {
10805
+ try {
10806
+ const controller = ctx.get?.("sessionController");
10807
+ return controller !== void 0 && typeof controller.resolveAgent === "function" ? controller : void 0;
10808
+ } catch {
10809
+ return;
10810
+ }
10811
+ }
10812
+ /**
10813
+ * Resolve the live Agent for an execution session, resuming a cold persisted
10814
+ * session through `sessionController.resolveAgent` when it is not yet in
10815
+ * `ctx.agents` (issue #1861).
10816
+ *
10817
+ * A fresh run (`session/create`) or a session the browser is currently
10818
+ * following is already in `ctx.agents`, so the fast path returns immediately.
10819
+ * A scheduled or manual reuse of a cold session — one that `session/list`
10820
+ * reports as idle from disk without activating its Agent — is not in
10821
+ * `ctx.agents` when `pinAndPrompt` runs `/permission` ahead of
10822
+ * `session/selectModel` and `session/prompt`; delegating to
10823
+ * `sessionController.resolveAgent` resumes it with its recorded preset and
10824
+ * model-selection state, matching what those gateway endpoints do next.
10825
+ * @param ctx - the plugin context.
10826
+ * @param sessionId - the target execution session id.
10827
+ * @returns the live Agent for `sessionId`.
10828
+ */
10829
+ async function resolveExecutionAgent(ctx, sessionId) {
10830
+ const live = ctx.agents.get(sessionId);
10831
+ if (live !== void 0) return live;
10832
+ const controller = resolveSessionController(ctx);
10833
+ if (controller !== void 0) {
10834
+ const resolved = await controller.resolveAgent(sessionId);
10835
+ if ("agent" in resolved && resolved.agent !== void 0) return resolved.agent;
10836
+ const detail = "error" in resolved && typeof resolved.error?.message === "string" && resolved.error.message !== "" ? `: ${resolved.error.message}` : "";
10837
+ throw new Error(`execution session ${sessionId} is not available${detail}`);
10838
+ }
10839
+ throw new Error(`execution session ${sessionId} is not available`);
10840
+ }
10841
+ /**
8725
10842
  * Activate the board's host half: ledger, routes, cron scheduler, power
8726
10843
  * inhibitor, and the model-facing announcement.
8727
10844
  *
@@ -8744,6 +10861,14 @@ function applyImpl(ctx, config) {
8744
10861
  const preventIdleSleep = () => readConfigField(config?.preventIdleSleep, false);
8745
10862
  /** Current subtask depth limit (read live: the settings card edits it in place). */
8746
10863
  const maxSubtaskDepth = () => readConfigField(config?.maxSubtaskDepth, 1);
10864
+ /** Current roster-poll cadence in seconds (read live: the settings card edits it in place). */
10865
+ const sessionPollSeconds = () => readConfigField(config?.sessionPollSeconds, 5);
10866
+ /**
10867
+ * Current run-launch admission width. Read live on every admission, so the
10868
+ * settings card bounds the next launch of a run that is still queueing without
10869
+ * remounting the row; the Host clamps a hand-edited value into the schema range.
10870
+ */
10871
+ const runLaunchWidth = () => readConfigField(config?.runLaunchWidth, 3);
8747
10872
  /**
8748
10873
  * The baseline the permission confirmation gate judges against: the row's own
8749
10874
  * `sessionDefaultPermission` when the deployment pins one, otherwise the Host's
@@ -8758,27 +10883,59 @@ function applyImpl(ctx, config) {
8758
10883
  model: readConfigField(config?.goalVerificationModel, ""),
8759
10884
  reasoningEffort: readConfigField(config?.goalVerificationReasoningEffort, "")
8760
10885
  });
10886
+ /**
10887
+ * The acceptance time ceilings, read live: raising one must unblock a card
10888
+ * whose acceptance is stuck on a slow judge route without a remount, so
10889
+ * these deliberately are NOT frozen into the per-execution contract.
10890
+ */
10891
+ const verificationCallTimeoutMsLive = () => verificationCallTimeoutMs(readConfigField(config?.goalVerificationCallTimeoutSeconds, 150));
10892
+ const verificationBudgetMsLive = () => verificationBudgetMs(readConfigField(config?.goalVerificationBudgetSeconds, DEFAULT_VERIFICATION_BUDGET_SECONDS));
8761
10893
  let hostForCatalog;
8762
10894
  const verificationCatalog = async () => {
8763
10895
  const runner = hostForCatalog?.runner;
8764
10896
  return runner === void 0 ? void 0 : normalizeCatalog(await runner.modelCatalog());
8765
10897
  };
10898
+ /**
10899
+ * Live reader of the GLOBAL native-/goal switch. The service calls it exactly
10900
+ * once per execution and carries the value, so a settings edit only reaches
10901
+ * executions that start afterwards.
10902
+ */
10903
+ const goalRunEnabled = () => readConfigField(config?.goalRunEnabled, false);
10904
+ /**
10905
+ * Live reader of the new-card defaults, read at every use like the switch
10906
+ * above: the create dialog prefills from the published snapshot, and each
10907
+ * creating session re-reads them when it submits the create. A blank value
10908
+ * means "not set" and reaches the resolver as an absent target.
10909
+ */
10910
+ const cardDefaults = () => {
10911
+ const permission = readConfigField(config?.cardDefaultPermission, "");
10912
+ return {
10913
+ mode: readConfigField(config?.cardDefaultMode, "").trim() || void 0,
10914
+ permission: isTaskPermission(permission) ? permission : void 0,
10915
+ model: readConfigField(config?.cardDefaultModel, "").trim() || void 0,
10916
+ reasoningEffort: readConfigField(config?.cardDefaultReasoningEffort, "").trim() || void 0
10917
+ };
10918
+ };
10919
+ const ownerPeer = createOwnerPeer(ctx);
8766
10920
  const host = new TaskBoardHostService(ctx.typertGateway, {
8767
10921
  workspaceRegistry: ctx.workspaceRegistry,
10922
+ ownerPeer,
8768
10923
  sessionDefaultPermission,
8769
10924
  maxSubtaskDepth: maxSubtaskDepth(),
10925
+ goalRunEnabled,
10926
+ runLaunchWidth,
10927
+ cardDefaults,
8770
10928
  verificationSettings,
8771
10929
  verificationCatalog,
8772
10930
  timers: resolveHostTimers(ctx),
8773
10931
  team: buildTeamDispatcher(ctx, config?.teamProvider ?? "spawn"),
8774
10932
  commandDispatcher: { async execute(sessionId, line, signal) {
8775
- const agent = ctx.agents.get(sessionId);
8776
- if (agent === void 0) throw new Error(`execution session ${sessionId} is not available`);
10933
+ const agent = await resolveExecutionAgent(ctx, sessionId);
8777
10934
  return (await ctx.commands.execute(agent, line, [], signal))?.result;
8778
10935
  } }
8779
10936
  });
8780
10937
  hostForCatalog = host;
8781
- host.setConfiguration(enabled(), preventIdleSleep());
10938
+ host.setConfiguration(enabled(), preventIdleSleep(), sessionPollSeconds());
8782
10939
  host.start();
8783
10940
  if (typeof ctx.provide === "function") ctx.provide(TASK_BOARD_SERVICE_NAME, {
8784
10941
  registerExtension: (extension) => host.registerExtension(extension),
@@ -8824,6 +10981,8 @@ function applyImpl(ctx, config) {
8824
10981
  }
8825
10982
  },
8826
10983
  workspaceChanges: () => probeWorkspaceChanges(ctx),
10984
+ callTimeoutMs: verificationCallTimeoutMsLive,
10985
+ budgetMs: verificationBudgetMsLive,
8827
10986
  logger: { warn: (message, ...rest) => {
8828
10987
  const logger = ctx.logger;
8829
10988
  if (typeof logger?.warn === "function") logger.warn(message, ...rest);
@@ -8870,12 +11029,13 @@ function applyImpl(ctx, config) {
8870
11029
  enabled: enabled(),
8871
11030
  announceToAgent: announceToAgent(),
8872
11031
  preventIdleSleep: preventIdleSleep(),
8873
- maxSubtaskDepth: maxSubtaskDepth()
11032
+ maxSubtaskDepth: maxSubtaskDepth(),
11033
+ sessionPollSeconds: sessionPollSeconds()
8874
11034
  };
8875
- if (applied !== void 0 && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep && applied.maxSubtaskDepth === next.maxSubtaskDepth) return;
11035
+ if (applied !== void 0 && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep && applied.maxSubtaskDepth === next.maxSubtaskDepth && applied.sessionPollSeconds === next.sessionPollSeconds) return;
8876
11036
  applied = next;
8877
11037
  host.ledger.setMaxSubtaskDepth(next.maxSubtaskDepth);
8878
- host.setConfiguration(next.enabled, next.preventIdleSleep);
11038
+ host.setConfiguration(next.enabled, next.preventIdleSleep, next.sessionPollSeconds);
8879
11039
  setToolsEnabled(next.enabled);
8880
11040
  if (disposeSection !== void 0) {
8881
11041
  disposeSection();
@@ -8894,4 +11054,4 @@ function applyImpl(ctx, config) {
8894
11054
  sync();
8895
11055
  }
8896
11056
  //#endregion
8897
- export { Config, DEFAULT_GOAL_VERIFICATION, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveHostDefaultPermission, resolveHostTimers, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
11057
+ export { Config, DEFAULT_GOAL_RUN, DEFAULT_GOAL_VERIFICATION, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveExecutionAgent, resolveHostDefaultPermission, resolveHostTimers, resolveLlmRuntime, resolveProxyAccess, resolveSessionController, resolveToolRegistry };