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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +20 -12
  3. package/README.zh.md +20 -12
  4. package/lib/client.js +1327 -229
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1909 -348
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
  8. package/lib/types/client/index.d.ts +4 -5
  9. package/lib/types/client/locales.d.ts +65 -0
  10. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  11. package/lib/types/client/schedule-zone.d.ts +50 -0
  12. package/lib/types/core/controller.d.ts +10 -11
  13. package/lib/types/core/poll-cadence.d.ts +31 -0
  14. package/lib/types/core/tasks.d.ts +144 -14
  15. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  16. package/lib/types/core/verification-budget.d.ts +52 -0
  17. package/lib/types/core/verification.d.ts +376 -9
  18. package/lib/types/host/verification-gate.d.ts +24 -0
  19. package/lib/types/host/verification-runner.d.ts +38 -7
  20. package/lib/types/host-ledger.d.ts +82 -7
  21. package/lib/types/host-runner.d.ts +32 -9
  22. package/lib/types/host-service.d.ts +117 -9
  23. package/lib/types/index.d.ts +54 -0
  24. package/lib/types/protocol.d.ts +50 -12
  25. package/package.json +2 -6
  26. package/src/client/TaskBoardSettingsCard.tsx +74 -1
  27. package/src/client/board/NewTaskModal.tsx +151 -20
  28. package/src/client/board/TaskCard.tsx +3 -3
  29. package/src/client/board/TaskDetail.tsx +219 -18
  30. package/src/client/board/VerificationReport.tsx +132 -13
  31. package/src/client/board/status-key.ts +3 -0
  32. package/src/client/index.ts +43 -50
  33. package/src/client/locales.ts +137 -4
  34. package/src/client/plugin-card-seat.ts +7 -0
  35. package/src/client/schedule-zone.ts +100 -2
  36. package/src/core/controller.ts +12 -8
  37. package/src/core/poll-cadence.ts +41 -0
  38. package/src/core/store.ts +64 -15
  39. package/src/core/tasks.ts +193 -39
  40. package/src/core/use-cases/task-create.ts +30 -9
  41. package/src/core/use-cases/task-schedule.ts +190 -25
  42. package/src/core/use-cases/task-update.ts +3 -3
  43. package/src/core/verification-budget.ts +115 -0
  44. package/src/core/verification.ts +785 -17
  45. package/src/host/agent-tools.ts +106 -21
  46. package/src/host/verification-gate.ts +172 -16
  47. package/src/host/verification-runner.ts +207 -40
  48. package/src/host-ledger.ts +328 -42
  49. package/src/host-runner.ts +52 -19
  50. package/src/host-service.ts +299 -36
  51. package/src/index.ts +101 -5
  52. package/src/protocol.ts +132 -28
  53. package/lib/invariant.js +0 -6
  54. package/lib/types/client/board-card-seat.d.ts +0 -34
  55. package/lib/types/invariant.d.ts +0 -3
  56. package/src/client/board-card-seat.ts +0 -102
  57. package/src/invariant.ts +0 -4
package/lib/index.js CHANGED
@@ -7,6 +7,93 @@ import { homedir } from "node:os";
7
7
  import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
8
8
  import { ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
9
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
+ /**
11
+ * Clamp a configured cadence into the supported range. A value the schema
12
+ * would refuse (non-finite, below the floor, above the ceiling) still leaves
13
+ * the board polling: an out-of-range setting never disables the timer.
14
+ * @param seconds - configured cadence in seconds, or undefined for the default.
15
+ * @returns the effective cadence in seconds.
16
+ */
17
+ function normalizeSessionPollSeconds(seconds) {
18
+ if (seconds === void 0 || !Number.isFinite(seconds)) return 5;
19
+ return Math.min(Math.max(Math.trunc(seconds), 1), 300);
20
+ }
21
+ /**
22
+ * The cadence in the milliseconds the Host timer face takes.
23
+ * @param seconds - configured cadence in seconds, or undefined for the default.
24
+ * @returns the effective cadence in milliseconds.
25
+ */
26
+ function sessionPollMs(seconds) {
27
+ return normalizeSessionPollSeconds(seconds) * 1e3;
28
+ }
29
+ //#endregion
30
+ //#region src/core/session-reuse.ts
31
+ /**
32
+ * Pick the session a new execution of this task may continue in, or undefined
33
+ * to mint a fresh conversation. Reuse requires ALL of:
34
+ * - the task opted in (`reuseSession === true`);
35
+ * - the task's newest SETTLED execution carries a session id (issue #1587: the
36
+ * launch path calls this after `startExecution` appended this run's own open
37
+ * record, so reading the array tail always found an unsettled row and reuse
38
+ * never happened);
39
+ * - the roster is known and that session is present and idle.
40
+ *
41
+ * An unknown roster (session/list unavailable) never reuses: minting a fresh
42
+ * conversation is always safe, prompting into a session we cannot see is not.
43
+ * @param task - the task about to run.
44
+ * @param idleSessionIds - ids the last roster saw as present and not running;
45
+ * undefined when that roster is unknown.
46
+ * @returns the session id to continue in, or undefined for a fresh session.
47
+ */
48
+ function reusableSessionId(task, idleSessionIds) {
49
+ if (task.reuseSession !== true || idleSessionIds === void 0) return void 0;
50
+ let last;
51
+ for (let index = task.executions.length - 1; index >= 0; index -= 1) {
52
+ const candidate = task.executions[index];
53
+ if (candidate.sessionId !== void 0 && candidate.endedAt !== void 0) {
54
+ last = candidate;
55
+ break;
56
+ }
57
+ }
58
+ if (last === void 0 || last.sessionId === void 0) return void 0;
59
+ return idleSessionIds.has(last.sessionId) ? last.sessionId : void 0;
60
+ }
61
+ //#endregion
62
+ //#region src/dsh-home.ts
63
+ /**
64
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
65
+ * environment override wins, the platform home fallback follows. Mirrors
66
+ * what dsh-pet and dsh-liangshen each used to implement locally.
67
+ */
68
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
69
+ function expandHome(path, home = homedir()) {
70
+ const j = home.startsWith("/") ? join$1 : join;
71
+ if (path === "~") return home;
72
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
73
+ return path;
74
+ }
75
+ /**
76
+ * Resolve the DSH home directory.
77
+ * @param env - process environment to read DSH_HOME from.
78
+ * @param home - platform home directory fallback (test seam).
79
+ * @returns the absolute DSH home path.
80
+ */
81
+ function resolveDshHome(env = process.env, home = homedir()) {
82
+ const isPosix = home.startsWith("/");
83
+ const j = isPosix ? join$1 : join;
84
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
85
+ const raw = env.DSH_HOME;
86
+ if (raw !== void 0 && raw.trim() !== "") {
87
+ const expanded = expandHome(raw.trim(), home);
88
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
89
+ }
90
+ return j(home, ".dsh");
91
+ }
92
+ /** Resolve the DSH home directory from the live environment. */
93
+ function dshHome() {
94
+ return resolveDshHome();
95
+ }
96
+ //#endregion
10
97
  //#region src/core/schedule.ts
11
98
  /** Inclusive ranges per field, in cron order. */
12
99
  const FIELD_RANGES = [
@@ -283,73 +370,6 @@ function isDigits(value) {
283
370
  return /^\d+$/.test(value);
284
371
  }
285
372
  //#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
373
  //#region src/core/extension.ts
354
374
  /** Cordis service name the host and browser halves each publish. */
355
375
  const TASK_BOARD_SERVICE_NAME = "taskBoard";
@@ -437,6 +457,37 @@ function retainRecentExecutions(executions) {
437
457
  const keepSettled = Math.max(20 - open.length, 0);
438
458
  return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open];
439
459
  }
460
+ /** Every stop reason this build understands; the repair and wire gates share it. */
461
+ const SCHEDULE_STOP_REASONS = [
462
+ "fired",
463
+ "limit",
464
+ "no-target",
465
+ "missed",
466
+ "busy",
467
+ "permission",
468
+ "launch-failed"
469
+ ];
470
+ /**
471
+ * The rule's total run budget: one for a one-shot, the stored cap for a
472
+ * recurring rule, and undefined when the rule is unlimited.
473
+ * @param rule - the schedule rule.
474
+ * @returns the budget, or undefined for an uncapped rule.
475
+ */
476
+ function scheduleRunBudget(rule) {
477
+ if (rule.mode === "once") return 1;
478
+ return rule.maxRuns;
479
+ }
480
+ /**
481
+ * Whether a rule has no runs left. The scheduler refuses to open another
482
+ * execution for an exhausted rule, which is what makes the budget terminal
483
+ * regardless of restarts or repeated timer fires.
484
+ * @param rule - the schedule rule.
485
+ * @returns true when the run budget is spent.
486
+ */
487
+ function scheduleExhausted(rule) {
488
+ const budget = scheduleRunBudget(rule);
489
+ return budget !== void 0 && rule.runCount >= budget;
490
+ }
440
491
  /** Whether an unknown value is a well-formed tag (strict: the wire gate). */
441
492
  function isTaskTag(value) {
442
493
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -610,7 +661,7 @@ function createTask(input, now, id) {
610
661
  model: normalizeTargetId(input.model),
611
662
  reuseSession: input.reuseSession === true ? true : void 0,
612
663
  teamRun: input.teamRun === true ? true : void 0,
613
- goalRun: input.goalRun === false ? false : void 0,
664
+ goalRun: input.goalRun === true ? true : void 0,
614
665
  skipVerification: input.skipVerification === true ? true : void 0,
615
666
  ...input.freeze === void 0 ? {} : { freeze: freezeOf(input.freeze, now) },
616
667
  ...input.handover === void 0 ? {} : { handover: {
@@ -630,27 +681,47 @@ function withStatus(task, status, now) {
630
681
  updatedAt: now
631
682
  };
632
683
  }
684
+ /** Every field a schedule patch may carry; the rest of the rule is carried over. */
685
+ const SCHEDULE_PATCH_KEYS = [
686
+ "enabled",
687
+ "mode",
688
+ "cron",
689
+ "at",
690
+ "timeZone",
691
+ "nextRunAt",
692
+ "lastTriggeredAt",
693
+ "runCount",
694
+ "maxRuns",
695
+ "endedAt",
696
+ "endedReason",
697
+ "skippedAt",
698
+ "skippedReason"
699
+ ];
633
700
  /**
634
701
  * Merge a schedule patch into a task's schedule rule (creating it when
635
702
  * absent), with a fresh updatedAt. Keys present in the patch overwrite the
636
703
  * current value — including explicit `undefined`, which clears a field (used
637
- * to disarm `nextRunAt`); absent keys keep their current value.
704
+ * to disarm `nextRunAt` or to drop an ended/skip record); absent keys keep
705
+ * their current value.
638
706
  */
639
707
  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;
708
+ const schedule = task.schedule === void 0 ? {
709
+ enabled: false,
710
+ mode: "cron",
711
+ nextRunAt: void 0,
712
+ lastTriggeredAt: void 0,
713
+ runCount: 0
714
+ } : { ...task.schedule };
715
+ const fields = schedule;
716
+ const next = patch;
717
+ for (const key of SCHEDULE_PATCH_KEYS) {
718
+ if (!(key in patch)) continue;
719
+ if (next[key] === void 0) delete fields[key];
720
+ else fields[key] = next[key];
721
+ }
722
+ if (schedule.enabled !== true) schedule.enabled = false;
723
+ if (schedule.mode !== "once") schedule.mode = "cron";
724
+ if (!Number.isFinite(schedule.runCount) || schedule.runCount < 0) schedule.runCount = 0;
654
725
  return {
655
726
  ...task,
656
727
  updatedAt: now,
@@ -689,6 +760,20 @@ function startExecution(task, now, executionId, initiatedBy, runGroupId) {
689
760
  };
690
761
  }
691
762
  /**
763
+ * The column a settled outcome puts a card in. The ONE derivation shared by
764
+ * every settlement path (issue #1826): a Host-run execution and an outcome an
765
+ * outside agent reported both land in the same column from the same rule, so
766
+ * the column never becomes a second, independent piece of state.
767
+ @param task - the card being settled.
768
+ @param outcome - the settled outcome.
769
+ @returns the status the card shows afterwards.
770
+ */
771
+ function settledStatus(task, outcome) {
772
+ if (outcome === "succeeded") return task.schedule?.enabled === true ? "todo" : "done";
773
+ if (outcome === "failed") return "failed";
774
+ return task.status === "running" ? "todo" : task.status;
775
+ }
776
+ /**
692
777
  * Settle a running execution: record the outcome and move the task into the
693
778
  * matching column. No-op (returns the input task) when the execution is not
694
779
  * the task's latest or is already settled.
@@ -706,10 +791,9 @@ function settleExecution(task, executionId, outcome, now, error) {
706
791
  };
707
792
  const executions = [...task.executions];
708
793
  executions[index] = settled;
709
- const status = outcome === "succeeded" ? task.schedule?.enabled ? "todo" : "done" : outcome === "failed" ? "failed" : task.status === "running" ? "todo" : task.status;
710
794
  return {
711
795
  ...task,
712
- status,
796
+ status: settledStatus(task, outcome),
713
797
  updatedAt: now,
714
798
  executions
715
799
  };
@@ -1205,6 +1289,20 @@ const SCALE_DESCRIPTION = [
1205
1289
  " Q-S = failed with some partial progress",
1206
1290
  " T = clearly and completely failed (worst)"
1207
1291
  ].join("\n");
1292
+ /**
1293
+ * Which trajectory slot holds the WORK during one round.
1294
+ *
1295
+ * The even round (0) puts the work in slot A and the empty-work baseline in
1296
+ * slot B; the odd round (1) swaps them, which is how the acceptance cancels
1297
+ * position bias. The judge prompt ALWAYS introduces the work as
1298
+ * `location="TRAJECTORY_B"`, because that is the slot the work occupies in the
1299
+ * prompt's own framing; the parser maps the cited slot back through this table.
1300
+ * @param round - the round index (0-based).
1301
+ * @returns the slot letter the work occupies in that round.
1302
+ */
1303
+ function workSlotOfRound(round) {
1304
+ return round % 2 === 1 ? "B" : "A";
1305
+ }
1208
1306
  Array.from({ length: 20 }, (_, index) => String.fromCharCode(65 + index));
1209
1307
  /**
1210
1308
  * Normalize one score token to an A-T letter.
@@ -1286,8 +1384,9 @@ function evidenceNonce(...parts) {
1286
1384
  * byte-identical for a deployment that serves no change service.
1287
1385
  * @returns the rendered prompt.
1288
1386
  */
1289
- function buildAcceptancePrompt(problem, traceA, traceB, criterion, groundTruthNote = DEFAULT_GROUND_TRUTH_NOTE, context) {
1387
+ function buildAcceptancePrompt(problem, traceA, traceB, criterion, groundTruthNote = DEFAULT_GROUND_TRUTH_NOTE, context, workSlot = "A") {
1290
1388
  const reference = context?.trim() ?? "";
1389
+ const workSlotTag = workSlot === "A" ? "Trajectory A" : "Trajectory B";
1291
1390
  const token = reference === "" ? evidenceNonce(problem, traceA, traceB) : evidenceNonce(problem, reference, traceA, traceB);
1292
1391
  return [
1293
1392
  EVALUATOR_ROLE,
@@ -1300,6 +1399,10 @@ function buildAcceptancePrompt(problem, traceA, traceB, criterion, groundTruthNo
1300
1399
  "**Rating Scale:**\n" + SCALE_DESCRIPTION,
1301
1400
  "**Evaluation Guideline — " + criterion.name + ":**\n" + criterion.description,
1302
1401
  "Score each trajectory ONLY on this specific criterion (\"" + criterion.name + "\"). Ignore other aspects that are not relevant to it.",
1402
+ "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):",
1403
+ "<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>",
1404
+ "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.",
1405
+ "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
1406
  "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
1407
  "Begin your analysis now."
1305
1408
  ].join("\n\n");
@@ -1312,6 +1415,116 @@ function qualityAttempts(verification) {
1312
1415
  function exceptionAttempts(verification) {
1313
1416
  return verification === void 0 ? [] : verification.attempts.filter((attempt) => attempt.stage === "exception");
1314
1417
  }
1418
+ /**
1419
+ * Invalid acceptances spent by this cycle: a veto whose required evidence was
1420
+ * missing, unlocatable, or aimed at the empty-work baseline. They are NOT
1421
+ * quality verdicts, so they never close the cycle and never fail the card; the
1422
+ * bound is what keeps an evidence-free judge from re-running forever.
1423
+ */
1424
+ function invalidAttempts(verification) {
1425
+ return verification === void 0 ? [] : verification.attempts.filter((attempt) => attempt.stage === "invalid");
1426
+ }
1427
+ /** Whether this cycle still has invalid-acceptance budget left. */
1428
+ function hasInvalidBudget(verification) {
1429
+ return invalidAttempts(verification).length < 2;
1430
+ }
1431
+ /**
1432
+ * The acceptance block with every non-quality attempt dropped: the explicit
1433
+ * reset a user performs after fixing the environment (issue #1828).
1434
+ *
1435
+ * Only attempts that are not a quality verdict are dropped. A quality verdict
1436
+ * is the board's own judgement of the work and survives every reset, so this
1437
+ * can never buy an extra acceptance or re-open a judged failure; it only lets
1438
+ * the environment be judged again.
1439
+ * @param verification - the persisted acceptance state.
1440
+ * @returns the cleared block, or undefined when there is nothing to clear.
1441
+ */
1442
+ function withoutAcceptanceAnomalies(verification) {
1443
+ const kept = verification.attempts.filter((attempt) => attempt.stage === "quality");
1444
+ if (kept.length === verification.attempts.length) return void 0;
1445
+ const cleared = {
1446
+ ...verification,
1447
+ attempts: kept
1448
+ };
1449
+ delete cleared.inFlight;
1450
+ delete cleared.failedReason;
1451
+ delete cleared.failedAt;
1452
+ delete cleared.cleanup;
1453
+ return cleared;
1454
+ }
1455
+ /**
1456
+ * The one-line problem summary a cleaned execution keeps: the failing criteria
1457
+ * with their scores, plus how many locatable findings were removed. This is the
1458
+ * lightweight audit material that survives the detail cleanup.
1459
+ * @param attempt - the attempt whose detail is being removed.
1460
+ * @returns the bounded summary line.
1461
+ */
1462
+ function findingSummaryOf(attempt) {
1463
+ const failed = attempt.criteria.filter((criterion) => !criterion.passed);
1464
+ const parts = [];
1465
+ if (failed.length > 0) parts.push("未达标判据:" + failed.map((criterion) => criterion.name + " " + percent(criterion.score)).join("、"));
1466
+ const located = (attempt.criterionFindings ?? []).length;
1467
+ if (located > 0) parts.push("已移除的可定位问题 " + located + " 条");
1468
+ if (attempt.error !== void 0 && attempt.error !== "") parts.push(attempt.error);
1469
+ if (parts.length === 0) return void 0;
1470
+ return parts.join(";");
1471
+ }
1472
+ /**
1473
+ * The audit credential of one attempt: everything the completion gate, the
1474
+ * report and a later review need, and nothing that makes the ledger heavy.
1475
+ * @param attempt - the attempt being condensed.
1476
+ * @returns the lightweight audit record.
1477
+ */
1478
+ function auditOf(attempt) {
1479
+ const summary = findingSummaryOf(attempt);
1480
+ return {
1481
+ stage: attempt.stage,
1482
+ passed: attempt.passed,
1483
+ at: attempt.at,
1484
+ score: attempt.score,
1485
+ baseline: attempt.baseline,
1486
+ criteria: attempt.criteria.map((criterion) => ({ ...criterion })),
1487
+ evidenceHash: attempt.evidence.hash,
1488
+ route: { ...attempt.route },
1489
+ ...attempt.error === void 0 ? {} : { error: attempt.error },
1490
+ ...summary === void 0 ? {} : { findingSummary: summary },
1491
+ usage: { ...attempt.usage }
1492
+ };
1493
+ }
1494
+ /**
1495
+ * Remove this execution's OWN acceptance-only detail while keeping the light
1496
+ * audit record — the acceptance mechanism's side of requirement 2.
1497
+ *
1498
+ * Only material the acceptance mechanism itself created is removed: the
1499
+ * per-attempt findings, the invalid diagnostics and the token/evidence detail
1500
+ * beyond the summary. Everything a human needs to see the verdict survives
1501
+ * (stage, pass flag, time, scores, evidence hash, judge route, usage totals),
1502
+ * so the completion gate's pass record and a later review are unaffected, and
1503
+ * NO user file, task artifact, raw session history or other execution's record
1504
+ * is ever touched by this function.
1505
+ * @param verification - the execution's acceptance block.
1506
+ * @param now - cleanup instant (ms epoch).
1507
+ * @returns the cleaned block, or undefined when there is nothing to remove.
1508
+ */
1509
+ function withAcceptanceDetailCleared(verification, now) {
1510
+ if (verification.cleanup?.state === "cleaned") return void 0;
1511
+ const audit = verification.attempts.map(auditOf);
1512
+ return {
1513
+ ...verification,
1514
+ attempts: verification.attempts.map((attempt) => ({
1515
+ ...attempt,
1516
+ findings: [],
1517
+ criterionFindings: []
1518
+ })),
1519
+ cleanup: {
1520
+ state: "cleaned",
1521
+ attempts: (verification.cleanup?.attempts ?? 0) + 1,
1522
+ startedAt: verification.cleanup?.startedAt ?? now,
1523
+ cleanedAt: now,
1524
+ audit
1525
+ }
1526
+ };
1527
+ }
1315
1528
  /** The quality attempt that passed, when one did. */
1316
1529
  function passedAttempt(verification) {
1317
1530
  return qualityAttempts(verification).find((attempt) => attempt.passed);
@@ -1328,6 +1541,33 @@ function passedAttempt(verification) {
1328
1541
  function verificationRequired(verification) {
1329
1542
  return verification !== void 0 && verification.applicability === "enforced";
1330
1543
  }
1544
+ /**
1545
+ * Whether this execution's acceptance gate was never opened: acceptance was
1546
+ * enforced, the judge recorded nothing at all, and no cycle ever closed. Zero
1547
+ * attempts is therefore a fact about the RUN, not a verdict about the work — a
1548
+ * session that narrated completion instead of calling
1549
+ * `update_goal(action: complete)` produces exactly this shape, and its failure
1550
+ * reason must stay distinguishable from a quality verdict (issue #1837).
1551
+ * @param verification - the execution's persisted acceptance state.
1552
+ * @returns true when no acceptance attempt ever ran.
1553
+ */
1554
+ function verificationNeverInvoked(verification) {
1555
+ return verification !== void 0 && verification.applicability === "enforced" && verification.attempts.length === 0 && verification.failedReason === void 0;
1556
+ }
1557
+ /**
1558
+ * Whether this execution's acceptance produced no usable veto at all: it was
1559
+ * enforced, the judge recorded attempts, and every one of them is INVALID.
1560
+ *
1561
+ * Such a run must never be described as an evidence-backed quality veto (issue
1562
+ * #1837's sibling): the judge could not locate its own rejection, so the run
1563
+ * ended for lack of a matching pass rather than because the work was judged
1564
+ * and rejected. The settlement reason names that difference.
1565
+ * @param verification - the execution's persisted acceptance state.
1566
+ * @returns true when nothing but invalid acceptances were recorded.
1567
+ */
1568
+ function verificationOnlyInvalid(verification) {
1569
+ 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;
1570
+ }
1331
1571
  /** Whether this cycle still has quality budget left. */
1332
1572
  function hasQualityBudget(verification) {
1333
1573
  return qualityAttempts(verification).length < 2;
@@ -1336,6 +1576,99 @@ function hasQualityBudget(verification) {
1336
1576
  function hasExceptionBudget(verification) {
1337
1577
  return exceptionAttempts(verification).length < 2;
1338
1578
  }
1579
+ /** Whether a text carries a real statement rather than filler. */
1580
+ function substantive(text) {
1581
+ return text.trim().length >= 4;
1582
+ }
1583
+ /**
1584
+ * Program-level validity of a REJECTING acceptance, decided before any quality
1585
+ * verdict is booked (requirement 1: the rule cannot live in a prompt alone).
1586
+ *
1587
+ * A veto is only usable when every criterion that failed has its own structured
1588
+ * finding carrying the requirement, the observation, the difference and a
1589
+ * citation that really occurs in the evidence the judge saw. A citation aimed at
1590
+ * the empty-work baseline, a citation that is not in the reviewed evidence, an
1591
+ * empty or generic statement, and a truncated review that cannot substantiate
1592
+ * the veto are all invalid acceptances: no quality verdict, no quality budget,
1593
+ * and no card failure.
1594
+ * @param input - the attempt's scores, findings and the evidence it judged.
1595
+ * @returns valid, or the reason and detail of the invalidity.
1596
+ */
1597
+ function assessAcceptanceValidity(input) {
1598
+ const failed = input.criteria.filter((criterion) => !criterion.passed);
1599
+ const truncated = input.omittedCharacters > 0;
1600
+ if (failed.length === 0) {
1601
+ if (input.findings.filter((finding) => validFinding(finding, input.evidence) === void 0).length > 0) return { valid: true };
1602
+ return truncated ? {
1603
+ valid: false,
1604
+ reason: "insufficient-evidence",
1605
+ detail: "the review was truncated and no verifiable citation supports the total-score veto"
1606
+ } : {
1607
+ valid: false,
1608
+ reason: "missing-finding",
1609
+ detail: "the total score was rejected without any locatable finding"
1610
+ };
1611
+ }
1612
+ for (const criterion of failed) {
1613
+ const own = input.findings.filter((finding) => finding.criterionId === criterion.id);
1614
+ if (own.length === 0) return truncated ? {
1615
+ valid: false,
1616
+ reason: "insufficient-evidence",
1617
+ detail: criterion.name + ": the evidence was truncated and the judge reported no citation of its own, so this criterion cannot be judged"
1618
+ } : {
1619
+ valid: false,
1620
+ reason: "missing-finding",
1621
+ detail: criterion.name + ": the criterion failed with no structured finding of its own"
1622
+ };
1623
+ let usable = false;
1624
+ let lastReason;
1625
+ for (const finding of own) {
1626
+ const problem = validFinding(finding, input.evidence);
1627
+ if (problem === void 0) {
1628
+ usable = true;
1629
+ break;
1630
+ }
1631
+ lastReason = problem;
1632
+ }
1633
+ if (!usable) {
1634
+ const detail = (lastReason?.detail ?? criterion.name) + " (" + criterion.name + ")";
1635
+ return {
1636
+ valid: false,
1637
+ reason: lastReason?.reason ?? "vacuous-finding",
1638
+ detail
1639
+ };
1640
+ }
1641
+ }
1642
+ return { valid: true };
1643
+ }
1644
+ /**
1645
+ * Why one finding cannot support a veto, or undefined when it can.
1646
+ *
1647
+ * The citation is checked against the evidence text of the LOCATION the finding
1648
+ * names, so a quote invented by the judge, a quote taken from an A/B slot that
1649
+ * held the empty-work baseline, and a quote the reviewed window never contained
1650
+ * are all rejected.
1651
+ */
1652
+ function validFinding(finding, evidence) {
1653
+ if (!substantive(finding.requirement) || !substantive(finding.observation) || !substantive(finding.gap)) return {
1654
+ reason: "vacuous-finding",
1655
+ detail: "the finding states no requirement, observation or difference"
1656
+ };
1657
+ if (finding.location === "baseline") return {
1658
+ reason: "baseline-finding",
1659
+ detail: "the citation is taken from the empty-work baseline, which is not the work under review"
1660
+ };
1661
+ const quote = finding.quote.trim();
1662
+ if (quote.length < 12) return {
1663
+ reason: "unlocatable-quote",
1664
+ detail: "the citation is too short to locate anything in the evidence"
1665
+ };
1666
+ const haystack = finding.location === "task" ? evidence.task : finding.location === "workspace" ? evidence.workspace : finding.location === "trajectory" ? evidence.trajectory : "";
1667
+ if (haystack === "" || !haystack.includes(quote)) return {
1668
+ reason: "unlocatable-quote",
1669
+ detail: "the citation does not occur in the evidence the judge reviewed"
1670
+ };
1671
+ }
1339
1672
  /**
1340
1673
  * The acceptance rule, mirrored from the verifier's `sessionAccepted`:
1341
1674
  * the work must BEAT the empty-work baseline, reach the total threshold, and
@@ -1357,7 +1690,7 @@ function readAttempt(value) {
1357
1690
  if (typeof value !== "object" || value === null) return void 0;
1358
1691
  const row = value;
1359
1692
  if (typeof row.index !== "number" || typeof row.at !== "number") return void 0;
1360
- if (row.stage !== "quality" && row.stage !== "exception") return void 0;
1693
+ if (row.stage !== "quality" && row.stage !== "exception" && row.stage !== "budget" && row.stage !== "invalid") return void 0;
1361
1694
  if (typeof row.passed !== "boolean") return void 0;
1362
1695
  if (typeof row.score !== "number" || !Number.isFinite(row.score)) return void 0;
1363
1696
  if (typeof row.baseline !== "number" || !Number.isFinite(row.baseline)) return void 0;
@@ -1392,7 +1725,18 @@ function readAttempt(value) {
1392
1725
  const routeRow = route;
1393
1726
  if (typeof routeRow.provider !== "string" || typeof routeRow.model !== "string") return void 0;
1394
1727
  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") : [];
1728
+ const findings = (Array.isArray(row.findings) ? row.findings : []).filter((item) => typeof item === "string").slice(0, 12).map((item) => clipRead(item, 400));
1729
+ const criterionFindings = [];
1730
+ if (Array.isArray(row.criterionFindings)) {
1731
+ if (row.criterionFindings.length > 12) return void 0;
1732
+ for (const entry of row.criterionFindings) {
1733
+ const finding = readCriterionFinding(entry);
1734
+ if (finding === void 0) return void 0;
1735
+ criterionFindings.push(finding);
1736
+ }
1737
+ }
1738
+ const invalidReason = row.invalidReason;
1739
+ if (invalidReason !== void 0 && invalidReason !== "missing-finding" && invalidReason !== "unlocatable-quote" && invalidReason !== "baseline-finding" && invalidReason !== "vacuous-finding" && invalidReason !== "insufficient-evidence") return void 0;
1396
1740
  return {
1397
1741
  index: row.index,
1398
1742
  at: row.at,
@@ -1425,7 +1769,51 @@ function readAttempt(value) {
1425
1769
  },
1426
1770
  channel: "explicit-tag",
1427
1771
  rounds: typeof row.rounds === "number" ? row.rounds : 2,
1428
- ...typeof row.error === "string" ? { error: row.error } : {}
1772
+ ...typeof row.error === "string" ? { error: row.error } : {},
1773
+ ...criterionFindings.length === 0 ? {} : { criterionFindings },
1774
+ ...typeof invalidReason === "string" ? { invalidReason } : {}
1775
+ };
1776
+ }
1777
+ /**
1778
+ * Bound one persisted finding's text to what the acceptance itself would have
1779
+ * written.
1780
+ *
1781
+ * The reader is the only other door into the ledger: a hand-edited or
1782
+ * corrupted document could otherwise carry a megabyte of prose in a field the
1783
+ * writer caps at a few hundred characters, and every later render, audit
1784
+ * summary and cleanup pass would pay for it. Clipping on READ (never rejecting)
1785
+ * keeps the block usable and its capacity bounded: the acceptance-only material
1786
+ * the cleanup removes is bounded by construction on both sides.
1787
+ * @param value - the raw persisted text.
1788
+ * @param max - the cap the writer applies to that field.
1789
+ * @returns the bounded text.
1790
+ */
1791
+ function clipRead(value, max) {
1792
+ return value.length <= max ? value : value.slice(0, max);
1793
+ }
1794
+ /**
1795
+ * Repair one persisted structured finding, or undefined when it is unusable.
1796
+ * A malformed finding drops the WHOLE acceptance block (fail closed): a
1797
+ * persisted veto that cannot be read back could otherwise be counted as
1798
+ * evidence it no longer holds.
1799
+ */
1800
+ function readCriterionFinding(value) {
1801
+ if (typeof value !== "object" || value === null) return void 0;
1802
+ const row = value;
1803
+ if (typeof row.criterionId !== "string" || row.criterionId === "") return void 0;
1804
+ if (typeof row.requirement !== "string" || typeof row.observation !== "string" || typeof row.gap !== "string") return void 0;
1805
+ if (typeof row.quote !== "string") return void 0;
1806
+ if (row.criterionId.length > 64) return void 0;
1807
+ const location = row.location;
1808
+ if (location !== "task" && location !== "trajectory" && location !== "workspace" && location !== "baseline" && location !== "unknown") return void 0;
1809
+ return {
1810
+ criterionId: row.criterionId,
1811
+ requirement: clipRead(row.requirement, 400),
1812
+ observation: clipRead(row.observation, 400),
1813
+ gap: clipRead(row.gap, 400),
1814
+ quote: clipRead(row.quote, 600),
1815
+ location,
1816
+ ...typeof row.action === "string" ? { action: clipRead(row.action, 400) } : {}
1429
1817
  };
1430
1818
  }
1431
1819
  /**
@@ -1473,7 +1861,7 @@ function normalizeVerification(value) {
1473
1861
  };
1474
1862
  }
1475
1863
  const applicability = row.applicability;
1476
- if (applicability !== "enforced" && applicability !== "disabled" && applicability !== "skipped" && applicability !== "goal-unavailable" && applicability !== "team-member") return void 0;
1864
+ if (applicability !== "enforced" && applicability !== "disabled" && applicability !== "skipped" && applicability !== "goal-unavailable" && applicability !== "team-member" && applicability !== "goal-disabled") return void 0;
1477
1865
  const attempts = [];
1478
1866
  if (!Array.isArray(row.attempts)) return void 0;
1479
1867
  for (const entry of row.attempts) {
@@ -1482,6 +1870,8 @@ function normalizeVerification(value) {
1482
1870
  attempts.push(attempt);
1483
1871
  }
1484
1872
  if (typeof row.failedReason === "string" && row.failedReason === "") return void 0;
1873
+ const cleanup = readCleanupRecord(row.cleanup);
1874
+ if (row.cleanup !== void 0 && cleanup === void 0) return void 0;
1485
1875
  return {
1486
1876
  contract: {
1487
1877
  enabled: contractRow.enabled,
@@ -1496,7 +1886,98 @@ function normalizeVerification(value) {
1496
1886
  ...row.inFlight === true ? { inFlight: true } : {},
1497
1887
  applicability,
1498
1888
  ...typeof row.failedReason === "string" ? { failedReason: row.failedReason } : {},
1499
- ...typeof row.failedAt === "number" && Number.isFinite(row.failedAt) ? { failedAt: row.failedAt } : {}
1889
+ ...typeof row.failedAt === "number" && Number.isFinite(row.failedAt) ? { failedAt: row.failedAt } : {},
1890
+ ...cleanup === void 0 ? {} : { cleanup }
1891
+ };
1892
+ }
1893
+ /**
1894
+ * Repair the persisted cleanup record, or undefined when it is unusable. The
1895
+ * record is the audit trail the UI reads to tell "the judge gave its evidence
1896
+ * and it was cleaned up" apart from "the judge produced no evidence at all",
1897
+ * so a malformed one drops the whole block (fail closed).
1898
+ */
1899
+ function readCleanupRecord(value) {
1900
+ if (typeof value !== "object" || value === null) return void 0;
1901
+ const row = value;
1902
+ if (row.state !== "pending" && row.state !== "cleaned" && row.state !== "failed") return void 0;
1903
+ if (typeof row.attempts !== "number" || !Number.isInteger(row.attempts) || row.attempts < 0) return void 0;
1904
+ if (typeof row.startedAt !== "number" || !Number.isFinite(row.startedAt)) return void 0;
1905
+ if (row.cleanedAt !== void 0 && (typeof row.cleanedAt !== "number" || !Number.isFinite(row.cleanedAt))) return void 0;
1906
+ if (row.lastError !== void 0 && typeof row.lastError !== "string") return void 0;
1907
+ if (!Array.isArray(row.audit)) return void 0;
1908
+ const audit = [];
1909
+ for (const entry of row.audit) {
1910
+ const parsed = readAuditRecord(entry);
1911
+ if (parsed === void 0) return void 0;
1912
+ audit.push(parsed);
1913
+ }
1914
+ return {
1915
+ state: row.state,
1916
+ attempts: row.attempts,
1917
+ startedAt: row.startedAt,
1918
+ ...typeof row.cleanedAt === "number" ? { cleanedAt: row.cleanedAt } : {},
1919
+ ...typeof row.lastError === "string" ? { lastError: row.lastError } : {},
1920
+ audit
1921
+ };
1922
+ }
1923
+ /** Repair one persisted audit credential, or undefined when it is unusable. */
1924
+ function readAuditRecord(value) {
1925
+ if (typeof value !== "object" || value === null) return void 0;
1926
+ const row = value;
1927
+ if (row.stage !== "quality" && row.stage !== "exception" && row.stage !== "budget" && row.stage !== "invalid") return void 0;
1928
+ if (typeof row.passed !== "boolean") return void 0;
1929
+ if (typeof row.at !== "number" || !Number.isFinite(row.at)) return void 0;
1930
+ if (typeof row.score !== "number" || !Number.isFinite(row.score)) return void 0;
1931
+ if (typeof row.baseline !== "number" || !Number.isFinite(row.baseline)) return void 0;
1932
+ if (!Array.isArray(row.criteria)) return void 0;
1933
+ const criteria = [];
1934
+ for (const entry of row.criteria) {
1935
+ if (typeof entry !== "object" || entry === null) return void 0;
1936
+ const criterion = entry;
1937
+ if (typeof criterion.id !== "string" || typeof criterion.name !== "string") return void 0;
1938
+ if (typeof criterion.score !== "number" || !Number.isFinite(criterion.score)) return void 0;
1939
+ if (typeof criterion.baseline !== "number" || !Number.isFinite(criterion.baseline)) return void 0;
1940
+ if (typeof criterion.threshold !== "number" || !Number.isFinite(criterion.threshold)) return void 0;
1941
+ criteria.push({
1942
+ id: criterion.id,
1943
+ name: criterion.name,
1944
+ score: criterion.score,
1945
+ baseline: criterion.baseline,
1946
+ threshold: criterion.threshold,
1947
+ passed: criterion.passed === true
1948
+ });
1949
+ }
1950
+ if (typeof row.evidenceHash !== "string") return void 0;
1951
+ const route = row.route;
1952
+ if (typeof route !== "object" || route === null) return void 0;
1953
+ const routeRow = route;
1954
+ if (typeof routeRow.provider !== "string" || typeof routeRow.model !== "string") return void 0;
1955
+ const usage = row.usage;
1956
+ if (typeof usage !== "object" || usage === null) return void 0;
1957
+ const usageRow = usage;
1958
+ if (typeof usageRow.calls !== "number" || typeof usageRow.inputTokens !== "number" || typeof usageRow.outputTokens !== "number" || typeof usageRow.reasoningTokens !== "number") return void 0;
1959
+ return {
1960
+ stage: row.stage,
1961
+ passed: row.passed,
1962
+ at: row.at,
1963
+ score: row.score,
1964
+ baseline: row.baseline,
1965
+ criteria,
1966
+ evidenceHash: row.evidenceHash,
1967
+ route: {
1968
+ provider: routeRow.provider,
1969
+ model: routeRow.model,
1970
+ ...typeof routeRow.reasoningEffort === "string" ? { reasoningEffort: routeRow.reasoningEffort } : {}
1971
+ },
1972
+ ...typeof row.error === "string" ? { error: row.error } : {},
1973
+ ...typeof row.findingSummary === "string" ? { findingSummary: row.findingSummary } : {},
1974
+ usage: {
1975
+ calls: usageRow.calls,
1976
+ inputTokens: usageRow.inputTokens,
1977
+ outputTokens: usageRow.outputTokens,
1978
+ reasoningTokens: usageRow.reasoningTokens,
1979
+ ...usageRow.usageIncomplete === true ? { usageIncomplete: true } : {}
1980
+ }
1500
1981
  };
1501
1982
  }
1502
1983
  /**
@@ -1515,14 +1996,68 @@ function qualityFeedback(attempt, remaining) {
1515
1996
  "总分 " + percent(attempt.score) + "(阈值 " + percent(attempt.criteria[0]?.threshold ?? .65) + "),空工作基线 " + percent(attempt.baseline) + "。",
1516
1997
  "未达标判据:" + (failed.length === 0 ? "无(总分或基线比较未通过)" : failed.map((criterion) => criterion.name + " " + percent(criterion.score)).join("、"))
1517
1998
  ];
1518
- if (attempt.findings.length > 0) {
1999
+ const located = locatedFindingLines(attempt);
2000
+ if (located.length > 0) {
1519
2001
  lines.push("可定位问题:");
1520
- for (const finding of attempt.findings) lines.push("- " + finding);
2002
+ for (const line of located) lines.push("- " + line);
1521
2003
  } else lines.push("本次裁判没有报告可定位的问题;请直接对照任务要求核对证据中的真实输出。");
1522
2004
  lines.push("请按上述问题修复,然后再次调用 update_goal(action: complete) 触发第 " + (attempt.index + 1) + " 次验收;本执行周期的验收额度还剩 " + remaining + " 次。");
1523
2005
  return lines.join("\n");
1524
2006
  }
1525
2007
  /**
2008
+ * The feedback one INVALID acceptance returns.
2009
+ *
2010
+ * The claim is refused (the board still has no pass record, so completion may
2011
+ * not take effect) but the agent is NOT handed a repair instruction: there is
2012
+ * no located problem to repair, and telling it to "fix" an evidence-free veto
2013
+ * would be exactly the blind repair the requirement forbids. The text says what
2014
+ * was missing, what happens next, and what the agent should do instead (make
2015
+ * the missing output itself observable, or let a human look).
2016
+ * @param attempt - the invalid attempt.
2017
+ * @param remaining - invalid-acceptance attempts left after this one.
2018
+ * @returns the model-facing text.
2019
+ */
2020
+ function invalidAcceptanceFeedback(attempt, remaining) {
2021
+ const lines = [
2022
+ "[任务看板 · 验收无效] 第 " + attempt.index + " 次验收没有产生可用的质量判定。",
2023
+ "原因:" + invalidReasonText(attempt.invalidReason) + (attempt.error === void 0 || attempt.error === "" ? "" : "(" + attempt.error + ")"),
2024
+ "本次未消耗质量验收额度,也不把本次执行判为失败。"
2025
+ ];
2026
+ if (attempt.invalidReason === "insufficient-evidence") {
2027
+ lines.push("裁判看到的证据不完整(本次轨迹被截断 " + attempt.evidence.omittedCharacters + " 字符),无法据此否决;这属于验收无效,不代表执行者没有完成工作。");
2028
+ lines.push("请把关键结论的证据直接放进你能控制的输出里(例如把最终验证命令的输出贴出来),或说明还需要哪些证据;再次调用 update_goal(action: complete) 会重新验收。");
2029
+ } else {
2030
+ lines.push("验收没有拿到任务要求、实际观测、差异与可定位引用俱全的证据,因此本次否决无效。");
2031
+ lines.push("请不要据此盲目修改:先确认你的交付是否真的满足了任务要求,并确保关键产物/输出可见;再次调用 update_goal(action: complete) 会重新验收。");
2032
+ }
2033
+ if (remaining > 0) lines.push("无效验收重试额度还剩 " + remaining + " 次;用尽后看板会停止调用裁判并等待人工处理。");
2034
+ else lines.push("无效验收重试额度已用尽:看板将停止调用裁判并把本次执行留在等待人工处理的状态。");
2035
+ return lines.join("\n");
2036
+ }
2037
+ /**
2038
+ * The reason one execution records when its invalid-acceptance budget is spent.
2039
+ *
2040
+ * Deliberately NOT a quality failure: no veto was ever substantiated, so the
2041
+ * card stays open for a human and the reason says the acceptance is INVALID
2042
+ * rather than rejected.
2043
+ * @param used - invalid attempts spent.
2044
+ * @returns the user- and model-facing text.
2045
+ */
2046
+ function invalidHoldReason(used) {
2047
+ return "goal 验收无效(连续 " + used + " 次未产生可用判定):验收没有给出与任务要求对应、且可定位到本次证据的否决依据,本次执行未判失败、未消耗质量验收额度。看板已停止调用裁判,等待人工处理:修复验收环境或证据可见性后,用 task_board_manage(action=reset-verification) 清除记录的无效验收,再重新完成目标;也可以直接重跑该卡片。";
2048
+ }
2049
+ /** Human-readable text of one invalid reason. */
2050
+ function invalidReasonText(reason) {
2051
+ switch (reason) {
2052
+ case "missing-finding": return "未达标的判据没有对应的结构化问题反馈";
2053
+ case "unlocatable-quote": return "问题反馈引用的位置不存在于本次裁判所见的证据中";
2054
+ case "baseline-finding": return "问题反馈指向空工作基线,而不是本次执行的工作";
2055
+ case "vacuous-finding": return "问题反馈为空或空泛评价,没有说明实际观测与差异";
2056
+ case "insufficient-evidence": return "裁判看到的证据不足(例如轨迹被截断),无法支撑否决";
2057
+ default: return "未产生可用的质量判定";
2058
+ }
2059
+ }
2060
+ /**
1526
2061
  * The reason one exhausted acceptance cycle records as the execution's final
1527
2062
  * failure. Unlike {@link qualityFeedback} — which the fixing agent reads while
1528
2063
  * budget remains — this reason is also persisted on the ledger, echoed on every
@@ -1538,12 +2073,36 @@ function qualityFeedback(attempt, remaining) {
1538
2073
  function finalFailureReason(attempt, used, threshold) {
1539
2074
  const failed = attempt.criteria.filter((criterion) => !criterion.passed);
1540
2075
  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) {
2076
+ const located = locatedFindingLines(attempt);
2077
+ if (located.length > 0) {
1542
2078
  lines.push("验收报告记录的可定位问题:");
1543
- for (const finding of attempt.findings) lines.push("- " + finding);
2079
+ for (const line of located) lines.push("- " + line);
1544
2080
  } else lines.push("本次验收没有记录可定位的问题;请对照任务要求与验收面板中的判据得分核对证据中的真实输出。");
1545
2081
  return lines.join("\n");
1546
2082
  }
2083
+ /**
2084
+ * The locatable problem lines of one attempt, structured findings first.
2085
+ *
2086
+ * A structured finding renders the requirement, the observed fact, the gap and
2087
+ * the citation in a fixed order, so the fixing agent reads WHAT was required,
2088
+ * WHAT was seen, WHY it falls short and WHERE to look — never a bare complaint.
2089
+ * @param attempt - the attempt whose findings are rendered.
2090
+ * @returns one line per finding, bounded.
2091
+ */
2092
+ function locatedFindingLines(attempt) {
2093
+ const structured = attempt.criterionFindings ?? [];
2094
+ if (structured.length > 0) return structured.map((finding) => {
2095
+ const locator = "[" + finding.criterionId + " " + finding.location + "] \"" + clip(finding.quote, 200) + "\"";
2096
+ const action = finding.action === void 0 || finding.action === "" ? "" : " → 建议核实:" + finding.action;
2097
+ return locator + " 要求:" + finding.requirement + " 观测:" + finding.observation + " 差异:" + finding.gap + action;
2098
+ });
2099
+ return [...attempt.findings];
2100
+ }
2101
+ /** Bound one text for a single-line rendering. */
2102
+ function clip(text, max) {
2103
+ const value = text.replace(/s+/g, " ").trim();
2104
+ return value.length <= max ? value : value.slice(0, max) + "…";
2105
+ }
1547
2106
  /** `0.42` style percentage. */
1548
2107
  function percent(value) {
1549
2108
  return (value * 100).toFixed(1) + "%";
@@ -1737,6 +2296,7 @@ function isTaskRecordShape(value) {
1737
2296
  if (entry.initiatedBy !== void 0 && typeof entry.initiatedBy !== "string") return false;
1738
2297
  if (entry.frozenBy !== void 0 && typeof entry.frozenBy !== "string") return false;
1739
2298
  if (entry.frozenAt !== void 0 && typeof entry.frozenAt !== "number") return false;
2299
+ if (entry.external !== void 0 && typeof entry.external !== "boolean") return false;
1740
2300
  if (entry.verification !== void 0 && normalizeVerification(entry.verification) === void 0) return false;
1741
2301
  }
1742
2302
  return true;
@@ -1749,23 +2309,62 @@ function isTaskRecord(value) {
1749
2309
  function normalizeStatus(status) {
1750
2310
  return isTaskStatus(status) ? status : "todo";
1751
2311
  }
2312
+ /** A persisted ms timestamp, kept only when it is a usable finite number. */
2313
+ function optionalInstant(value) {
2314
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2315
+ }
2316
+ /** A persisted positive-integer run cap, or undefined for unlimited/repair. */
2317
+ function optionalRunCap(value) {
2318
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : void 0;
2319
+ }
2320
+ /** A persisted stop reason, kept only when it names a reason this build knows. */
2321
+ function optionalStopReason(value) {
2322
+ return typeof value === "string" && SCHEDULE_STOP_REASONS.includes(value) ? value : void 0;
2323
+ }
1752
2324
  /**
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).
2325
+ * Repair a persisted schedule rule. Both modes are repaired field by field and
2326
+ * a rule that carries no usable plan is dropped (the task row survives):
2327
+ * a recurring rule without a well-formed 5-field cron, or a one-shot without a
2328
+ * finite planned instant. A rule written before `mode` existed is a cron rule.
1756
2329
  */
1757
2330
  function normalizeSchedule(schedule) {
1758
2331
  if (typeof schedule !== "object" || schedule === null) return void 0;
1759
2332
  const rule = schedule;
2333
+ const timeZone = typeof rule.timeZone === "string" && isValidTimeZone(rule.timeZone) ? rule.timeZone : void 0;
2334
+ const runCountValue = rule.runCount;
2335
+ const runCount = typeof runCountValue === "number" && Number.isInteger(runCountValue) && runCountValue >= 0 ? runCountValue : 0;
2336
+ const endedAt = optionalInstant(rule.endedAt);
2337
+ const endedReason = optionalStopReason(rule.endedReason);
2338
+ const skippedAt = optionalInstant(rule.skippedAt);
2339
+ const skippedReason = optionalStopReason(rule.skippedReason);
2340
+ const common = {
2341
+ enabled: rule.enabled === true,
2342
+ ...timeZone === void 0 ? {} : { timeZone },
2343
+ nextRunAt: optionalInstant(rule.nextRunAt),
2344
+ lastTriggeredAt: optionalInstant(rule.lastTriggeredAt),
2345
+ runCount,
2346
+ ...endedAt === void 0 ? {} : { endedAt },
2347
+ ...endedReason === void 0 ? {} : { endedReason },
2348
+ ...skippedAt === void 0 ? {} : { skippedAt },
2349
+ ...skippedReason === void 0 ? {} : { skippedReason }
2350
+ };
2351
+ if (rule.mode === "once") {
2352
+ const at = optionalInstant(rule.at);
2353
+ if (at === void 0) return void 0;
2354
+ return {
2355
+ ...common,
2356
+ mode: "once",
2357
+ at
2358
+ };
2359
+ }
1760
2360
  if (typeof rule.cron !== "string") return void 0;
1761
2361
  if (rule.cron.trim() === "" || !isValidCron(rule.cron)) return void 0;
1762
- const timeZone = typeof rule.timeZone === "string" && isValidTimeZone(rule.timeZone) ? rule.timeZone : void 0;
2362
+ const maxRuns = optionalRunCap(rule.maxRuns);
1763
2363
  return {
1764
- enabled: rule.enabled === true,
2364
+ ...common,
2365
+ mode: "cron",
1765
2366
  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
2367
+ ...maxRuns === void 0 ? {} : { maxRuns }
1769
2368
  };
1770
2369
  }
1771
2370
  /**
@@ -1842,14 +2441,15 @@ function parseLedger(raw) {
1842
2441
  runGroupId: normalizeTargetId(execution.runGroupId),
1843
2442
  ownResult: isExecutionOutcome(execution.ownResult) ? execution.ownResult : void 0,
1844
2443
  ownError: typeof execution.ownError === "string" ? execution.ownError : void 0,
1845
- verification: normalizeVerification(execution.verification)
2444
+ verification: normalizeVerification(execution.verification),
2445
+ external: execution.external === true ? true : void 0
1846
2446
  }));
1847
2447
  task.workspaceId = normalizeTargetId(row.workspaceId);
1848
2448
  task.mode = normalizeTargetId(row.mode);
1849
2449
  task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
1850
2450
  task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
1851
2451
  task.reuseSession = row.reuseSession === true ? true : void 0;
1852
- task.goalRun = row.goalRun === false ? false : void 0;
2452
+ task.goalRun = row.goalRun === true ? true : void 0;
1853
2453
  task.skipVerification = row.skipVerification === true ? true : void 0;
1854
2454
  task.freeze = normalizeFreeze(row.freeze);
1855
2455
  task.handover = normalizeHandover(row.handover);
@@ -2002,21 +2602,49 @@ function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1, hostTimeZon
2002
2602
  model: input.model ?? parent.model
2003
2603
  }, now, id);
2004
2604
  const requested = input.schedule;
2005
- if (requested?.enabled === true && requested.cron.trim() !== "" && isValidCron(requested.cron)) {
2006
- const cron = requested.cron.trim();
2605
+ if (requested?.enabled === true) {
2007
2606
  const requestedZone = requested.timeZone;
2008
2607
  if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
2009
2608
  task: void 0,
2010
2609
  tasks,
2011
2610
  error: "invalid schedule time zone"
2012
2611
  };
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);
2612
+ if (requested.mode === "once") {
2613
+ const at = requested.at;
2614
+ if (at === void 0 || !Number.isInteger(at) || at <= now) return {
2615
+ task: void 0,
2616
+ tasks,
2617
+ error: "invalid schedule instant"
2618
+ };
2619
+ task = withSchedule(task, {
2620
+ enabled: true,
2621
+ mode: "once",
2622
+ at,
2623
+ timeZone: requestedZone,
2624
+ nextRunAt: at,
2625
+ runCount: 0
2626
+ }, now);
2627
+ } else {
2628
+ const cron = (requested.cron ?? "").trim();
2629
+ if (cron !== "" && isValidCron(cron)) {
2630
+ const maxRuns = requested.maxRuns;
2631
+ if (maxRuns !== void 0 && (!Number.isInteger(maxRuns) || maxRuns < 1)) return {
2632
+ task: void 0,
2633
+ tasks,
2634
+ error: "invalid schedule run cap"
2635
+ };
2636
+ const nextRunAt = nextRunAtMs(cron, now, requestedZone ?? hostTimeZone);
2637
+ if (nextRunAt !== void 0) task = withSchedule(task, {
2638
+ enabled: true,
2639
+ mode: "cron",
2640
+ cron,
2641
+ maxRuns,
2642
+ timeZone: requestedZone,
2643
+ nextRunAt,
2644
+ runCount: 0
2645
+ }, now);
2646
+ }
2647
+ }
2020
2648
  }
2021
2649
  return {
2022
2650
  task,
@@ -2047,16 +2675,30 @@ function applyDeleteTask(tasks, selectedTaskId, id) {
2047
2675
  //#endregion
2048
2676
  //#region src/core/use-cases/task-schedule.ts
2049
2677
  /**
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.
2678
+ * Schedule use cases: arm/disarm a task's scheduling rule, and record what a
2679
+ * due occurrence did to it. Pure ledger transitions (no persistence or notify
2680
+ * — the controller orchestrates those). Validation and next-run computation
2681
+ * live here, sharing the core cron parser (schedule.ts) and the withSchedule
2682
+ * transition.
2683
+ *
2684
+ * A rule is either a recurring cron rule or a single planned instant, and
2685
+ * either kind may be capped by a run budget. The counter (`runCount`) moves
2686
+ * only when the Host actually opened an execution; a skipped occurrence
2687
+ * (busy, unconfirmed permission, missed instant, unreachable plan) moves the
2688
+ * rule without consuming its budget.
2689
+ */
2690
+ /**
2691
+ * Set an on-board task's scheduling rule. An unknown or archived task, an
2692
+ * unusable zone, a blank or invalid cron, a one-shot with no usable instant,
2693
+ * a one-shot armed in the past, and a run cap that is not a positive integer
2694
+ * are all rejected (state untouched). An enabled rule computes its next run
2695
+ * instant immediately — a one-shot's next instant IS its planned instant — and
2696
+ * a disabled rule carries none.
2697
+ *
2698
+ * Arming a rule that was not enabled, or switching its kind, starts a fresh
2699
+ * budget: the run counter and any previous stop/skip record are cleared. Edits
2700
+ * to a live rule (expression, zone, cap) keep the consumed count, so an
2701
+ * already-spent budget can never silently reset and overrun.
2060
2702
  * @param tasks - current ledger.
2061
2703
  * @param id - the task to schedule.
2062
2704
  * @param patch - rule fields to change (absent fields keep their current value).
@@ -2070,47 +2712,168 @@ function applySetSchedule(tasks, id, patch, now, hostTimeZone) {
2070
2712
  applied: false
2071
2713
  };
2072
2714
  const current = task.schedule;
2715
+ const mode = patch.mode ?? current?.mode ?? "cron";
2716
+ const timeZone = patch.timeZone === void 0 ? current?.timeZone : patch.timeZone ?? void 0;
2717
+ if (timeZone !== void 0 && !isValidTimeZone(timeZone)) return {
2718
+ tasks,
2719
+ applied: false
2720
+ };
2721
+ const enabled = patch.enabled ?? current?.enabled ?? false;
2722
+ const restart = enabled && current?.enabled !== true || (current?.mode ?? "cron") !== mode;
2723
+ const runCount = restart ? 0 : current?.runCount ?? 0;
2724
+ const effectiveZone = timeZone ?? hostTimeZone;
2725
+ const cleared = restart ? {
2726
+ endedAt: void 0,
2727
+ endedReason: void 0,
2728
+ skippedAt: void 0,
2729
+ skippedReason: void 0
2730
+ } : {
2731
+ endedAt: current?.endedAt,
2732
+ endedReason: current?.endedReason,
2733
+ skippedAt: current?.skippedAt,
2734
+ skippedReason: current?.skippedReason
2735
+ };
2736
+ if (mode === "once") {
2737
+ const at = patch.at ?? current?.at;
2738
+ if (at === void 0 || !Number.isInteger(at)) return {
2739
+ tasks,
2740
+ applied: false
2741
+ };
2742
+ if (enabled && at <= now) return {
2743
+ tasks,
2744
+ applied: false
2745
+ };
2746
+ return {
2747
+ tasks: tasks.map((candidate) => candidate.id !== id ? candidate : withSchedule(candidate, {
2748
+ enabled,
2749
+ mode: "once",
2750
+ at,
2751
+ cron: void 0,
2752
+ maxRuns: void 0,
2753
+ timeZone: timeZone ?? void 0,
2754
+ nextRunAt: enabled ? at : void 0,
2755
+ runCount,
2756
+ ...cleared
2757
+ }, now)),
2758
+ applied: true
2759
+ };
2760
+ }
2073
2761
  const cron = (patch.cron ?? current?.cron ?? "").trim();
2074
2762
  if (cron === "" || !isValidCron(cron)) return {
2075
2763
  tasks,
2076
2764
  applied: false
2077
2765
  };
2078
- const requestedZone = patch.timeZone === void 0 ? current?.timeZone : patch.timeZone ?? void 0;
2079
- if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
2766
+ const maxRuns = ("maxRuns" in patch ? patch.maxRuns : current?.maxRuns) ?? void 0;
2767
+ if (maxRuns !== void 0 && (!Number.isInteger(maxRuns) || maxRuns < 1)) return {
2080
2768
  tasks,
2081
2769
  applied: false
2082
2770
  };
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 {
2771
+ const spent = maxRuns !== void 0 && runCount >= maxRuns;
2772
+ const armed = enabled && !spent;
2773
+ const nextRunAt = armed ? nextRunAtMs(cron, now, effectiveZone) : void 0;
2774
+ if (armed && nextRunAt === void 0) return {
2086
2775
  tasks,
2087
2776
  applied: false
2088
2777
  };
2778
+ const exhausted = enabled && spent;
2089
2779
  return {
2090
- tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
2091
- enabled,
2780
+ tasks: tasks.map((candidate) => candidate.id !== id ? candidate : withSchedule(candidate, {
2781
+ enabled: armed,
2782
+ mode: "cron",
2092
2783
  cron,
2093
- timeZone: requestedZone ?? void 0,
2094
- nextRunAt
2095
- }, now) : candidate),
2784
+ at: void 0,
2785
+ maxRuns,
2786
+ timeZone: timeZone ?? void 0,
2787
+ nextRunAt,
2788
+ runCount,
2789
+ ...exhausted ? {
2790
+ endedAt: now,
2791
+ endedReason: "limit"
2792
+ } : cleared
2793
+ }, now)),
2096
2794
  applied: true
2097
2795
  };
2098
2796
  }
2099
2797
  /**
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).
2798
+ * Refund the one scheduled run an occurrence consumed before its launch failed
2799
+ * without ever creating a session. Nothing ran, so the run budget is restored
2800
+ * and the rule is left able to serve its next occurrence — a launch failure
2801
+ * must never silently spend the user's limited budget.
2802
+ *
2803
+ * A recurring rule keeps its already-rolled-forward target; a fire that ended
2804
+ * the rule at its cap (no target left) is restored and re-armed at the next
2805
+ * occurrence, so a failed launch consumes nothing. A one-shot has no later
2806
+ * occurrence, so it is left stopped with `launch-failed` and its run returned.
2807
+ * A rule the user disabled in the meantime is not resurrected.
2808
+ * @param tasks - current ledger.
2809
+ * @param id - the task whose occurrence is being refunded.
2810
+ * @param now - clock instant (ms epoch).
2811
+ * @param hostTimeZone - zone a rule with no stored zone follows.
2812
+ * @returns the next ledger.
2813
+ */
2814
+ function applyScheduleRefund(tasks, id, now, hostTimeZone) {
2815
+ return tasks.map((task) => {
2816
+ if (task.id !== id || task.archivedAt !== void 0 || task.schedule === void 0) return task;
2817
+ const rule = task.schedule;
2818
+ const runCount = Math.max(rule.runCount - 1, 0);
2819
+ if (rule.mode === "once") return withSchedule(task, {
2820
+ enabled: false,
2821
+ runCount,
2822
+ nextRunAt: void 0,
2823
+ endedAt: now,
2824
+ endedReason: "launch-failed",
2825
+ skippedAt: void 0,
2826
+ skippedReason: void 0
2827
+ }, now);
2828
+ const refunded = withSchedule(task, {
2829
+ runCount,
2830
+ skippedAt: now,
2831
+ skippedReason: "launch-failed"
2832
+ }, now);
2833
+ if (rule.nextRunAt !== void 0 || rule.endedReason !== "limit") return refunded;
2834
+ const cron = rule.cron ?? "";
2835
+ const next = cron === "" ? void 0 : nextRunAtMs(cron, now, rule.timeZone ?? hostTimeZone);
2836
+ return withSchedule(refunded, next === void 0 ? {
2837
+ enabled: false,
2838
+ endedAt: now,
2839
+ endedReason: "no-target"
2840
+ } : {
2841
+ enabled: true,
2842
+ nextRunAt: next,
2843
+ endedAt: void 0,
2844
+ endedReason: void 0
2845
+ }, now);
2846
+ });
2847
+ }
2848
+ /**
2849
+ * Record what a due occurrence did to a task's rule: advance the run counter
2850
+ * by the executions it opened, set the next target (or stop the rule), and
2851
+ * note a terminal reason or a skipped occurrence. Nothing is written for a
2852
+ * task that has no rule or has been archived mid-tick.
2103
2853
  * @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.
2854
+ * @param id - the task whose rule fired.
2855
+ * @param progress - the occurrence's effect.
2107
2856
  * @param now - clock instant (ms epoch).
2857
+ * @returns the next ledger.
2108
2858
  */
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);
2859
+ function applyScheduleProgress(tasks, id, progress, now) {
2860
+ return tasks.map((task) => {
2861
+ if (task.id !== id || task.archivedAt !== void 0 || task.schedule === void 0) return task;
2862
+ return withSchedule(task, {
2863
+ runCount: task.schedule.runCount + progress.consumed,
2864
+ nextRunAt: progress.nextRunAt,
2865
+ ...progress.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: progress.lastTriggeredAt },
2866
+ ...progress.endedReason === void 0 ? {} : {
2867
+ enabled: false,
2868
+ endedAt: now,
2869
+ endedReason: progress.endedReason
2870
+ },
2871
+ ...progress.skippedReason === void 0 ? {} : {
2872
+ skippedAt: now,
2873
+ skippedReason: progress.skippedReason
2874
+ }
2875
+ }, now);
2876
+ });
2114
2877
  }
2115
2878
  //#endregion
2116
2879
  //#region src/core/use-cases/task-parent.ts
@@ -2375,7 +3138,7 @@ function applyUpdateTask(tasks, id, patch, now) {
2375
3138
  if ("permission" in patch && patch.permission !== void 0 && patch.permission !== task.permission || "handover" in patch) next.permissionConfirmedAt = void 0;
2376
3139
  if ("reuseSession" in patch) next.reuseSession = patch.reuseSession === true ? true : void 0;
2377
3140
  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;
3141
+ if ("goalRun" in patch) next.goalRun = patch.goalRun === true ? true : void 0;
2379
3142
  if ("skipVerification" in patch) next.skipVerification = patch.skipVerification === true ? true : void 0;
2380
3143
  if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
2381
3144
  if (mode !== void 0 || "mode" in patch) next.mode = mode;
@@ -2385,17 +3148,20 @@ function applyUpdateTask(tasks, id, patch, now) {
2385
3148
  });
2386
3149
  }
2387
3150
  /**
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.
3151
+ * Every older generation the loader migrates in place. v5 only added the
3152
+ * per-execution acceptance block and v6 only added the scheduling mode and run
3153
+ * budget, so the migration is the normalization pass that re-validates each
3154
+ * row; a document from any listed generation upgrades losslessly.
2392
3155
  */
2393
3156
  const TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS = [
3157
+ 5,
2394
3158
  4,
2395
3159
  3,
2396
3160
  2
2397
3161
  ];
2398
3162
  const TASK_BOARD_API_PREFIX = "/api/task-board";
3163
+ /** Longest accepted free-text summary on an external outcome record. */
3164
+ const EXTERNAL_SUMMARY_MAX_LENGTH = 1e3;
2399
3165
  /** Strict parse of a `/parse` request body; undefined rejects the request. */
2400
3166
  function parseTaskParseRequest(value) {
2401
3167
  if (typeof value !== "object" || value === null) return void 0;
@@ -2433,15 +3199,38 @@ function hasForbiddenImportField(value) {
2433
3199
  function optionalFiniteNumber(value) {
2434
3200
  return value === void 0 || typeof value === "number" && Number.isFinite(value);
2435
3201
  }
3202
+ /** Whether a stop-reason field names a reason this build understands. */
3203
+ function validStopReason(value) {
3204
+ return value === void 0 || typeof value === "string" && SCHEDULE_STOP_REASONS.includes(value);
3205
+ }
3206
+ /**
3207
+ * Whether an imported schedule rule is structurally usable. A one-shot must
3208
+ * carry its planned instant and a recurring rule its cron expression; the
3209
+ * counter and stop record are optional (a rule written before them simply has
3210
+ * none), but a value that IS present must be usable so the import cannot smuggle
3211
+ * a rule the scheduler would misread.
3212
+ */
3213
+ function validImportedSchedule(value) {
3214
+ const schedule = record(value);
3215
+ if (schedule === void 0 || typeof schedule.enabled !== "boolean") return false;
3216
+ if (schedule.mode !== void 0 && schedule.mode !== "cron" && schedule.mode !== "once") return false;
3217
+ if (schedule.mode === "once") {
3218
+ if (typeof schedule.at !== "number" || !Number.isFinite(schedule.at)) return false;
3219
+ } else if (typeof schedule.cron !== "string") return false;
3220
+ if (schedule.cron !== void 0 && typeof schedule.cron !== "string") return false;
3221
+ if (schedule.at !== void 0 && (typeof schedule.at !== "number" || !Number.isFinite(schedule.at))) return false;
3222
+ if (schedule.maxRuns !== void 0 && (typeof schedule.maxRuns !== "number" || !Number.isInteger(schedule.maxRuns) || schedule.maxRuns < 1)) return false;
3223
+ if (schedule.runCount !== void 0 && (typeof schedule.runCount !== "number" || !Number.isInteger(schedule.runCount) || schedule.runCount < 0)) return false;
3224
+ if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false;
3225
+ if (!optionalFiniteNumber(schedule.endedAt) || !optionalFiniteNumber(schedule.skippedAt)) return false;
3226
+ if (!validStopReason(schedule.endedReason) || !validStopReason(schedule.skippedReason)) return false;
3227
+ if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
3228
+ return true;
3229
+ }
2436
3230
  function validImportedKnownFields(value) {
2437
3231
  if (value.tags !== void 0 && !isTaskTagList(value.tags)) return false;
2438
3232
  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
- }
3233
+ if (value.schedule !== void 0 && !validImportedSchedule(value.schedule)) return false;
2445
3234
  if (value.executions !== void 0) {
2446
3235
  if (!Array.isArray(value.executions)) return false;
2447
3236
  for (const item of value.executions) {
@@ -2455,6 +3244,7 @@ function validImportedKnownFields(value) {
2455
3244
  "cancelled"
2456
3245
  ].includes(String(execution.result))) return false;
2457
3246
  if (execution.initiatedBy !== void 0 && typeof execution.initiatedBy !== "string") return false;
3247
+ if (execution.external !== void 0 && typeof execution.external !== "boolean") return false;
2458
3248
  if (execution.frozenBy !== void 0 && typeof execution.frozenBy !== "string") return false;
2459
3249
  if (execution.frozenAt !== void 0 && typeof execution.frozenAt !== "number") return false;
2460
3250
  if (execution.verification !== void 0 && normalizeVerification(execution.verification) === void 0) return false;
@@ -2483,16 +3273,11 @@ function importedTask(value) {
2483
3273
  result: execution.result,
2484
3274
  error: execution.error,
2485
3275
  ...execution.initiatedBy === void 0 ? {} : { initiatedBy: execution.initiatedBy },
3276
+ ...execution.external === true ? { external: true } : {},
2486
3277
  ...execution.frozenAt === void 0 ? {} : { frozenAt: execution.frozenAt },
2487
3278
  ...execution.frozenBy === void 0 ? {} : { frozenBy: execution.frozenBy }
2488
3279
  })),
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
- } },
3280
+ ...task.schedule === void 0 ? {} : { schedule: { ...task.schedule } },
2496
3281
  ...task.parentId === void 0 ? {} : { parentId: task.parentId },
2497
3282
  ...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
2498
3283
  ...task.mode === void 0 ? {} : { mode: task.mode },
@@ -2568,10 +3353,17 @@ function createInput(value) {
2568
3353
  const schedule = record(input.schedule);
2569
3354
  if (schedule === void 0 || !exactKeys(schedule, [
2570
3355
  "enabled",
3356
+ "mode",
2571
3357
  "cron",
2572
- "timeZone"
3358
+ "at",
3359
+ "timeZone",
3360
+ "maxRuns"
2573
3361
  ])) return false;
2574
- if (typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
3362
+ if (typeof schedule.enabled !== "boolean") return false;
3363
+ if (schedule.mode !== void 0 && schedule.mode !== "cron" && schedule.mode !== "once") return false;
3364
+ if (schedule.cron !== void 0 && typeof schedule.cron !== "string") return false;
3365
+ if (schedule.at !== void 0 && (typeof schedule.at !== "number" || !Number.isInteger(schedule.at))) return false;
3366
+ if (schedule.maxRuns !== void 0 && (typeof schedule.maxRuns !== "number" || !Number.isInteger(schedule.maxRuns) || schedule.maxRuns < 1)) return false;
2575
3367
  if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
2576
3368
  }
2577
3369
  return true;
@@ -2623,9 +3415,12 @@ function schedulePatch(value) {
2623
3415
  const patch = record(value);
2624
3416
  return patch !== void 0 && exactKeys(patch, [
2625
3417
  "enabled",
3418
+ "mode",
2626
3419
  "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));
3420
+ "at",
3421
+ "timeZone",
3422
+ "maxRuns"
3423
+ ]) && (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
3424
  }
2630
3425
  function parseActionEnvelope(value) {
2631
3426
  const parsed = parseEnvelopeAction(value);
@@ -2804,11 +3599,38 @@ function parseEnvelopeAction(value) {
2804
3599
  ...action.payload === void 0 ? {} : { payload: action.payload }
2805
3600
  }
2806
3601
  };
3602
+ case "record-external-outcome": {
3603
+ if (!exactKeys(action, [
3604
+ "kind",
3605
+ "taskId",
3606
+ "result",
3607
+ "initiatedBy",
3608
+ "summary"
3609
+ ])) return void 0;
3610
+ if (taskId === void 0) return void 0;
3611
+ if (action.result !== "succeeded" && action.result !== "failed") return void 0;
3612
+ if (typeof action.initiatedBy !== "string") return void 0;
3613
+ const initiatedBy = action.initiatedBy.trim();
3614
+ if (initiatedBy === "" || initiatedBy.length > 200) return void 0;
3615
+ if (action.summary !== void 0 && typeof action.summary !== "string") return void 0;
3616
+ const summary = typeof action.summary === "string" ? action.summary.trim().slice(0, EXTERNAL_SUMMARY_MAX_LENGTH) : "";
3617
+ return {
3618
+ requestId: envelope.requestId,
3619
+ action: {
3620
+ kind: "record-external-outcome",
3621
+ taskId,
3622
+ result: action.result,
3623
+ initiatedBy,
3624
+ ...summary === "" ? {} : { summary }
3625
+ }
3626
+ };
3627
+ }
2807
3628
  case "confirm-permission":
2808
3629
  case "delete":
2809
3630
  case "archive":
2810
3631
  case "restore":
2811
3632
  case "settle":
3633
+ case "reset-verification":
2812
3634
  case "run":
2813
3635
  case "rerun":
2814
3636
  if (!exactKeys(action, ["kind", "taskId"])) return void 0;
@@ -2836,8 +3658,25 @@ function timeZone() {
2836
3658
  function scheduleZone(schedule) {
2837
3659
  return schedule.timeZone ?? timeZone();
2838
3660
  }
2839
- function cloneTasks(tasks) {
2840
- return JSON.parse(JSON.stringify(tasks));
3661
+ /**
3662
+ * The task collection one board read hands out.
3663
+ *
3664
+ * A read must not hand a consumer the ledger's own array: the state route, every
3665
+ * action response and the agent tools all receive this value in-process, so an
3666
+ * in-place rewrite of that array (splice, sort, push, `length = 0`) would edit
3667
+ * the authoritative document from outside the write path. One level of copying
3668
+ * is what that contract needs, and it is all it needs: every write in this class
3669
+ * replaces the document's array (`this.document.tasks = ...`) and replaces the
3670
+ * records it changes rather than mutating them, so the records a caller receives
3671
+ * are stable values. Copying them as well only rebuilt the entire document on
3672
+ * every board read - measured at 0.43 ms per read for 400 cards with 2
3673
+ * executions each and 2.72 ms for 400 cards with 20 - and bought nothing a
3674
+ * consumer relies on. Callers still must not mutate a record in place; that is
3675
+ * what the shared record objects now make possible, and no consumer in this
3676
+ * package does it.
3677
+ */
3678
+ function tasksForRead(tasks) {
3679
+ return [...tasks];
2841
3680
  }
2842
3681
  /**
2843
3682
  * Process states that are dead but still occupy the PID table: `Z` (zombie)
@@ -3103,15 +3942,18 @@ function parseHostTasks(values) {
3103
3942
  const rawSchedule = rawById.get(task.id)?.schedule;
3104
3943
  if (typeof rawSchedule !== "object" || rawSchedule === null) return task;
3105
3944
  const schedule = rawSchedule;
3945
+ if (schedule.mode === "once") return task;
3106
3946
  if (typeof schedule.cron !== "string" || isValidCron(schedule.cron)) return task;
3107
3947
  return {
3108
3948
  ...task,
3109
3949
  schedule: {
3110
3950
  enabled: false,
3951
+ mode: "cron",
3111
3952
  cron: schedule.cron,
3112
3953
  ...typeof schedule.timeZone === "string" && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {},
3113
3954
  nextRunAt: void 0,
3114
- lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0
3955
+ lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0,
3956
+ runCount: typeof schedule.runCount === "number" && Number.isInteger(schedule.runCount) && schedule.runCount >= 0 ? schedule.runCount : 0
3115
3957
  }
3116
3958
  };
3117
3959
  });
@@ -3202,27 +4044,31 @@ var HostTaskLedger = class {
3202
4044
  const { revision, scheduler } = this.summary();
3203
4045
  return {
3204
4046
  revision,
3205
- tasks: cloneTasks(this.document.tasks),
4047
+ tasks: tasksForRead(this.document.tasks),
3206
4048
  scheduler
3207
4049
  };
3208
4050
  }
3209
4051
  /**
3210
- * Runtime-only projection for the 5 s Host poll. It copies just primitive
4052
+ * Runtime-only projection for the Host poll. It copies just primitive
3211
4053
  * identifiers and timestamps, never the complete task/execution history or
3212
4054
  * an authoritative mutable object from the ledger.
3213
4055
  */
3214
4056
  runtimeView() {
3215
4057
  let armedSchedules = 0;
4058
+ let runningCards = 0;
3216
4059
  const teamGroups = /* @__PURE__ */ new Set();
3217
4060
  for (const task of this.document.tasks) {
3218
4061
  if (task.teamRun !== true) continue;
3219
4062
  for (const execution of task.executions) if (execution.runGroupId !== void 0) teamGroups.add(execution.runGroupId);
3220
4063
  }
3221
4064
  const openExecutions = [];
4065
+ const openSessionIds = [];
3222
4066
  for (const task of this.document.tasks) {
3223
4067
  if (task.archivedAt === void 0 && task.schedule?.enabled === true) armedSchedules += 1;
4068
+ if (task.status === "running") runningCards += 1;
3224
4069
  for (const execution of task.executions) {
3225
4070
  if (execution.endedAt !== void 0) continue;
4071
+ if (execution.sessionId !== void 0) openSessionIds.push(execution.sessionId);
3226
4072
  if (execution.ownResult !== void 0) continue;
3227
4073
  openExecutions.push({
3228
4074
  taskId: task.id,
@@ -3235,7 +4081,9 @@ var HostTaskLedger = class {
3235
4081
  }
3236
4082
  return {
3237
4083
  armedSchedules,
3238
- openExecutions
4084
+ openExecutions,
4085
+ openSessionIds,
4086
+ needsSessionState: openExecutions.length > 0 || runningCards > 0
3239
4087
  };
3240
4088
  }
3241
4089
  /** Count armed, non-archived schedules without cloning task histories. */
@@ -3272,7 +4120,8 @@ var HostTaskLedger = class {
3272
4120
  if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
3273
4121
  due.push({
3274
4122
  taskId: task.id,
3275
- cron: schedule.cron,
4123
+ mode: schedule.mode,
4124
+ ...schedule.cron === void 0 ? {} : { cron: schedule.cron },
3276
4125
  timeZone: scheduleZone(schedule),
3277
4126
  nextRunAt: schedule.nextRunAt
3278
4127
  });
@@ -3315,41 +4164,144 @@ var HostTaskLedger = class {
3315
4164
  /**
3316
4165
  * Open the cascade one due schedule triggers: an empty array means nothing
3317
4166
  * ran (already running, or a participant whose elevated permission is still
3318
- * unconfirmed), and the rule rolls to its next occurrence either way.
4167
+ * unconfirmed, a one-shot whose instant was missed, or an exhausted budget),
4168
+ * and the rule's own state says which.
4169
+ *
4170
+ * The run budget is enforced HERE, inside the single ledger writer: a rule
4171
+ * whose counter already reached its cap is stopped instead of opened, so no
4172
+ * restart, repeated timer fire or re-arm can overrun it.
4173
+ * @param taskId - the task whose rule came due.
4174
+ * @param triggeredAt - the instant the occurrence came due.
4175
+ * @returns the executions this occurrence opened.
3319
4176
  */
3320
- openScheduled(taskId, nextRunAt, triggeredAt) {
4177
+ openScheduled(taskId, triggeredAt) {
3321
4178
  const task = this.document.tasks.find((item) => item.id === taskId);
3322
4179
  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
- };
4180
+ const schedule = task.schedule;
4181
+ if (schedule === void 0 || !schedule.enabled) return [];
4182
+ if (scheduleExhausted(schedule)) {
4183
+ this.progressSchedule(taskId, {
4184
+ consumed: 0,
4185
+ endedReason: schedule.mode === "once" ? "fired" : "limit"
4186
+ }, triggeredAt);
4187
+ return [];
4188
+ }
3327
4189
  const refusal = this.bindingRefusal(task);
3328
4190
  if (refusal !== void 0) {
3329
4191
  this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, "schedule")}`;
3330
- rollForward();
4192
+ this.skipOccurrence(taskId, "permission", triggeredAt);
3331
4193
  return [];
3332
4194
  }
3333
4195
  if (hasOpenExecution(task)) {
3334
- rollForward();
4196
+ this.skipOccurrence(taskId, "busy", triggeredAt);
3335
4197
  return [];
3336
4198
  }
3337
- return this.startCascade(task, triggeredAt, void 0, false, nextRunAt);
4199
+ if (schedule.mode === "once") return this.startCascade(task, triggeredAt, void 0, false, {
4200
+ consumed: 1,
4201
+ lastTriggeredAt: triggeredAt,
4202
+ endedReason: "fired"
4203
+ });
4204
+ const next = nextRunAtMs(schedule.cron ?? "", schedule.nextRunAt ?? triggeredAt, scheduleZone(schedule));
4205
+ if (next === void 0) {
4206
+ this.progressSchedule(taskId, {
4207
+ consumed: 0,
4208
+ endedReason: "no-target"
4209
+ }, triggeredAt);
4210
+ return [];
4211
+ }
4212
+ const consumed = schedule.runCount + 1;
4213
+ const budget = scheduleRunBudget(schedule);
4214
+ const spent = budget !== void 0 && consumed >= budget;
4215
+ return this.startCascade(task, triggeredAt, void 0, false, {
4216
+ consumed: 1,
4217
+ nextRunAt: spent ? void 0 : next,
4218
+ lastTriggeredAt: triggeredAt,
4219
+ ...spent ? { endedReason: "limit" } : {}
4220
+ });
4221
+ }
4222
+ /**
4223
+ * Record a due occurrence that opened no execution: roll a recurring rule to
4224
+ * its next occurrence (budget untouched), or stop it when there is none. A
4225
+ * one-shot has no second occurrence, so it always stops here with the reason.
4226
+ */
4227
+ skipOccurrence(taskId, reason, now) {
4228
+ const schedule = this.document.tasks.find((item) => item.id === taskId)?.schedule;
4229
+ if (schedule === void 0) return;
4230
+ if (schedule.mode === "once") {
4231
+ this.progressSchedule(taskId, {
4232
+ consumed: 0,
4233
+ endedReason: reason
4234
+ }, now);
4235
+ return;
4236
+ }
4237
+ const next = nextRunAtMs(schedule.cron ?? "", schedule.nextRunAt ?? now, scheduleZone(schedule));
4238
+ if (next === void 0) this.progressSchedule(taskId, {
4239
+ consumed: 0,
4240
+ endedReason: "no-target"
4241
+ }, now);
4242
+ else this.progressSchedule(taskId, {
4243
+ consumed: 0,
4244
+ nextRunAt: next,
4245
+ skippedReason: reason
4246
+ }, now);
4247
+ }
4248
+ /** Apply a schedule-occurrence transition and persist it atomically. */
4249
+ progressSchedule(taskId, progress, now) {
4250
+ this.document.tasks = [...applyScheduleProgress(this.document.tasks, taskId, progress, now)];
4251
+ this.commit();
4252
+ }
4253
+ /**
4254
+ * Refund the one scheduled run an occurrence consumed before its launch
4255
+ * failed without creating a session (see
4256
+ * {@link applyScheduleRefund}). Called by the service on the failed-launch
4257
+ * path only; a manual run and a launch that did reach a session are never
4258
+ * refunded. A no-op for a task with no rule, so a failed cascade member does
4259
+ * not touch anything.
4260
+ * @param taskId - the task whose occurrence is being refunded.
4261
+ * @param now - clock instant (ms epoch).
4262
+ */
4263
+ refundScheduledOccurrence(taskId, now) {
4264
+ const before = this.document.tasks.find((item) => item.id === taskId)?.schedule;
4265
+ if (before === void 0) return;
4266
+ const next = applyScheduleRefund(this.document.tasks, taskId, now, timeZone());
4267
+ const after = next.find((item) => item.id === taskId)?.schedule;
4268
+ if (JSON.stringify(before) === JSON.stringify(after)) return;
4269
+ this.document.tasks = [...next];
4270
+ this.commit();
3338
4271
  }
4272
+ /**
4273
+ * Boot / resume recovery: skip every occurrence that came due while the board
4274
+ * was not running through it. A recurring rule rolls to its next future
4275
+ * target; a one-shot whose instant has passed is stopped as missed, because
4276
+ * there is no later occurrence left to run. Rendering the missed occurrence
4277
+ * is deliberately not attempted: the ACL of a card that fired hours ago is
4278
+ * stale, and the board's own recovery contract is "missed triggers are
4279
+ * skipped, never replayed".
4280
+ */
3339
4281
  skipMissed(now) {
3340
4282
  let changed = false;
3341
4283
  this.document.tasks = this.document.tasks.map((task) => {
3342
4284
  const schedule = task.schedule;
3343
4285
  if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) return task;
3344
4286
  changed = true;
3345
- return {
3346
- ...task,
3347
- schedule: {
3348
- ...schedule,
3349
- nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
3350
- },
3351
- updatedAt: now
3352
- };
4287
+ if (schedule.mode === "once") return withSchedule(task, {
4288
+ enabled: false,
4289
+ nextRunAt: void 0,
4290
+ endedAt: now,
4291
+ endedReason: "missed"
4292
+ }, now);
4293
+ const next = nextRunAtMs(schedule.cron ?? "", now, scheduleZone(schedule));
4294
+ if (next === void 0) return withSchedule(task, {
4295
+ enabled: false,
4296
+ nextRunAt: void 0,
4297
+ endedAt: now,
4298
+ endedReason: "no-target"
4299
+ }, now);
4300
+ return withSchedule(task, {
4301
+ nextRunAt: next,
4302
+ skippedAt: now,
4303
+ skippedReason: "missed"
4304
+ }, now);
3353
4305
  });
3354
4306
  if (changed) this.commit();
3355
4307
  }
@@ -3434,7 +4386,7 @@ var HostTaskLedger = class {
3434
4386
  case "import": {
3435
4387
  const sources = new Set(this.document.scheduler.importedSources ?? []);
3436
4388
  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);
4389
+ const invalidScheduleIds = action.tasks.filter((task) => task.schedule !== void 0 && task.schedule.mode !== "once" && !isValidCron(task.schedule.cron ?? "")).map((task) => task.id);
3438
4390
  const incoming = parseHostTasks(action.tasks);
3439
4391
  const merged = new Map(this.document.tasks.map((task) => [task.id, task]));
3440
4392
  for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id), task) : task);
@@ -3448,7 +4400,8 @@ var HostTaskLedger = class {
3448
4400
  }
3449
4401
  case "create": {
3450
4402
  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");
4403
+ const requested = action.input.schedule;
4404
+ if (requested?.enabled === true && !this.validScheduleRequest(requested, now)) throw new Error("invalid schedule");
3452
4405
  const input = action.input.freeze === void 0 || initiator === void 0 || initiator === "" ? action.input : {
3453
4406
  ...action.input,
3454
4407
  freeze: {
@@ -3507,6 +4460,29 @@ var HostTaskLedger = class {
3507
4460
  this.document.tasks = [...result.tasks];
3508
4461
  break;
3509
4462
  }
4463
+ case "record-external-outcome": {
4464
+ const task = this.document.tasks.find((item) => item.id === action.taskId);
4465
+ if (task === void 0) throw new Error("task not found");
4466
+ if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
4467
+ if (hasOpenExecution(task)) throw new Error("running task cannot receive an external outcome");
4468
+ const execution = {
4469
+ id: crypto.randomUUID(),
4470
+ sessionId: void 0,
4471
+ startedAt: now,
4472
+ endedAt: now,
4473
+ result: action.result,
4474
+ error: action.summary,
4475
+ initiatedBy: action.initiatedBy,
4476
+ external: true
4477
+ };
4478
+ this.document.tasks = this.document.tasks.map((item) => item.id !== action.taskId ? item : {
4479
+ ...item,
4480
+ status: settledStatus(item, action.result),
4481
+ updatedAt: now,
4482
+ executions: retainRecentExecutions([...item.executions, execution])
4483
+ });
4484
+ break;
4485
+ }
3510
4486
  case "settle": {
3511
4487
  const task = this.document.tasks.find((item) => item.id === action.taskId);
3512
4488
  if (task === void 0) throw new Error("task not found");
@@ -3520,6 +4496,26 @@ var HostTaskLedger = class {
3520
4496
  this.finalizeReadyRuns(false);
3521
4497
  break;
3522
4498
  }
4499
+ case "reset-verification": {
4500
+ const task = this.document.tasks.find((item) => item.id === action.taskId);
4501
+ if (task === void 0) throw new Error("task not found");
4502
+ if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
4503
+ const execution = [...task.executions].reverse().find((entry) => entry.endedAt === void 0);
4504
+ if (execution === void 0) throw new Error("task has no open execution");
4505
+ const verification = execution.verification;
4506
+ if (verification === void 0 || verification.applicability !== "enforced") throw new Error("this execution is not gated by task acceptance");
4507
+ const cleared = withoutAcceptanceAnomalies(verification);
4508
+ if (cleared === void 0) throw new Error("this execution has no acceptance anomaly to reset");
4509
+ this.document.tasks = this.document.tasks.map((item) => item.id !== action.taskId ? item : {
4510
+ ...item,
4511
+ updatedAt: now,
4512
+ executions: item.executions.map((entry) => entry.id === execution.id ? {
4513
+ ...entry,
4514
+ verification: cleared
4515
+ } : entry)
4516
+ });
4517
+ break;
4518
+ }
3523
4519
  case "restore": {
3524
4520
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error("running task cannot be restored");
3525
4521
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth);
@@ -3575,6 +4571,21 @@ var HostTaskLedger = class {
3575
4571
  };
3576
4572
  }
3577
4573
  /**
4574
+ * Whether a creation-time schedule request is usable. A one-shot must carry
4575
+ * a whole-millisecond instant in the future; a recurring rule must carry a
4576
+ * valid expression with a reachable occurrence and, when capped, a positive
4577
+ * whole run budget. An unusable zone is refused rather than reinterpreted as
4578
+ * the Host zone.
4579
+ */
4580
+ validScheduleRequest(request, now) {
4581
+ if (request.timeZone !== void 0 && !isValidTimeZone(request.timeZone)) return false;
4582
+ if (request.mode === "once") return typeof request.at === "number" && Number.isInteger(request.at) && request.at > now;
4583
+ const cron = (request.cron ?? "").trim();
4584
+ if (cron === "" || !isValidCron(cron)) return false;
4585
+ if (request.maxRuns !== void 0 && (!Number.isInteger(request.maxRuns) || request.maxRuns < 1)) return false;
4586
+ return nextRunAtMs(cron, now, request.timeZone ?? timeZone()) !== void 0;
4587
+ }
4588
+ /**
3578
4589
  * The binding that makes a run illegal, if any.
3579
4590
  *
3580
4591
  * A plain cascade launches one session per participant, so every participant
@@ -3636,9 +4647,11 @@ var HostTaskLedger = class {
3636
4647
  * @param now - clock instant (ms epoch).
3637
4648
  * @param initiator - the DSH session that asked for the run (audit only).
3638
4649
  * @param rerun - true to reset the root to 'todo' before starting it.
3639
- * @param nextRunAt - when set, the root's schedule also rolls forward.
4650
+ * @param progress - when set, this run was a due occurrence and the root's
4651
+ * rule is advanced (counter, next target, stop/skip record) in the same
4652
+ * atomic commit that opened the executions.
3640
4653
  */
3641
- startCascade(root, now, initiator, rerun = false, nextRunAt) {
4654
+ startCascade(root, now, initiator, rerun = false, progress) {
3642
4655
  const before = this.document.tasks;
3643
4656
  const groupId = crypto.randomUUID();
3644
4657
  const team = root.teamRun === true;
@@ -3657,7 +4670,7 @@ var HostTaskLedger = class {
3657
4670
  }
3658
4671
  if (runs.length === 0) return [];
3659
4672
  let tasks = before.map((task) => started.get(task.id) ?? task);
3660
- if (nextRunAt !== void 0) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now);
4673
+ if (progress !== void 0) tasks = applyScheduleProgress(tasks, root.id, progress, now);
3661
4674
  this.document.tasks = [...tasks];
3662
4675
  this.commit();
3663
4676
  return runs;
@@ -3779,6 +4792,14 @@ var HostTaskLedger = class {
3779
4792
  for (const memberId of members) if (this.settleCascade(memberId, groupId, now)) changed = true;
3780
4793
  return changed;
3781
4794
  }
4795
+ /**
4796
+ * Re-align every armed rule at load/import. A recurring rule is recomputed
4797
+ * from the current instant; a one-shot is armed at its planned instant when
4798
+ * that is still in the future, and stopped as missed once it has passed
4799
+ * (there is no later occurrence to run). A recurring rule recomputed past an
4800
+ * occurrence records the skip, so the board can show that a fire was passed
4801
+ * over rather than silently rewinding.
4802
+ */
3782
4803
  repairSchedules(skipPast, persist = true) {
3783
4804
  const now = this.now();
3784
4805
  let changed = false;
@@ -3786,30 +4807,49 @@ var HostTaskLedger = class {
3786
4807
  const schedule = task.schedule;
3787
4808
  if (schedule === void 0 || !schedule.enabled) return task;
3788
4809
  if (!skipPast && schedule.nextRunAt !== void 0) return task;
3789
- const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule));
4810
+ if (schedule.mode === "once") {
4811
+ const at = schedule.at;
4812
+ if (at === void 0) {
4813
+ changed = true;
4814
+ return withSchedule(task, {
4815
+ enabled: false,
4816
+ nextRunAt: void 0,
4817
+ endedAt: now,
4818
+ endedReason: "no-target"
4819
+ }, now);
4820
+ }
4821
+ if (at <= now) {
4822
+ changed = true;
4823
+ return withSchedule(task, {
4824
+ enabled: false,
4825
+ nextRunAt: void 0,
4826
+ endedAt: now,
4827
+ endedReason: "missed"
4828
+ }, now);
4829
+ }
4830
+ if (schedule.nextRunAt === at) return task;
4831
+ changed = true;
4832
+ return withSchedule(task, { nextRunAt: at }, now);
4833
+ }
4834
+ const next = nextRunAtMs(schedule.cron ?? "", now, scheduleZone(schedule));
3790
4835
  if (next === void 0) {
3791
4836
  changed = true;
3792
4837
  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
- };
4838
+ return withSchedule(task, {
4839
+ enabled: false,
4840
+ nextRunAt: void 0,
4841
+ endedAt: now,
4842
+ endedReason: "no-target"
4843
+ }, now);
3802
4844
  }
3803
- if (schedule.nextRunAt === next) return task;
4845
+ const passed = schedule.nextRunAt !== void 0 && schedule.nextRunAt <= now;
4846
+ if (schedule.nextRunAt === next && !passed) return task;
3804
4847
  changed = true;
3805
- return {
3806
- ...task,
3807
- schedule: {
3808
- ...schedule,
3809
- nextRunAt: next
3810
- },
3811
- updatedAt: now
3812
- };
4848
+ return withSchedule(task, passed ? {
4849
+ nextRunAt: next,
4850
+ skippedAt: now,
4851
+ skippedReason: "missed"
4852
+ } : { nextRunAt: next }, now);
3813
4853
  });
3814
4854
  if (changed && persist) this.commit();
3815
4855
  }
@@ -3854,7 +4894,7 @@ var HostTaskLedger = class {
3854
4894
  */
3855
4895
  migrateLegacyDocument(parsed) {
3856
4896
  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) => {
4897
+ const rows = typeof parsed.schemaVersion === "number" && parsed.schemaVersion <= 4 ? parsed.tasks.map((value) => {
3858
4898
  const schedule = value.schedule;
3859
4899
  if (typeof schedule !== "object" || schedule === null) return value;
3860
4900
  if (typeof schedule.timeZone === "string") return value;
@@ -3865,7 +4905,7 @@ var HostTaskLedger = class {
3865
4905
  timeZone: timeZone()
3866
4906
  }
3867
4907
  };
3868
- });
4908
+ }) : parsed.tasks;
3869
4909
  return this.normalizeDocument({
3870
4910
  ...parsed,
3871
4911
  tasks: rows
@@ -3931,10 +4971,10 @@ var HostTaskLedger = class {
3931
4971
  if (typeof parsed.schemaVersion === "number" && TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) try {
3932
4972
  return this.migrateLegacyDocument(parsed);
3933
4973
  } 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)}`);
4974
+ 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
4975
  }
3936
4976
  try {
3937
- if (parsed.schemaVersion !== 5 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
4977
+ if (parsed.schemaVersion !== 6 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
3938
4978
  return this.normalizeDocument(parsed);
3939
4979
  } catch (error) {
3940
4980
  return this.recoverCorrupt(dir, existed, error);
@@ -3949,14 +4989,15 @@ var HostTaskLedger = class {
3949
4989
  if (typeof value !== "object" || value === null) return [];
3950
4990
  const row = value;
3951
4991
  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"] : [];
4992
+ const rule = row.schedule;
4993
+ if (rule.mode === "once") return typeof rule.at === "number" && Number.isFinite(rule.at) ? [] : [typeof row.id === "string" ? row.id : "unknown"];
4994
+ return typeof rule.cron !== "string" || !isValidCron(rule.cron) ? [typeof row.id === "string" ? row.id : "unknown"] : [];
3954
4995
  });
3955
4996
  const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === "number" ? parsed.scheduler.lastTickAt : void 0;
3956
4997
  const sidecarLastTickAt = this.readSchedulerSidecar();
3957
4998
  const lastTickAt = sidecarLastTickAt === void 0 || documentLastTickAt !== void 0 && documentLastTickAt >= sidecarLastTickAt ? documentLastTickAt : sidecarLastTickAt;
3958
4999
  return {
3959
- schemaVersion: 5,
5000
+ schemaVersion: 6,
3960
5001
  revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
3961
5002
  tasks,
3962
5003
  scheduler: {
@@ -3982,7 +5023,7 @@ var HostTaskLedger = class {
3982
5023
  if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`);
3983
5024
  mkdirSync(dir, { recursive: true });
3984
5025
  return {
3985
- schemaVersion: 5,
5026
+ schemaVersion: 6,
3986
5027
  revision: 0,
3987
5028
  tasks: [],
3988
5029
  scheduler: {
@@ -4261,16 +5302,19 @@ function peerPromptPreamble(context) {
4261
5302
  return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join("\n")}`;
4262
5303
  }
4263
5304
  /**
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
5305
+ * The scheduled-run section: this execution was triggered by the card's
5306
+ * schedule rather than by a person. It states the firing instant, the rule's
5307
+ * zone, and the rule text, so the run's own clock is unambiguous — the board's
4267
5308
  * 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.
5309
+ * necessarily the zone of whatever session the run lands in. A one-shot says
5310
+ * so explicitly, so the run does not expect a recurrence.
4269
5311
  */
4270
5312
  function schedulePromptPreamble(context) {
4271
5313
  const schedule = context.schedule;
4272
5314
  if (schedule === void 0) return void 0;
4273
- return `本次执行由任务看板的定时规则自动触发:触发时间 ${new Date(schedule.triggeredAt).toISOString()}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
5315
+ const stamp = new Date(schedule.triggeredAt).toISOString();
5316
+ if (schedule.mode === "once") return `本次执行由任务看板的单次定时自动触发:计划时间 ${stamp}(UTC),时区 ${schedule.timeZone}。这是一次性计划,本次执行之后该卡片不会再自动执行。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
5317
+ return `本次执行由任务看板的定时规则自动触发:触发时间 ${stamp}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
4274
5318
  }
4275
5319
  /**
4276
5320
  * Build the execution prompt for one task.
@@ -4440,7 +5484,7 @@ var HostExecutionRunner = class {
4440
5484
  try {
4441
5485
  options.onSession?.(reused);
4442
5486
  await this.assertReusedPreset(reused, mode);
4443
- await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed);
5487
+ await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true);
4444
5488
  } catch (error) {
4445
5489
  throw new SessionLaunchError(reused, error);
4446
5490
  }
@@ -4456,7 +5500,7 @@ var HostExecutionRunner = class {
4456
5500
  sessionId,
4457
5501
  title: task.title
4458
5502
  });
4459
- await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed);
5503
+ await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true);
4460
5504
  } catch (error) {
4461
5505
  throw new SessionLaunchError(sessionId, error);
4462
5506
  }
@@ -4487,7 +5531,7 @@ var HostExecutionRunner = class {
4487
5531
  * prompt. Shared by the fresh-session and reuse paths so both apply exactly
4488
5532
  * the same permission/model pins before the prompt.
4489
5533
  */
4490
- async pinAndPrompt(sessionId, task, permission, context = {}, onGoalArmed) {
5534
+ async pinAndPrompt(sessionId, task, permission, context = {}, onGoalArmed, goalEnabled = false) {
4491
5535
  if (permission !== void 0) {
4492
5536
  if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
4493
5537
  const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
@@ -4518,7 +5562,7 @@ var HostExecutionRunner = class {
4518
5562
  text: promptText(task, context)
4519
5563
  }]
4520
5564
  });
4521
- const armed = await this.armGoal(sessionId, task, context);
5565
+ const armed = await this.armGoal(sessionId, task, context, goalEnabled);
4522
5566
  onGoalArmed?.(armed);
4523
5567
  }
4524
5568
  /**
@@ -4544,14 +5588,25 @@ var HostExecutionRunner = class {
4544
5588
  * dsh's goal-round driver keeps starting continuation rounds until the agent
4545
5589
  * marks it complete (see {@link goalVerdict} for how the run settles).
4546
5590
  *
4547
- * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
5591
+ * Two switches gate the arming, and BOTH must ask for it:
5592
+ * - the GLOBAL native-/goal switch (`goalEnabled`, default OFF), frozen when
5593
+ * this execution started; and
5594
+ * - the task's own `goalRun` (absent means off, only an explicit true opts
5595
+ * the card into a goal run).
5596
+ *
4548
5597
  * 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.
5598
+ * objective the command rejects) is reported and the run continues as a plain
5599
+ * turn: the task was asked to run, and a missing goal mode must not lose the
5600
+ * work.
5601
+ * @param sessionId - the session to arm in.
5602
+ * @param task - the task being run.
5603
+ * @param context - the run's prompt context.
5604
+ * @param goalEnabled - the GLOBAL switch, frozen for this execution.
5605
+ * @returns whether `/goal` was really armed.
4552
5606
  */
4553
- async armGoal(sessionId, task, context) {
4554
- if (task.goalRun === false) return false;
5607
+ async armGoal(sessionId, task, context, goalEnabled) {
5608
+ if (!goalEnabled) return false;
5609
+ if (task.goalRun !== true) return false;
4555
5610
  if (this.commands === void 0) {
4556
5611
  console.warn("[dsh-task-board] no command dispatcher is available; task " + task.id + " runs without /goal");
4557
5612
  return false;
@@ -5473,8 +6528,8 @@ var TaskBoardExtensionRegistry = class {
5473
6528
  };
5474
6529
  //#endregion
5475
6530
  //#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;
6531
+ /** Ceiling for the roster-poll failure backoff: a broken session tree is retried at most once a minute. */
6532
+ const POLL_FAILURE_MAX_BACKOFF_MS = 6e4;
5478
6533
  /**
5479
6534
  * How late an armed schedule fire may be before it counts as a resume rather
5480
6535
  * than a normal occurrence. The schedule timer is armed AT the next due
@@ -5495,6 +6550,30 @@ const MAX_TIMER_DELAY_MS = 2147483647;
5495
6550
  */
5496
6551
  const UNREADABLE_SETTLE_POLLS = 24;
5497
6552
  /**
6553
+ * Why an enforced goal execution settled failed while its acceptance DID run:
6554
+ * attempts were recorded and none of them passed. The judge answered about the
6555
+ * work and the work did not pass.
6556
+ */
6557
+ const NO_MATCHING_PASS_VERIFICATION_REASON = "goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。";
6558
+ /**
6559
+ * Why an enforced goal execution settled failed with ZERO acceptance records:
6560
+ * the gate was never opened, so the judge never ran and nothing was ever judged
6561
+ * (issue #1837). The root cause is a session that declared completion in prose
6562
+ * instead of calling `update_goal(action: complete)` — a tool-adherence problem,
6563
+ * NOT a quality verdict on the delivery — so this reason says so and keeps the
6564
+ * two investigation directions apart.
6565
+ */
6566
+ const NEVER_INVOKED_VERIFICATION_REASON = "goal 验收未触发:本次执行没有任何验收记录,验收门从未打开——执行会话没有调用 update_goal(action: complete) 来完成目标(常见于只在回复里宣告完成)。这不是质量判负:交付从未被裁判评估。";
6567
+ /**
6568
+ * Why an enforced goal execution settled failed while its ONLY acceptance
6569
+ * records are INVALID: the judge answered but could not locate its own
6570
+ * rejection, so the board stopped calling it. This is NOT an evidence-backed
6571
+ * quality veto — the requirement is explicit that such a run must not be
6572
+ * described as one — but the execution still has no matching pass, so it ends
6573
+ * for lack of one, and the reason names that precisely.
6574
+ */
6575
+ const INVALID_ONLY_VERIFICATION_REASON = "goal 验收无效:本次执行只记录了无效验收(裁判未能给出与任务要求对应、且可定位到本次证据的否决依据),看板已停止调用裁判。这**不是**有证据的质量判负:验收本身不可用,本次运行因没有匹配的通过记录而结束。请检查验收环境与证据可见性,或用 task_board_manage(action=reset-verification) 清除记录的无效验收后重新完成目标。";
6576
+ /**
5498
6577
  * Actions that can move an armed trigger. Only these re-arm the native timer;
5499
6578
  * an unrelated card edit leaves the pending fire untouched.
5500
6579
  */
@@ -5525,6 +6604,12 @@ var TaskBoardHostService = class {
5525
6604
  listeners = /* @__PURE__ */ new Set();
5526
6605
  /** The one recurring timer: the session-roster poll. */
5527
6606
  pollTimer;
6607
+ /** Cadence of the roster poll, in seconds (a settings field; see the config schema). */
6608
+ sessionPollSeconds = 5;
6609
+ /** Pending delay that follows a failed poll pass, if one is armed. */
6610
+ pollBackoffTimer;
6611
+ /** Consecutive failed poll passes; doubles the next retry delay. */
6612
+ consecutivePollFailures = 0;
5528
6613
  /** The armed schedule timer, if a trigger is pending. */
5529
6614
  scheduleTimer;
5530
6615
  /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
@@ -5533,13 +6618,6 @@ var TaskBoardHostService = class {
5533
6618
  pollInFlight = false;
5534
6619
  active = true;
5535
6620
  /**
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.
5540
- */
5541
- idleSessionIds;
5542
- /**
5543
6621
  * Consecutive unreadable-history polls per open execution (see
5544
6622
  * {@link noteUnreadableInspection}). Cleared as soon as an inspection
5545
6623
  * resolves, so a transient reader failure never fails a card.
@@ -5550,6 +6628,8 @@ var TaskBoardHostService = class {
5550
6628
  verificationCatalog;
5551
6629
  team;
5552
6630
  timers;
6631
+ /** Live reader of the global native-/goal switch; frozen per execution. */
6632
+ goalRunEnabled;
5553
6633
  lastPowerJson = "";
5554
6634
  now;
5555
6635
  /** The deployment's workspaces, consulted to resolve a card that pins none. */
@@ -5571,6 +6651,7 @@ var TaskBoardHostService = class {
5571
6651
  this.timers = options.timers ?? PROCESS_TIMERS;
5572
6652
  this.power = options.power ?? new PowerInhibitor();
5573
6653
  this.now = options.now ?? Date.now;
6654
+ this.goalRunEnabled = options.goalRunEnabled ?? (() => false);
5574
6655
  this.verificationSettings = options.verificationSettings ?? (() => ({
5575
6656
  enabled: false,
5576
6657
  model: "",
@@ -5599,14 +6680,35 @@ var TaskBoardHostService = class {
5599
6680
  this.syncPowerReasons();
5600
6681
  this.pollTimer = this.timers.interval(() => {
5601
6682
  this.schedulePoll();
5602
- }, SESSION_POLL_MS);
6683
+ }, sessionPollMs(this.sessionPollSeconds));
5603
6684
  this.schedulePoll();
6685
+ this.cleanupPendingAcceptanceDetail();
5604
6686
  this.recoverSchedule();
5605
6687
  }
5606
- setConfiguration(active, preventIdleSleep) {
6688
+ /**
6689
+ * Apply the live configuration. The roster cadence is re-read on every
6690
+ * commit, so a settings edit takes effect without a restart; the poll is
6691
+ * scheduled at the new cadence immediately rather than waiting out the old
6692
+ * interval.
6693
+ * @param active - the board's master switch.
6694
+ * @param preventIdleSleep - whether the board holds an idle-sleep assertion.
6695
+ * @param sessionPollSeconds - roster-poll cadence in seconds (clamped).
6696
+ */
6697
+ setConfiguration(active, preventIdleSleep, sessionPollSeconds) {
5607
6698
  const resumed = !this.active && active;
5608
6699
  this.active = active;
5609
6700
  this.preventIdleSleep = preventIdleSleep;
6701
+ const cadence = normalizeSessionPollSeconds(sessionPollSeconds);
6702
+ const retimed = this.pollTimer !== void 0 && cadence !== this.sessionPollSeconds;
6703
+ this.sessionPollSeconds = cadence;
6704
+ if (retimed) {
6705
+ this.pollTimer?.();
6706
+ this.pollTimer = this.timers.interval(() => {
6707
+ this.schedulePoll();
6708
+ }, sessionPollMs(this.sessionPollSeconds));
6709
+ this.clearPollBackoff();
6710
+ this.schedulePoll();
6711
+ }
5610
6712
  if (resumed) {
5611
6713
  const current = this.power.snapshot();
5612
6714
  this.power.updateReasons({
@@ -5627,7 +6729,7 @@ var TaskBoardHostService = class {
5627
6729
  const state = this.ledger.state();
5628
6730
  const published = this.extensions.published();
5629
6731
  return {
5630
- schemaVersion: 5,
6732
+ schemaVersion: 6,
5631
6733
  revision: state.revision,
5632
6734
  tasks: state.tasks,
5633
6735
  scheduler: state.scheduler,
@@ -5635,6 +6737,7 @@ var TaskBoardHostService = class {
5635
6737
  sessionDefaultPermission: this.ledger.sessionDefaultPermission,
5636
6738
  maxSubtaskDepth: this.ledger.maxSubtaskDepth,
5637
6739
  teamRunAvailable: this.team !== void 0,
6740
+ goalRunEnabled: this.goalRunEnabled(),
5638
6741
  ...Object.keys(published).length === 0 ? {} : { extensions: published }
5639
6742
  };
5640
6743
  }
@@ -5731,7 +6834,7 @@ var TaskBoardHostService = class {
5731
6834
  this.emitStatusChanges(before, initiator);
5732
6835
  if (action.kind === "delete") this.extensions.emitTaskDeleted({ taskId: action.taskId });
5733
6836
  return {
5734
- schemaVersion: 5,
6837
+ schemaVersion: 6,
5735
6838
  revision: result.state.revision,
5736
6839
  tasks: result.state.tasks,
5737
6840
  scheduler: result.state.scheduler,
@@ -5769,6 +6872,7 @@ var TaskBoardHostService = class {
5769
6872
  this.disposed = true;
5770
6873
  this.extensions.dispose();
5771
6874
  this.clearScheduleTimer();
6875
+ this.clearPollBackoff();
5772
6876
  this.pollTimer?.();
5773
6877
  this.pollTimer = void 0;
5774
6878
  this.power.dispose();
@@ -5776,9 +6880,11 @@ var TaskBoardHostService = class {
5776
6880
  this.listeners.clear();
5777
6881
  }
5778
6882
  async launch(opened, others = [], schedule) {
6883
+ /** The session this launch attached to the execution, once it exists. */
6884
+ let attached;
5779
6885
  try {
5780
6886
  const team = opened.task.teamRun === true;
5781
- const reuseSessionId = team ? void 0 : reusableSessionId(opened.task, this.idleSessionIds);
6887
+ const reuseSessionId = team ? void 0 : await this.reuseSessionFor(opened.task);
5782
6888
  const peers = others.length === 0 ? void 0 : others.map((other) => ({
5783
6889
  id: other.task.id,
5784
6890
  title: other.task.title,
@@ -5790,33 +6896,38 @@ var TaskBoardHostService = class {
5790
6896
  ...schedule === void 0 ? {} : { schedule }
5791
6897
  };
5792
6898
  const settings = this.verificationSettings();
6899
+ const globalGoalEnabled = this.goalRunEnabled() === true;
6900
+ const goalEnabled = globalGoalEnabled && opened.task.goalRun === true;
5793
6901
  const skippedBy = opened.task.skipVerification === true ? "skipped" : "disabled";
5794
6902
  const contract = resolveContract({
5795
6903
  ...settings,
5796
6904
  enabled: settings.enabled && opened.task.skipVerification !== true
5797
6905
  }, await this.verificationCatalog());
6906
+ const notArmed = globalGoalEnabled ? "goal-unavailable" : "goal-disabled";
5798
6907
  const initial = {
5799
6908
  contract,
5800
6909
  attempts: [],
5801
- applicability: team ? "team-member" : !contract.enabled ? skippedBy : "goal-unavailable"
6910
+ applicability: team ? "team-member" : !contract.enabled ? skippedBy : notArmed
5802
6911
  };
5803
6912
  this.ledger.setVerification(opened.task.id, opened.execution.id, initial);
5804
- let attached;
5805
6913
  const sessionId = await this.runner.launch(opened.task, {
5806
6914
  ...reuseSessionId === void 0 ? {} : { reuseSessionId },
5807
6915
  ...promptContext === void 0 ? {} : { promptContext },
6916
+ goalEnabled,
5808
6917
  onSession: (id) => {
5809
6918
  attached = id;
5810
6919
  this.ledger.attachSession(opened.task.id, opened.execution.id, id);
5811
6920
  },
5812
6921
  onGoalArmed: (armed) => {
5813
- this.setApplicability(opened.task.id, opened.execution.id, team ? "team-member" : !contract.enabled ? skippedBy : armed ? "enforced" : "goal-unavailable");
6922
+ this.setApplicability(opened.task.id, opened.execution.id, team ? "team-member" : !contract.enabled ? skippedBy : armed ? "enforced" : notArmed);
5814
6923
  }
5815
6924
  });
5816
6925
  if (attached === void 0) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
5817
6926
  if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId);
5818
6927
  } catch (error) {
5819
- if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
6928
+ const launchedSession = error instanceof SessionLaunchError ? error.sessionId : void 0;
6929
+ if (launchedSession !== void 0) this.ledger.attachSession(opened.task.id, opened.execution.id, launchedSession);
6930
+ if (schedule !== void 0 && attached === void 0 && launchedSession === void 0) this.ledger.refundScheduledOccurrence(opened.task.id, this.now());
5820
6931
  this.settleAndNotify(opened.task.id, opened.execution.id, "failed", error instanceof Error ? error.message : String(error));
5821
6932
  }
5822
6933
  }
@@ -5854,31 +6965,70 @@ var TaskBoardHostService = class {
5854
6965
  this.settleAndNotify(opened.task.id, opened.execution.id, "failed", error instanceof Error ? error.message : String(error));
5855
6966
  }
5856
6967
  }
6968
+ /**
6969
+ * One roster pass, and whether it could read the session tree.
6970
+ *
6971
+ * The board reads the DSH session roster only while it has something the
6972
+ * roster can decide: an execution to inspect, or a card parked in the running
6973
+ * column whose verdict may arrive from a settle this process never saw. That
6974
+ * gate is what removes the cost the reporter measured — `session/list`
6975
+ * rebuilds every persisted session row (string conversion, object allocation,
6976
+ * a stat per record), and an idle board with no tasks was paying it every few
6977
+ * seconds forever.
6978
+ * @returns whether the roster was readable (an idle pass counts as readable).
6979
+ */
5857
6980
  async pollSessions() {
5858
- if (this.disposed) return;
5859
- if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return;
6981
+ if (this.disposed) return true;
6982
+ const runtime = this.ledger.runtimeView();
6983
+ if (!this.active) {
6984
+ if (runtime.openExecutions.length === 0 && runtime.armedSchedules === 0) return true;
6985
+ } else if (!runtime.needsSessionState) return true;
5860
6986
  const running = await this.runner.listRunning();
5861
6987
  const previous = this.power.snapshot();
5862
6988
  if (!running.known) {
5863
- this.idleSessionIds = void 0;
5864
6989
  this.power.updateReasons({
5865
6990
  runningSessions: previous.runningSessions,
5866
- armedSchedules: this.ledger.armedScheduleCount(),
6991
+ armedSchedules: runtime.armedSchedules,
5867
6992
  sessionStateKnown: false
5868
6993
  });
5869
- return;
6994
+ return false;
5870
6995
  }
5871
- this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
5872
6996
  const foldedBefore = this.statusMap();
5873
6997
  this.ledger.finalizeReadyRuns();
5874
6998
  this.emitStatusChanges(foldedBefore);
5875
- const runtime = this.ledger.runtimeView();
6999
+ const current = this.ledger.runtimeView();
5876
7000
  this.power.updateReasons({
5877
7001
  runningSessions: running.count,
5878
- armedSchedules: runtime.armedSchedules,
7002
+ armedSchedules: current.armedSchedules,
5879
7003
  sessionStateKnown: true
5880
7004
  });
5881
- await this.reconcileExecutions(running.items, runtime.openExecutions);
7005
+ await this.reconcileExecutions(running.items, current.openExecutions);
7006
+ return true;
7007
+ }
7008
+ /**
7009
+ * The session a run may continue in, for a card that opted into reuse
7010
+ * (issue #1419). Reuse requires positive evidence that the previous session
7011
+ * is present and idle, and that evidence must be read for THIS launch: an
7012
+ * idle board no longer polls, so a roster cached from an earlier pass could
7013
+ * be hours old — either refusing a session that has been idle all along, or
7014
+ * prompting into one that started running since. A card that did not opt in
7015
+ * reads no roster at all, and a read that fails mints a fresh conversation
7016
+ * exactly as an unknown roster always did.
7017
+ * @param task - the task about to run.
7018
+ * @returns the session id to continue in, or undefined for a fresh session.
7019
+ */
7020
+ async reuseSessionFor(task) {
7021
+ if (task.reuseSession !== true) return void 0;
7022
+ let running;
7023
+ try {
7024
+ running = await this.runner.listRunning();
7025
+ } catch (error) {
7026
+ this.notePollFailure();
7027
+ safeConsoleError("[dsh-task-board] session roster read for session reuse failed; starting a fresh session", error);
7028
+ return;
7029
+ }
7030
+ if (!running.known) return void 0;
7031
+ return reusableSessionId(task, new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId)));
5882
7032
  }
5883
7033
  /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
5884
7034
  async reconcileExecutions(sessions, executions) {
@@ -5898,7 +7048,7 @@ var TaskBoardHostService = class {
5898
7048
  }
5899
7049
  this.unreadablePolls.delete(execution.executionId);
5900
7050
  if (result.outcome === "succeeded" && verificationRequired(verification) && passedAttempt(verification) === void 0) {
5901
- this.settleAndNotify(execution.taskId, execution.executionId, "failed", "goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。");
7051
+ 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
7052
  continue;
5903
7053
  }
5904
7054
  this.settleAndNotify(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
@@ -5936,6 +7086,59 @@ var TaskBoardHostService = class {
5936
7086
  outcome,
5937
7087
  ...error === void 0 ? {} : { error }
5938
7088
  });
7089
+ if (outcome === "succeeded") this.cleanupAcceptanceDetail(taskId, executionId);
7090
+ }
7091
+ /**
7092
+ * Remove one execution's acceptance-only detail after it PASSED and settled.
7093
+ *
7094
+ * Deliberately narrow: only the acceptance mechanism's own per-attempt
7095
+ * material (findings, invalid diagnostics, the bulky judge side) is removed,
7096
+ * and only for THIS execution, keyed by its own id in the ledger it belongs
7097
+ * to. No user file, task artifact, raw session history or other execution's
7098
+ * record is read or written. Idempotent by the record's own state, and it
7099
+ * clearly never touches a run that did not pass (the caller's guard) — a
7100
+ * failed or invalid execution keeps everything a repair or a review needs.
7101
+ * @param taskId - the task whose execution settled.
7102
+ * @param executionId - the settled execution.
7103
+ */
7104
+ cleanupAcceptanceDetail(taskId, executionId) {
7105
+ const verification = (this.ledger.getTask(taskId)?.executions.find((entry) => entry.id === executionId))?.verification;
7106
+ if (verification === void 0 || passedAttempt(verification) === void 0) return;
7107
+ if (verification.cleanup?.state === "cleaned") return;
7108
+ try {
7109
+ const cleaned = withAcceptanceDetailCleared(verification, this.now());
7110
+ if (cleaned === void 0) return;
7111
+ this.ledger.setVerification(taskId, executionId, cleaned);
7112
+ } catch (error) {
7113
+ const attempts = (verification.cleanup?.attempts ?? 0) + 1;
7114
+ this.ledger.setVerification(taskId, executionId, {
7115
+ ...verification,
7116
+ cleanup: {
7117
+ state: "failed",
7118
+ attempts,
7119
+ startedAt: verification.cleanup?.startedAt ?? this.now(),
7120
+ lastError: error instanceof Error ? error.message : String(error),
7121
+ audit: verification.cleanup?.audit ?? []
7122
+ }
7123
+ });
7124
+ safeConsoleError("[dsh-task-board] acceptance-detail cleanup failed; it is retried on the next Host start", error);
7125
+ }
7126
+ }
7127
+ /**
7128
+ * Catch-up pass: clean the acceptance detail of every execution that PASSED
7129
+ * and settled but whose cleanup did not finish (a crash, a failed write, a
7130
+ * Host killed mid-cleanup).
7131
+ *
7132
+ * The same bounds as any other cleanup apply — only a real pass record on a
7133
+ * settled execution is touched — so this can never delete material belonging
7134
+ * to an execution that did not pass, and never a user file.
7135
+ */
7136
+ cleanupPendingAcceptanceDetail() {
7137
+ for (const task of this.ledger.allTasks()) for (const execution of task.executions) {
7138
+ if (execution.endedAt === void 0) continue;
7139
+ if (execution.result !== "succeeded") continue;
7140
+ this.cleanupAcceptanceDetail(task.id, execution.id);
7141
+ }
5939
7142
  }
5940
7143
  noteUnreadableInspection(execution, reason) {
5941
7144
  const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1;
@@ -6003,14 +7206,12 @@ var TaskBoardHostService = class {
6003
7206
  return;
6004
7207
  }
6005
7208
  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
- }
7209
+ for (const schedule of this.ledger.dueSchedules(now)) this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, now), {
7210
+ triggeredAt: now,
7211
+ timeZone: schedule.timeZone,
7212
+ mode: schedule.mode,
7213
+ ...schedule.cron === void 0 ? {} : { cron: schedule.cron }
7214
+ });
6014
7215
  this.emitStatusChanges(before);
6015
7216
  this.armSchedule();
6016
7217
  }
@@ -6051,13 +7252,44 @@ var TaskBoardHostService = class {
6051
7252
  schedulePoll() {
6052
7253
  if (this.pollInFlight || this.disposed) return;
6053
7254
  this.pollInFlight = true;
6054
- this.pollSessions().catch((error) => {
7255
+ this.pollSessions().then((rosterReadable) => {
7256
+ if (rosterReadable) this.consecutivePollFailures = 0;
7257
+ else this.notePollFailure();
7258
+ }).catch((error) => {
7259
+ this.notePollFailure();
6055
7260
  safeConsoleError("[dsh-task-board] session polling failed", error);
6056
7261
  }).finally(() => {
6057
7262
  this.pollInFlight = false;
6058
7263
  });
6059
7264
  }
6060
7265
  /**
7266
+ * Count one failed pass and arm the retry that follows it.
7267
+ *
7268
+ * A session tree that is down is retried on a doubling delay instead of
7269
+ * hammering it on the fixed cadence: the heartbeat would otherwise repeat
7270
+ * every failure — and every retry inside {@link HostExecutionRunner.listRunning}
7271
+ * — at exactly the interval the reporter measured.
7272
+ */
7273
+ notePollFailure() {
7274
+ this.consecutivePollFailures += 1;
7275
+ this.armPollBackoff();
7276
+ }
7277
+ /** Arm the retry that follows a failed pass, doubling up to the ceiling. */
7278
+ armPollBackoff() {
7279
+ if (this.disposed) return;
7280
+ this.pollBackoffTimer?.();
7281
+ const delay = Math.min(sessionPollMs(this.sessionPollSeconds) * 2 ** Math.min(this.consecutivePollFailures, 8), POLL_FAILURE_MAX_BACKOFF_MS);
7282
+ this.pollBackoffTimer = this.timers.timeout(() => {
7283
+ this.pollBackoffTimer = void 0;
7284
+ this.schedulePoll();
7285
+ }, delay);
7286
+ }
7287
+ /** Drop a pending retry; the next regular tick takes over. */
7288
+ clearPollBackoff() {
7289
+ this.pollBackoffTimer?.();
7290
+ this.pollBackoffTimer = void 0;
7291
+ }
7292
+ /**
6061
7293
  * Re-arm from the ledger's current targets. Callers that just changed a
6062
7294
  * schedule (the host routes, the agent tools) invoke this after the write
6063
7295
  * commits, so a new or edited trigger arms without waiting for the next fire.
@@ -6314,6 +7546,64 @@ async function parseTaskDraft(llm, request, signal) {
6314
7546
  signal?.removeEventListener("abort", abortFromCaller);
6315
7547
  }
6316
7548
  }
7549
+ /**
7550
+ * Total ceiling of one acceptance attempt, covering evidence rendering and
7551
+ * every judge call including retries. Derived from the per-call default rather
7552
+ * than pinned, because a total budget smaller than
7553
+ * {@link ACCEPTANCE_JUDGE_CALLS} times the per-call ceiling guarantees that no
7554
+ * acceptance can ever finish.
7555
+ */
7556
+ const DEFAULT_VERIFICATION_BUDGET_SECONDS = 150 * (CODING_CRITERIA.length * 2 + 2);
7557
+ /** Longest accepted total budget, thirty minutes. */
7558
+ const VERIFICATION_BUDGET_MAX_SECONDS = 1800;
7559
+ /** The default total budget in milliseconds, the face the abort timer takes. */
7560
+ const DEFAULT_VERIFICATION_BUDGET_MS = DEFAULT_VERIFICATION_BUDGET_SECONDS * 1e3;
7561
+ /**
7562
+ * Clamp a configured ceiling into the supported range. A value the schema
7563
+ * would refuse (non-finite, below the floor, above the ceiling) still leaves
7564
+ * the board judging: an out-of-range setting never disables acceptance.
7565
+ * @param value - configured seconds, or undefined for the default.
7566
+ * @param fallback - the default to use when nothing usable was configured.
7567
+ * @param min - shortest accepted value.
7568
+ * @param max - longest accepted value.
7569
+ * @returns the effective value in seconds.
7570
+ */
7571
+ function clampSeconds(value, fallback, min, max) {
7572
+ if (value === void 0 || !Number.isFinite(value)) return fallback;
7573
+ return Math.min(Math.max(Math.trunc(value), min), max);
7574
+ }
7575
+ /**
7576
+ * The effective per-judge-call ceiling.
7577
+ * @param seconds - configured seconds, or undefined for the default.
7578
+ * @returns the effective ceiling in seconds.
7579
+ */
7580
+ function normalizeVerificationCallTimeoutSeconds(seconds) {
7581
+ return clampSeconds(seconds, 150, 30, 600);
7582
+ }
7583
+ /**
7584
+ * The effective per-judge-call ceiling the timer face takes.
7585
+ * @param seconds - configured seconds, or undefined for the default.
7586
+ * @returns the effective ceiling in milliseconds.
7587
+ */
7588
+ function verificationCallTimeoutMs(seconds) {
7589
+ return normalizeVerificationCallTimeoutSeconds(seconds) * 1e3;
7590
+ }
7591
+ /**
7592
+ * The effective total budget of one acceptance attempt.
7593
+ * @param seconds - configured seconds, or undefined for the default.
7594
+ * @returns the effective budget in seconds.
7595
+ */
7596
+ function normalizeVerificationBudgetSeconds(seconds) {
7597
+ return clampSeconds(seconds, DEFAULT_VERIFICATION_BUDGET_SECONDS, 120, VERIFICATION_BUDGET_MAX_SECONDS);
7598
+ }
7599
+ /**
7600
+ * The effective total budget the abort face takes.
7601
+ * @param seconds - configured seconds, or undefined for the default.
7602
+ * @returns the effective budget in milliseconds.
7603
+ */
7604
+ function verificationBudgetMs(seconds) {
7605
+ return normalizeVerificationBudgetSeconds(seconds) * 1e3;
7606
+ }
6317
7607
  //#endregion
6318
7608
  //#region src/host/agent-tools.ts
6319
7609
  /** Registered tool names, in registration order. */
@@ -6364,10 +7654,18 @@ function callingSessionId(exec) {
6364
7654
  function scheduleView(schedule) {
6365
7655
  return {
6366
7656
  enabled: schedule.enabled,
6367
- cron: schedule.cron,
7657
+ mode: schedule.mode,
7658
+ ...schedule.cron === void 0 ? {} : { cron: schedule.cron },
7659
+ ...schedule.at === void 0 ? {} : { at: schedule.at },
6368
7660
  ...schedule.timeZone === void 0 ? {} : { timeZone: schedule.timeZone },
6369
7661
  ...schedule.nextRunAt === void 0 ? {} : { nextRunAt: schedule.nextRunAt },
6370
- ...schedule.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }
7662
+ ...schedule.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: schedule.lastTriggeredAt },
7663
+ runCount: schedule.runCount,
7664
+ ...schedule.maxRuns === void 0 ? {} : { maxRuns: schedule.maxRuns },
7665
+ ...schedule.endedAt === void 0 ? {} : { endedAt: schedule.endedAt },
7666
+ ...schedule.endedReason === void 0 ? {} : { endedReason: schedule.endedReason },
7667
+ ...schedule.skippedAt === void 0 ? {} : { skippedAt: schedule.skippedAt },
7668
+ ...schedule.skippedReason === void 0 ? {} : { skippedReason: schedule.skippedReason }
6371
7669
  };
6372
7670
  }
6373
7671
  function tagView(tag) {
@@ -6385,6 +7683,7 @@ function executionView(execution) {
6385
7683
  ...execution.result === void 0 ? {} : { result: execution.result },
6386
7684
  ...execution.error === void 0 ? {} : { error: execution.error },
6387
7685
  ...execution.initiatedBy === void 0 ? {} : { initiatedBy: execution.initiatedBy },
7686
+ ...execution.external === true ? { external: true } : {},
6388
7687
  ...execution.runGroupId === void 0 ? {} : { cascade: true },
6389
7688
  ...execution.ownResult === void 0 ? {} : {
6390
7689
  ownResult: execution.ownResult,
@@ -6415,7 +7714,7 @@ function taskSummary(task, tasks, sessionDefault) {
6415
7714
  ...task.model === void 0 ? {} : { model: task.model },
6416
7715
  ...task.reuseSession === true ? { reuseSession: true } : {},
6417
7716
  ...task.teamRun === true ? { teamRun: true } : {},
6418
- ...task.goalRun === false ? { goalRun: false } : {},
7717
+ ...task.goalRun === true ? { goalRun: true } : {},
6419
7718
  ...task.skipVerification === true ? { skipVerification: true } : {},
6420
7719
  ...task.schedule === void 0 ? {} : { schedule: scheduleView(task.schedule) },
6421
7720
  ...task.freeze === void 0 ? {} : { continuationCard: true },
@@ -6610,7 +7909,9 @@ function buildManageTool(host) {
6610
7909
  "Move, archive, restore, delete, or settle one task board card.",
6611
7910
  "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
7911
  "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.",
7912
+ "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
7913
  "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.",
7914
+ "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
7915
  "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
7916
  "It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI."
6616
7917
  ].join(" "),
@@ -6632,9 +7933,24 @@ function buildManageTool(host) {
6632
7933
  "archive",
6633
7934
  "restore",
6634
7935
  "delete",
6635
- "settle"
7936
+ "settle",
7937
+ "reset-verification",
7938
+ "record-external-outcome"
6636
7939
  ],
6637
7940
  description: "The lifecycle operation to perform."
7941
+ },
7942
+ result: {
7943
+ type: "string",
7944
+ enum: ["succeeded", "failed"],
7945
+ description: "record-external-outcome only: the verdict the outside agent actually reached."
7946
+ },
7947
+ initiatedBy: {
7948
+ type: "string",
7949
+ description: "record-external-outcome only: who finished the work outside this Host (for example your own agent name)."
7950
+ },
7951
+ summary: {
7952
+ type: "string",
7953
+ description: "record-external-outcome only: optional free-text note stored with the recorded outcome."
6638
7954
  }
6639
7955
  },
6640
7956
  output: {
@@ -6643,6 +7959,31 @@ function buildManageTool(host) {
6643
7959
  },
6644
7960
  async execute(args, exec) {
6645
7961
  const initiator = callingSessionId(exec);
7962
+ if (args.action === "record-external-outcome") {
7963
+ const result = args.result;
7964
+ const by = typeof args.initiatedBy === "string" ? args.initiatedBy.trim() : "";
7965
+ if (result !== "succeeded" && result !== "failed") return refused("missing-result", "record-external-outcome needs result=succeeded or result=failed");
7966
+ if (by === "") return refused("missing-initiator", "record-external-outcome needs initiatedBy naming who completed the work outside this Host");
7967
+ if (!host.snapshot().tasks.some((task) => task.id === args.taskId)) return refused("task-not-found", "no task with id " + args.taskId);
7968
+ try {
7969
+ const snapshot = await host.apply(crypto.randomUUID(), {
7970
+ kind: "record-external-outcome",
7971
+ taskId: args.taskId,
7972
+ result,
7973
+ initiatedBy: by,
7974
+ ...typeof args.summary === "string" && args.summary.trim() !== "" ? { summary: args.summary } : {}
7975
+ }, initiator);
7976
+ const task = snapshot.tasks.find((item) => item.id === args.taskId);
7977
+ return json({
7978
+ ok: true,
7979
+ action: args.action,
7980
+ ...task === void 0 ? {} : { task: taskSummary(task, snapshot.tasks, snapshot.sessionDefaultPermission) },
7981
+ 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."
7982
+ });
7983
+ } catch (error) {
7984
+ return refused("refused", messageOf(error));
7985
+ }
7986
+ }
6646
7987
  const action = {
6647
7988
  "move-todo": {
6648
7989
  kind: "move",
@@ -6677,6 +8018,10 @@ function buildManageTool(host) {
6677
8018
  kind: "settle",
6678
8019
  taskId: args.taskId
6679
8020
  },
8021
+ "reset-verification": {
8022
+ kind: "reset-verification",
8023
+ taskId: args.taskId
8024
+ },
6680
8025
  restore: {
6681
8026
  kind: "restore",
6682
8027
  taskId: args.taskId
@@ -6712,11 +8057,12 @@ function buildScheduleTool(host) {
6712
8057
  return defineTool({
6713
8058
  name: "task_board_schedule",
6714
8059
  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.",
8060
+ "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.",
8061
+ "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).",
8062
+ "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.",
6717
8063
  "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."
8064
+ "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.",
8065
+ "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
8066
  ].join(" "),
6721
8067
  parameters: {
6722
8068
  taskId: {
@@ -6728,13 +8074,26 @@ function buildScheduleTool(host) {
6728
8074
  type: "boolean",
6729
8075
  description: "Arm (true) or disarm (false) the schedule."
6730
8076
  },
8077
+ mode: {
8078
+ type: "string",
8079
+ enum: ["cron", "once"],
8080
+ description: "The kind of plan: \"cron\" for a recurring rule, \"once\" for a single future instant. Omit to keep the stored kind."
8081
+ },
6731
8082
  cron: {
6732
8083
  type: "string",
6733
- description: "5-field cron expression: minute hour day-of-month month day-of-week."
8084
+ description: "5-field cron expression of a recurring rule: minute hour day-of-month month day-of-week."
8085
+ },
8086
+ at: {
8087
+ type: "integer",
8088
+ description: "Planned instant of a one-shot, in milliseconds since the Unix epoch (UTC). Must be in the future."
6734
8089
  },
6735
8090
  timeZone: {
6736
8091
  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."
8092
+ 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."
8093
+ },
8094
+ maxRuns: {
8095
+ type: "integer",
8096
+ 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
8097
  }
6739
8098
  },
6740
8099
  output: {
@@ -6742,12 +8101,15 @@ function buildScheduleTool(host) {
6742
8101
  render: renderJson
6743
8102
  },
6744
8103
  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");
8104
+ 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
8105
  if (!host.snapshot().tasks.some((task) => task.id === args.taskId)) return refused("task-not-found", "no task with id " + args.taskId);
6747
8106
  const patch = {
6748
8107
  ...args.enabled === void 0 ? {} : { enabled: args.enabled },
8108
+ ...args.mode === void 0 ? {} : { mode: args.mode },
6749
8109
  ...args.cron === void 0 ? {} : { cron: args.cron },
6750
- ...args.timeZone === void 0 ? {} : { timeZone: args.timeZone === "" ? null : args.timeZone }
8110
+ ...args.at === void 0 ? {} : { at: args.at },
8111
+ ...args.timeZone === void 0 ? {} : { timeZone: args.timeZone === "" ? null : args.timeZone },
8112
+ ...args.maxRuns === void 0 ? {} : { maxRuns: args.maxRuns <= 0 ? null : args.maxRuns }
6751
8113
  };
6752
8114
  try {
6753
8115
  const snapshot = await host.apply(crypto.randomUUID(), {
@@ -6928,7 +8290,7 @@ function buildCreateTool(host) {
6928
8290
  },
6929
8291
  goalRun: {
6930
8292
  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."
8293
+ 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
8294
  },
6933
8295
  skipVerification: {
6934
8296
  type: "boolean",
@@ -6956,17 +8318,29 @@ function buildCreateTool(host) {
6956
8318
  schedule: {
6957
8319
  type: "object",
6958
8320
  additionalProperties: false,
6959
- description: "Arm a 5-field cron schedule at creation time (Host local time zone).",
8321
+ 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
8322
  properties: {
6961
8323
  enabled: {
6962
8324
  type: "boolean",
6963
8325
  required: true,
6964
8326
  description: "Whether the schedule is armed."
6965
8327
  },
8328
+ mode: {
8329
+ type: "string",
8330
+ enum: ["cron", "once"],
8331
+ description: "The kind of plan: \"cron\" for a recurring rule, \"once\" for a single future instant."
8332
+ },
6966
8333
  cron: {
6967
8334
  type: "string",
6968
- required: true,
6969
- description: "5-field cron: minute hour day-of-month month day-of-week."
8335
+ description: "5-field cron of a recurring rule: minute hour day-of-month month day-of-week."
8336
+ },
8337
+ at: {
8338
+ type: "integer",
8339
+ description: "Planned instant of a one-shot, in milliseconds since the Unix epoch (UTC). Must be in the future."
8340
+ },
8341
+ maxRuns: {
8342
+ type: "integer",
8343
+ description: "Maximum scheduled executions of a recurring rule (positive whole number); omit for unlimited."
6970
8344
  }
6971
8345
  }
6972
8346
  }
@@ -6993,12 +8367,15 @@ function buildCreateTool(host) {
6993
8367
  ...args.model === void 0 || args.model === "" ? {} : { model: args.model },
6994
8368
  ...args.reuseSession === true ? { reuseSession: true } : {},
6995
8369
  ...args.teamRun === true ? { teamRun: true } : {},
6996
- ...args.goalRun === false ? { goalRun: false } : {},
8370
+ ...args.goalRun === true ? { goalRun: true } : {},
6997
8371
  ...args.skipVerification === true ? { skipVerification: true } : {},
6998
8372
  ...tags.length === 0 ? {} : { tags },
6999
8373
  ...args.schedule === void 0 ? {} : { schedule: {
7000
8374
  enabled: args.schedule.enabled,
7001
- cron: args.schedule.cron
8375
+ ...args.schedule.mode === void 0 ? {} : { mode: args.schedule.mode },
8376
+ ...args.schedule.cron === void 0 ? {} : { cron: args.schedule.cron },
8377
+ ...args.schedule.at === void 0 ? {} : { at: args.schedule.at },
8378
+ ...args.schedule.maxRuns === void 0 ? {} : { maxRuns: args.schedule.maxRuns }
7002
8379
  } }
7003
8380
  };
7004
8381
  try {
@@ -7078,7 +8455,7 @@ function buildUpdateTool(host) {
7078
8455
  },
7079
8456
  goalRun: {
7080
8457
  type: "boolean",
7081
- description: "Whether each run starts with dsh built-in /goal (default true). Pass false to run one plain turn instead."
8458
+ 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
8459
  },
7083
8460
  skipVerification: {
7084
8461
  type: "boolean",
@@ -7119,7 +8496,7 @@ function buildUpdateTool(host) {
7119
8496
  if (has(args, "permission") && typeof args.permission === "string") patch.permission = args.permission === "" ? void 0 : args.permission;
7120
8497
  if (has(args, "reuseSession")) patch.reuseSession = args.reuseSession === true;
7121
8498
  if (has(args, "teamRun")) patch.teamRun = args.teamRun === true;
7122
- if (has(args, "goalRun")) patch.goalRun = args.goalRun === false ? false : true;
8499
+ if (has(args, "goalRun")) patch.goalRun = args.goalRun === true ? true : false;
7123
8500
  if (has(args, "skipVerification")) patch.skipVerification = args.skipVerification === true ? true : false;
7124
8501
  if (has(args, "tags")) {
7125
8502
  const tags = args.tags ?? [];
@@ -7702,8 +9079,11 @@ function mountOnce(packageName, fn) {
7702
9079
  const VERIFICATION_MAX_TRACE_CHARS = 8e4;
7703
9080
  /** Largest single trace entry; one huge tool result must not eat the budget. */
7704
9081
  const VERIFICATION_MAX_ENTRY_CHARS = 4e3;
7705
- /** One judge request's ceiling and its retry budget. */
7706
- const VERIFICATION_CALL_TIMEOUT_MS = 12e4;
9082
+ /** Largest verbatim citation kept from one finding. */
9083
+ const VERIFICATION_MAX_QUOTE_CHARS = 600;
9084
+ /** Largest requirement/observation/gap statement kept from one finding. */
9085
+ const VERIFICATION_MAX_PROBLEM_CHARS = 400;
9086
+ const VERIFICATION_MAX_FINDINGS = 12;
7707
9087
  const VERIFICATION_MAX_TOKENS = 16384;
7708
9088
  const VERIFICATION_TEMPERATURE = .2;
7709
9089
  /** Redaction patterns, mirrored from the verifier's defaults. */
@@ -7858,8 +9238,29 @@ function collectEvidence(session, problem, startedAt) {
7858
9238
  hash
7859
9239
  };
7860
9240
  }
7861
- /** Parse the optional `<finding ...>` lines out of one judge answer. */
7862
- function parseFindings(text, criterion) {
9241
+ /** How one cited slot maps onto the caller's own evidence under a swapped round. */
9242
+ function locationOfSlot(slot, round) {
9243
+ return slot === workSlotOfRound(round) ? "trajectory" : "baseline";
9244
+ }
9245
+ /** Resolve one `location` (or legacy `evidence`) attribute into an evidence location. */
9246
+ function readLocation(raw, round) {
9247
+ const value = raw.trim().toUpperCase();
9248
+ if (value === "TASK") return "task";
9249
+ if (value === "CONTEXT" || value === "WORKSPACE") return "workspace";
9250
+ if (value === "TRAJECTORY_A" || value === "A") return locationOfSlot("A", round);
9251
+ if (value === "TRAJECTORY_B" || value === "B") return locationOfSlot("B", round);
9252
+ }
9253
+ /**
9254
+ * Parse the `<finding ...>` elements out of one judge answer.
9255
+ *
9256
+ * A finding must name the criterion this prompt offered and cite an evidence
9257
+ * location the prompt actually rendered; a finding for another criterion, a
9258
+ * citation of a location that was never offered, and an empty body are dropped
9259
+ * here rather than echoed back as if the judge had located something. The
9260
+ * quote is carried verbatim: whether it really occurs in the evidence it names
9261
+ * is decided by the caller, which is the only side holding the evidence text.
9262
+ */
9263
+ function parseFindings(text, criterion, round) {
7863
9264
  const pattern = /<finding\s+([^>]*)>([\s\S]*?)<\/finding>/giu;
7864
9265
  const attributes = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/gu;
7865
9266
  const found = [];
@@ -7869,25 +9270,35 @@ function parseFindings(text, criterion) {
7869
9270
  for (let attribute = attributes.exec(match[1] ?? ""); attribute !== null; attribute = attributes.exec(match[1] ?? "")) if ([
7870
9271
  "criterion",
7871
9272
  "evidence",
9273
+ "location",
9274
+ "requirement",
9275
+ "observation",
9276
+ "gap",
9277
+ "quote",
7872
9278
  "action"
7873
9279
  ].includes(attribute[1] ?? "")) attrs.set(attribute[1] ?? "", attribute[2] ?? "");
7874
9280
  const named = attrs.get("criterion") ?? "";
7875
9281
  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;
9282
+ const location = readLocation(attrs.get("location") ?? attrs.get("evidence") ?? "", round);
9283
+ if (location === void 0) continue;
9284
+ const quote = (attrs.get("quote") ?? "").trim().slice(0, VERIFICATION_MAX_QUOTE_CHARS);
9285
+ const requirement = (attrs.get("requirement") ?? "").trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS);
9286
+ const observation = (attrs.get("observation") ?? "").trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS);
9287
+ const gap = (attrs.get("gap") ?? "").trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS);
7882
9288
  const body = (match[2] ?? "").trim().slice(0, 400);
7883
- if (body === "") continue;
9289
+ if (body === "" && quote === "") continue;
7884
9290
  const action = (attrs.get("action") ?? "").trim().slice(0, 300);
7885
- const key = named + "|" + evidence + "|" + body;
9291
+ const key = criterion.id + "|" + location + "|" + quote + "|" + body;
7886
9292
  if (seen.has(key)) continue;
7887
9293
  seen.add(key);
7888
9294
  found.push({
7889
- criterion: criterion.name,
7890
- evidence,
9295
+ criterionId: criterion.id,
9296
+ criterionName: criterion.name,
9297
+ location,
9298
+ quote,
9299
+ requirement,
9300
+ observation,
9301
+ gap,
7891
9302
  body,
7892
9303
  ...action === "" ? {} : { action }
7893
9304
  });
@@ -7896,20 +9307,31 @@ function parseFindings(text, criterion) {
7896
9307
  }
7897
9308
  /** Render one finding as the single feedback line the agent reads. */
7898
9309
  function renderFinding(finding) {
7899
- const locator = "[" + (finding.criterion ?? "finding") + " evidence=" + finding.evidence + "]";
9310
+ const locator = "[" + finding.criterionName + " " + finding.location + "]";
7900
9311
  const action = finding.action === void 0 ? "" : " → 建议核实:" + finding.action;
7901
- return locator + " " + finding.body + action;
9312
+ const detail = finding.body !== "" ? finding.body : finding.quote;
9313
+ return locator + " " + detail + action;
7902
9314
  }
7903
- /** One judge request's outcome. */
7904
- async function judgeOnce(llm, route, prompt, signal) {
9315
+ /**
9316
+ * One judge request's outcome.
9317
+ *
9318
+ * Every attempt is admitted against the acceptance budget first: a call whose
9319
+ * own ceiling no longer fits in the time left is refused before it is opened,
9320
+ * so a slow route cannot spend the whole acceptance budget on calls the outer
9321
+ * abort is going to kill anyway.
9322
+ */
9323
+ async function judgeOnce(llm, route, prompt, signal, budget) {
7905
9324
  const usage = emptyUsage();
7906
9325
  let lastError;
9326
+ /** The outer abort is the acceptance budget running out, not a cancellation. */
9327
+ 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
9328
  for (let attempt = 0; attempt < 3; attempt++) {
7908
- if (signal.aborted) throw new VerificationError("aborted", "the acceptance was cancelled before the judge answer", usage);
9329
+ if (signal.aborted) throw stopped();
9330
+ if (budget.deadline - budget.now() <= budget.callTimeoutMs) throw new VerificationError("budget", "the acceptance budget cannot fit another judge call", usage);
7909
9331
  const timeout = new AbortController();
7910
9332
  const timer = setTimeout(() => {
7911
9333
  timeout.abort(/* @__PURE__ */ new Error("task-board verification: the judge request timed out"));
7912
- }, VERIFICATION_CALL_TIMEOUT_MS);
9334
+ }, budget.callTimeoutMs);
7913
9335
  const forward = () => {
7914
9336
  timeout.abort(signal.reason);
7915
9337
  };
@@ -7944,7 +9366,7 @@ async function judgeOnce(llm, route, prompt, signal) {
7944
9366
  usage.reasoningTokens += typeof reported.reasoningTokens === "number" ? reported.reasoningTokens : 0;
7945
9367
  } else if (row.type === "finish") finishReason = chunk.type === "finish" ? chunk.reason : void 0;
7946
9368
  }
7947
- if (signal.aborted) throw new VerificationError("aborted", "the acceptance was cancelled", usage);
9369
+ if (signal.aborted) throw stopped();
7948
9370
  if (timeout.signal.aborted) throw new VerificationError("timeout", "the judge request timed out", usage);
7949
9371
  if (finishReason?.kind === "error" || finishReason?.kind === "aborted") {
7950
9372
  const failure = finishReason.failure;
@@ -7970,8 +9392,8 @@ async function judgeOnce(llm, route, prompt, signal) {
7970
9392
  } catch (error) {
7971
9393
  lastError = error;
7972
9394
  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);
9395
+ if (signal.aborted) throw stopped();
9396
+ if (timeout.signal.aborted) lastError = new VerificationError("timeout", "the judge request timed out after " + Math.round(budget.callTimeoutMs / 1e3) + "s", usage);
7975
9397
  const parseFailure = lastError instanceof VerificationError && lastError.kind === "parse";
7976
9398
  if (attempt + 1 >= 3 || !parseFailure && !retryable(lastError)) break;
7977
9399
  if (!parseFailure) usage.usageIncomplete = true;
@@ -7992,11 +9414,17 @@ async function judgeOnce(llm, route, prompt, signal) {
7992
9414
  * Run one acceptance: every criterion is judged twice, the odd round with the
7993
9415
  * A/B slots swapped, and the per-round scores are mapped back and averaged per
7994
9416
  * criterion.
7995
- * @param input - the judge route, evidence, threshold and cancellation.
7996
- * @returns the recorded attempt (quality verdict or anomaly).
9417
+ * @param input - the judge route, evidence, threshold, time budget and cancellation.
9418
+ * @returns the recorded attempt (quality verdict, anomaly, or budget stop).
7997
9419
  */
7998
9420
  async function runAcceptance(input) {
7999
- const startedAt = (input.now ?? Date.now)();
9421
+ const now = input.now ?? Date.now;
9422
+ const startedAt = now();
9423
+ const budget = {
9424
+ deadline: input.deadline ?? startedAt + DEFAULT_VERIFICATION_BUDGET_MS,
9425
+ callTimeoutMs: input.callTimeoutMs ?? 15e4,
9426
+ now
9427
+ };
8000
9428
  const usage = emptyUsage();
8001
9429
  const evidenceSummary = {
8002
9430
  chars: input.evidence.trace.length,
@@ -8009,6 +9437,13 @@ async function runAcceptance(input) {
8009
9437
  };
8010
9438
  const findings = [];
8011
9439
  const seenFindings = /* @__PURE__ */ new Set();
9440
+ const structured = [];
9441
+ const seenStructured = /* @__PURE__ */ new Set();
9442
+ const evidenceText = {
9443
+ task: input.evidence.problem,
9444
+ trajectory: input.evidence.trace,
9445
+ workspace: input.context ?? ""
9446
+ };
8012
9447
  try {
8013
9448
  const perCriterion = /* @__PURE__ */ new Map();
8014
9449
  for (const criterion of CODING_CRITERIA) perCriterion.set(criterion.id, {
@@ -8020,8 +9455,8 @@ async function runAcceptance(input) {
8020
9455
  const swapped = repeat % 2 === 1;
8021
9456
  const candidateA = swapped ? EMPTY_WORK_BASELINE : input.evidence.trace;
8022
9457
  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);
9458
+ const prompt = buildAcceptancePrompt(input.evidence.problem, candidateA, candidateB, criterion, void 0, input.context, workSlotOfRound(repeat));
9459
+ const answer = await judgeOnce(input.llm, input.route, prompt, input.signal, budget);
8025
9460
  const scoreA = extractScore(answer.text, "score_A");
8026
9461
  const scoreB = extractScore(answer.text, "score_B");
8027
9462
  if (answer.usage.usageIncomplete === true) usage.usageIncomplete = true;
@@ -8032,14 +9467,26 @@ async function runAcceptance(input) {
8032
9467
  const bucket = perCriterion.get(criterion.id);
8033
9468
  bucket.work.push(swapped ? scoreB : scoreA);
8034
9469
  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);
9470
+ for (const finding of parseFindings(answer.text, criterion, repeat)) {
9471
+ if (structured.length < VERIFICATION_MAX_FINDINGS) {
9472
+ const key = finding.criterionId + "|" + finding.location + "|" + finding.quote + "|" + finding.body;
9473
+ if (!seenStructured.has(key)) {
9474
+ seenStructured.add(key);
9475
+ structured.push({
9476
+ criterionId: finding.criterionId,
9477
+ requirement: finding.requirement !== "" ? finding.requirement : finding.criterionName,
9478
+ observation: finding.observation !== "" ? finding.observation : finding.body,
9479
+ gap: finding.gap !== "" ? finding.gap : finding.body,
9480
+ quote: finding.quote,
9481
+ location: finding.location,
9482
+ ...finding.action === void 0 ? {} : { action: finding.action }
9483
+ });
9484
+ }
9485
+ }
9486
+ const line = renderFinding(finding);
8040
9487
  if (seenFindings.has(line)) continue;
8041
9488
  seenFindings.add(line);
8042
- if (findings.length < 6) findings.push(line);
9489
+ if (findings.length < VERIFICATION_MAX_FINDINGS) findings.push(line);
8043
9490
  }
8044
9491
  }
8045
9492
  const criteria = CODING_CRITERIA.map((criterion) => {
@@ -8058,11 +9505,17 @@ async function runAcceptance(input) {
8058
9505
  const score = average(criteria.map((criterion) => criterion.score));
8059
9506
  const baseline = average(criteria.map((criterion) => criterion.baseline));
8060
9507
  const passed = acceptancePassed(score, baseline, criteria, input.threshold);
9508
+ const validity = passed ? { valid: true } : assessAcceptanceValidity({
9509
+ criteria,
9510
+ findings: structured,
9511
+ evidence: evidenceText,
9512
+ omittedCharacters: input.evidence.omittedCharacters
9513
+ });
8061
9514
  return { attempt: {
8062
9515
  index: input.index,
8063
9516
  at: startedAt,
8064
- stage: "quality",
8065
- passed,
9517
+ stage: validity.valid ? "quality" : "invalid",
9518
+ passed: validity.valid ? passed : false,
8066
9519
  score,
8067
9520
  baseline,
8068
9521
  criteria,
@@ -8071,7 +9524,12 @@ async function runAcceptance(input) {
8071
9524
  evidence: evidenceSummary,
8072
9525
  route: input.route,
8073
9526
  channel: "explicit-tag",
8074
- rounds: 2
9527
+ rounds: 2,
9528
+ ...structured.length === 0 ? {} : { criterionFindings: structured },
9529
+ ...validity.valid ? {} : {
9530
+ invalidReason: validity.reason,
9531
+ error: classifyInvalid(validity)
9532
+ }
8075
9533
  } };
8076
9534
  } catch (error) {
8077
9535
  const failure = error instanceof Error ? error : new Error(String(error));
@@ -8080,7 +9538,7 @@ async function runAcceptance(input) {
8080
9538
  return { attempt: {
8081
9539
  index: input.index,
8082
9540
  at: startedAt,
8083
- stage: "exception",
9541
+ stage: kind === "budget" ? "budget" : "exception",
8084
9542
  passed: false,
8085
9543
  score: 0,
8086
9544
  baseline: 0,
@@ -8110,6 +9568,16 @@ function mergeUsage(target, source) {
8110
9568
  ...target.usageIncomplete === true || source.usageIncomplete === true ? { usageIncomplete: true } : {}
8111
9569
  };
8112
9570
  }
9571
+ /** Prefix one invalid-acceptance message with its class so the report stays actionable. */
9572
+ function classifyInvalid(validity) {
9573
+ return {
9574
+ "missing-finding": "验收无效(未达标判据没有对应的结构化问题反馈)",
9575
+ "unlocatable-quote": "验收无效(问题反馈引用不存在于本次裁判所见的证据中)",
9576
+ "baseline-finding": "验收无效(问题反馈指向空工作基线而非本次工作)",
9577
+ "vacuous-finding": "验收无效(问题反馈空泛,未说明实际观测与差异)",
9578
+ "insufficient-evidence": "验收无效(裁判所见证据不足,无法支撑否决)"
9579
+ }[validity.reason] + ": " + validity.detail;
9580
+ }
8113
9581
  /** Prefix one anomaly message with its class so the report stays actionable. */
8114
9582
  function classifyMessage(kind, message) {
8115
9583
  return {
@@ -8118,7 +9586,8 @@ function classifyMessage(kind, message) {
8118
9586
  auth: "验收异常(鉴权失败)",
8119
9587
  parse: "验收异常(裁判回答无法解析)",
8120
9588
  request: "验收异常(裁判请求失败)",
8121
- aborted: "验收异常(验收被取消)"
9589
+ aborted: "验收异常(验收被取消)",
9590
+ budget: "验收未完成(时间预算耗尽,未产生任何判定,不计入异常额度)"
8122
9591
  }[kind] + ": " + message;
8123
9592
  }
8124
9593
  /** Character ceiling for one file's rendered comparison. */
@@ -8329,6 +9798,45 @@ function createGoalVerificationGate(deps) {
8329
9798
  blockGoal(agent, reason);
8330
9799
  return deny(bounded("[任务看板 · goal 验收判定失败] " + reason + "\n本次执行不会完成;请如实向用户报告验收未通过、已记录的分数与问题,不要再尝试标记完成。"), "TASK_BOARD_VERIFICATION_FAILED", reason);
8331
9800
  };
9801
+ /**
9802
+ * Hold the cycle open after its non-quality budget ran out.
9803
+ *
9804
+ * A judge route that times out, cannot authenticate or cannot be resolved is
9805
+ * the ENVIRONMENT failing, not the work, so this records the attempt and
9806
+ * refuses the completion claim without writing a `failedReason` and without
9807
+ * blocking the goal (issue #1828). The execution therefore survives for a
9808
+ * user who fixes the environment and clears the recorded anomalies, instead
9809
+ * of carrying a verdict that only the clock produced.
9810
+ */
9811
+ const hold = (taskId, executionId, verification, message, detail, code) => {
9812
+ write(taskId, executionId, {
9813
+ ...verification,
9814
+ inFlight: void 0
9815
+ });
9816
+ return deny(bounded(message), code, detail);
9817
+ };
9818
+ /** The refusal one recorded anomaly returns to the fixing agent. */
9819
+ const anomalyRefusal = (spend, error) => deny(bounded("[任务看板 · 验收异常] " + (error ?? "unknown") + "\n这是验收异常而非质量判定,本次未消耗质量验收额度(已用 " + exceptionAttempts(spend).length + "/2)。请修复环境后再次调用 update_goal(action: complete)。"), "TASK_BOARD_VERIFICATION_ANOMALY", error);
9820
+ /**
9821
+ * The refusal one recorded INVALID acceptance returns.
9822
+ *
9823
+ * The claim is still refused (no pass record exists), but the agent is told
9824
+ * the veto itself was unusable and is explicitly NOT asked to repair anything
9825
+ * on that basis. The reason and scores stay recorded for review; the quality
9826
+ * budget is untouched.
9827
+ */
9828
+ const invalidRefusal = (spend, attempt) => deny(bounded(invalidAcceptanceFeedback(attempt, 2 - invalidAttempts(spend).length)), "TASK_BOARD_VERIFICATION_INVALID", attempt.error);
9829
+ /**
9830
+ * Hold a cycle whose invalid-acceptance budget is spent.
9831
+ *
9832
+ * No usable veto was ever produced, so the board neither passes nor fails the
9833
+ * card: it stops calling the judge and leaves the execution for a human. Only
9834
+ * an explicit user action (or a rerun) resumes it, which is what bounds the
9835
+ * retries.
9836
+ */
9837
+ 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");
9838
+ /** The refusal a spent anomaly budget returns: no verdict, and no card failure. */
9839
+ 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
9840
  /** Record one attempt and decide whether the completion claim may proceed. */
8333
9841
  const settle = async (input) => {
8334
9842
  const { task, execution, verification, agent, objective } = input;
@@ -8373,9 +9881,9 @@ function createGoalVerificationGate(deps) {
8373
9881
  attempts,
8374
9882
  inFlight: void 0
8375
9883
  };
8376
- if (exceptionAttempts(spend).length >= 2) return finalize(task.id, execution.id, spend, "验收异常达到上限:" + attempt.error, agent);
9884
+ if (exceptionAttempts(spend).length >= 2) return anomalyHeld(task.id, execution.id, spend);
8377
9885
  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);
9886
+ return anomalyRefusal(spend, attempt.error);
8379
9887
  }
8380
9888
  write(task.id, execution.id, baselines);
8381
9889
  const llm = deps.llm?.();
@@ -8411,13 +9919,16 @@ function createGoalVerificationGate(deps) {
8411
9919
  attempts: [...verification.attempts, attempt],
8412
9920
  inFlight: void 0
8413
9921
  };
8414
- if (exceptionAttempts(spend).length >= 2) return finalize(task.id, execution.id, spend, "验收异常达到上限:" + attempt.error, agent);
9922
+ if (exceptionAttempts(spend).length >= 2) return anomalyHeld(task.id, execution.id, spend);
8415
9923
  write(task.id, execution.id, spend);
8416
9924
  return deny(bounded("[任务看板 · 验收异常] " + attempt.error), "TASK_BOARD_VERIFICATION_ANOMALY", attempt.error);
8417
9925
  }
8418
9926
  const session = agent.session;
8419
9927
  const evidence = collectEvidence(session, objective, execution.startedAt);
8420
- const acceptance = AbortSignal.timeout(6e5);
9928
+ const callTimeout = deps.callTimeoutMs?.() ?? verificationCallTimeoutMs(void 0);
9929
+ const budget = deps.budgetMs?.() ?? DEFAULT_VERIFICATION_BUDGET_MS;
9930
+ const deadline = now() + budget;
9931
+ const acceptance = AbortSignal.timeout(budget);
8421
9932
  const source = deps.workspaceChanges?.();
8422
9933
  const workspace = source === void 0 ? {
8423
9934
  text: "",
@@ -8437,6 +9948,8 @@ function createGoalVerificationGate(deps) {
8437
9948
  context: workspace.text === "" ? void 0 : workspace.text,
8438
9949
  workspaceFiles: workspace.files,
8439
9950
  signal: acceptance,
9951
+ deadline,
9952
+ callTimeoutMs: callTimeout,
8440
9953
  now
8441
9954
  })).attempt;
8442
9955
  const attempts = [...verification.attempts, attempt];
@@ -8445,10 +9958,16 @@ function createGoalVerificationGate(deps) {
8445
9958
  attempts,
8446
9959
  inFlight: void 0
8447
9960
  };
9961
+ 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
9962
  if (attempt.stage === "exception") {
8449
- if (exceptionAttempts(spend).length >= 2) return finalize(task.id, execution.id, spend, "验收异常达到上限:" + (attempt.error ?? "unknown"), agent);
9963
+ if (exceptionAttempts(spend).length >= 2) return anomalyHeld(task.id, execution.id, spend);
9964
+ write(task.id, execution.id, spend);
9965
+ return anomalyRefusal(spend, attempt.error);
9966
+ }
9967
+ if (attempt.stage === "invalid") {
9968
+ if (invalidAttempts(spend).length >= 2) return invalidHeld(task.id, execution.id, spend);
8450
9969
  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);
9970
+ return invalidRefusal(spend, attempt);
8452
9971
  }
8453
9972
  if (attempt.passed) {
8454
9973
  write(task.id, execution.id, spend);
@@ -8457,7 +9976,7 @@ function createGoalVerificationGate(deps) {
8457
9976
  const used = qualityAttempts(spend).length;
8458
9977
  if (used >= 2) return finalize(task.id, execution.id, spend, finalFailureReason(attempt, used, verification.contract.threshold), agent);
8459
9978
  write(task.id, execution.id, spend);
8460
- return deny(bounded(qualityFeedback(attempt, 2 - used)), "TASK_BOARD_VERIFICATION_FAILED", attempt.findings.join("\n"));
9979
+ return deny(bounded(qualityFeedback(attempt, 2 - used)), "TASK_BOARD_VERIFICATION_FAILED", locatedFindingDetail(attempt));
8461
9980
  };
8462
9981
  /** Evaluate one tool call; undefined means "not this gate's business". */
8463
9982
  const evaluate = async (exec) => {
@@ -8472,7 +9991,9 @@ function createGoalVerificationGate(deps) {
8472
9991
  if (verification.applicability !== "enforced") return void 0;
8473
9992
  if (passedAttempt(verification) !== void 0) return { kind: "allow" };
8474
9993
  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);
9994
+ if (!hasInvalidBudget(verification)) return invalidHeld(binding.task.id, binding.execution.id, verification);
9995
+ if (!hasQualityBudget(verification)) return finalize(binding.task.id, binding.execution.id, verification, "goal 验收额度已耗尽,本次 execution 判失败。", exec.agent);
9996
+ if (!hasExceptionBudget(verification)) return anomalyHeld(binding.task.id, binding.execution.id, verification);
8476
9997
  const key = binding.execution.id;
8477
9998
  const running = inFlight.get(key);
8478
9999
  if (running !== void 0) return await running;
@@ -8514,6 +10035,17 @@ function createGoalVerificationGate(deps) {
8514
10035
  }
8515
10036
  };
8516
10037
  }
10038
+ /**
10039
+ * The structured detail of one quality refusal: the judge's own findings, in
10040
+ * the order the acceptance recorded them, so the denial carries exactly the
10041
+ * located evidence the report shows.
10042
+ */
10043
+ function locatedFindingDetail(attempt) {
10044
+ const lines = [];
10045
+ for (const finding of attempt.criterionFindings ?? []) lines.push("[" + finding.criterionId + " " + finding.location + "] \"" + finding.quote + "\"");
10046
+ if (lines.length === 0) lines.push(...attempt.findings);
10047
+ return lines.join("\n");
10048
+ }
8517
10049
  /** The bare execution prompt, used when the goal service carries no objective. */
8518
10050
  function taskPrompt(task) {
8519
10051
  return task.prompt.trim() !== "" ? task.prompt : task.title;
@@ -8547,14 +10079,24 @@ const Config = z.object({
8547
10079
  proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
8548
10080
  sessionDefaultPermission: z.union(TASK_PERMISSIONS),
8549
10081
  maxSubtaskDepth: z.number().min(1).max(3).default(1).volatile(),
10082
+ sessionPollSeconds: z.number().min(1).max(300).default(5).volatile(),
8550
10083
  teamProvider: z.string().min(1).default(DEFAULT_TEAM_PROVIDER),
10084
+ goalRunEnabled: z.boolean().default(false).volatile(),
8551
10085
  goalVerification: z.boolean().default(true).volatile(),
8552
10086
  goalVerificationModel: z.string().default("").volatile(),
8553
- goalVerificationReasoningEffort: z.string().default("").volatile()
10087
+ goalVerificationReasoningEffort: z.string().default("").volatile(),
10088
+ goalVerificationCallTimeoutSeconds: z.number().min(30).max(600).default(150).volatile(),
10089
+ goalVerificationBudgetSeconds: z.number().min(120).max(VERIFICATION_BUDGET_MAX_SECONDS).default(DEFAULT_VERIFICATION_BUDGET_SECONDS).volatile()
8554
10090
  });
8555
10091
  /** Schema default for the goal-acceptance switch, re-read for hand-built contexts. */
8556
10092
  const DEFAULT_GOAL_VERIFICATION = true;
8557
10093
  /**
10094
+ * Schema default for the GLOBAL native-/goal switch, re-read for hand-built
10095
+ * contexts. Deliberately false: a deployment that never configured it (and
10096
+ * every card written before the option existed) runs plain single turns.
10097
+ */
10098
+ const DEFAULT_GOAL_RUN = false;
10099
+ /**
8558
10100
  * Read one config field's current value.
8559
10101
  *
8560
10102
  * The Loader hands schema-volatile fields as stable references it commits in
@@ -8744,6 +10286,8 @@ function applyImpl(ctx, config) {
8744
10286
  const preventIdleSleep = () => readConfigField(config?.preventIdleSleep, false);
8745
10287
  /** Current subtask depth limit (read live: the settings card edits it in place). */
8746
10288
  const maxSubtaskDepth = () => readConfigField(config?.maxSubtaskDepth, 1);
10289
+ /** Current roster-poll cadence in seconds (read live: the settings card edits it in place). */
10290
+ const sessionPollSeconds = () => readConfigField(config?.sessionPollSeconds, 5);
8747
10291
  /**
8748
10292
  * The baseline the permission confirmation gate judges against: the row's own
8749
10293
  * `sessionDefaultPermission` when the deployment pins one, otherwise the Host's
@@ -8758,15 +10302,29 @@ function applyImpl(ctx, config) {
8758
10302
  model: readConfigField(config?.goalVerificationModel, ""),
8759
10303
  reasoningEffort: readConfigField(config?.goalVerificationReasoningEffort, "")
8760
10304
  });
10305
+ /**
10306
+ * The acceptance time ceilings, read live: raising one must unblock a card
10307
+ * whose acceptance is stuck on a slow judge route without a remount, so
10308
+ * these deliberately are NOT frozen into the per-execution contract.
10309
+ */
10310
+ const verificationCallTimeoutMsLive = () => verificationCallTimeoutMs(readConfigField(config?.goalVerificationCallTimeoutSeconds, 150));
10311
+ const verificationBudgetMsLive = () => verificationBudgetMs(readConfigField(config?.goalVerificationBudgetSeconds, DEFAULT_VERIFICATION_BUDGET_SECONDS));
8761
10312
  let hostForCatalog;
8762
10313
  const verificationCatalog = async () => {
8763
10314
  const runner = hostForCatalog?.runner;
8764
10315
  return runner === void 0 ? void 0 : normalizeCatalog(await runner.modelCatalog());
8765
10316
  };
10317
+ /**
10318
+ * Live reader of the GLOBAL native-/goal switch. The service calls it exactly
10319
+ * once per execution and carries the value, so a settings edit only reaches
10320
+ * executions that start afterwards.
10321
+ */
10322
+ const goalRunEnabled = () => readConfigField(config?.goalRunEnabled, false);
8766
10323
  const host = new TaskBoardHostService(ctx.typertGateway, {
8767
10324
  workspaceRegistry: ctx.workspaceRegistry,
8768
10325
  sessionDefaultPermission,
8769
10326
  maxSubtaskDepth: maxSubtaskDepth(),
10327
+ goalRunEnabled,
8770
10328
  verificationSettings,
8771
10329
  verificationCatalog,
8772
10330
  timers: resolveHostTimers(ctx),
@@ -8778,7 +10336,7 @@ function applyImpl(ctx, config) {
8778
10336
  } }
8779
10337
  });
8780
10338
  hostForCatalog = host;
8781
- host.setConfiguration(enabled(), preventIdleSleep());
10339
+ host.setConfiguration(enabled(), preventIdleSleep(), sessionPollSeconds());
8782
10340
  host.start();
8783
10341
  if (typeof ctx.provide === "function") ctx.provide(TASK_BOARD_SERVICE_NAME, {
8784
10342
  registerExtension: (extension) => host.registerExtension(extension),
@@ -8824,6 +10382,8 @@ function applyImpl(ctx, config) {
8824
10382
  }
8825
10383
  },
8826
10384
  workspaceChanges: () => probeWorkspaceChanges(ctx),
10385
+ callTimeoutMs: verificationCallTimeoutMsLive,
10386
+ budgetMs: verificationBudgetMsLive,
8827
10387
  logger: { warn: (message, ...rest) => {
8828
10388
  const logger = ctx.logger;
8829
10389
  if (typeof logger?.warn === "function") logger.warn(message, ...rest);
@@ -8870,12 +10430,13 @@ function applyImpl(ctx, config) {
8870
10430
  enabled: enabled(),
8871
10431
  announceToAgent: announceToAgent(),
8872
10432
  preventIdleSleep: preventIdleSleep(),
8873
- maxSubtaskDepth: maxSubtaskDepth()
10433
+ maxSubtaskDepth: maxSubtaskDepth(),
10434
+ sessionPollSeconds: sessionPollSeconds()
8874
10435
  };
8875
- if (applied !== void 0 && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep && applied.maxSubtaskDepth === next.maxSubtaskDepth) return;
10436
+ 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
10437
  applied = next;
8877
10438
  host.ledger.setMaxSubtaskDepth(next.maxSubtaskDepth);
8878
- host.setConfiguration(next.enabled, next.preventIdleSleep);
10439
+ host.setConfiguration(next.enabled, next.preventIdleSleep, next.sessionPollSeconds);
8879
10440
  setToolsEnabled(next.enabled);
8880
10441
  if (disposeSection !== void 0) {
8881
10442
  disposeSection();
@@ -8894,4 +10455,4 @@ function applyImpl(ctx, config) {
8894
10455
  sync();
8895
10456
  }
8896
10457
  //#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 };
10458
+ export { Config, DEFAULT_GOAL_RUN, DEFAULT_GOAL_VERIFICATION, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveHostDefaultPermission, resolveHostTimers, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };