@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.
- package/README.i18n.yaml +2 -2
- package/README.md +20 -12
- package/README.zh.md +20 -12
- package/lib/client.js +1327 -229
- package/lib/client.js.map +1 -1
- package/lib/index.js +1909 -348
- package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
- package/lib/types/client/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +65 -0
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/core/controller.d.ts +10 -11
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +144 -14
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- package/lib/types/host/verification-gate.d.ts +24 -0
- package/lib/types/host/verification-runner.d.ts +38 -7
- package/lib/types/host-ledger.d.ts +82 -7
- package/lib/types/host-runner.d.ts +32 -9
- package/lib/types/host-service.d.ts +117 -9
- package/lib/types/index.d.ts +54 -0
- package/lib/types/protocol.d.ts +50 -12
- package/package.json +2 -6
- package/src/client/TaskBoardSettingsCard.tsx +74 -1
- package/src/client/board/NewTaskModal.tsx +151 -20
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +219 -18
- package/src/client/board/VerificationReport.tsx +132 -13
- package/src/client/board/status-key.ts +3 -0
- package/src/client/index.ts +43 -50
- package/src/client/locales.ts +137 -4
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/core/controller.ts +12 -8
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/store.ts +64 -15
- package/src/core/tasks.ts +193 -39
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +3 -3
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +106 -21
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +328 -42
- package/src/host-runner.ts +52 -19
- package/src/host-service.ts +299 -36
- package/src/index.ts +101 -5
- package/src/protocol.ts +132 -28
- package/lib/invariant.js +0 -6
- package/lib/types/client/board-card-seat.d.ts +0 -34
- package/lib/types/invariant.d.ts +0 -3
- package/src/client/board-card-seat.ts +0 -102
- 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 ===
|
|
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
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
1999
|
+
const located = locatedFindingLines(attempt);
|
|
2000
|
+
if (located.length > 0) {
|
|
1519
2001
|
lines.push("可定位问题:");
|
|
1520
|
-
for (const
|
|
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
|
-
|
|
2076
|
+
const located = locatedFindingLines(attempt);
|
|
2077
|
+
if (located.length > 0) {
|
|
1542
2078
|
lines.push("验收报告记录的可定位问题:");
|
|
1543
|
-
for (const
|
|
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
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
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
|
|
2362
|
+
const maxRuns = optionalRunCap(rule.maxRuns);
|
|
1763
2363
|
return {
|
|
1764
|
-
|
|
2364
|
+
...common,
|
|
2365
|
+
mode: "cron",
|
|
1765
2366
|
cron: rule.cron,
|
|
1766
|
-
...
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
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
|
|
2051
|
-
* Pure ledger transitions (no persistence or notify
|
|
2052
|
-
* orchestrates those). Validation and next-run computation
|
|
2053
|
-
* the core cron parser (schedule.ts) and the withSchedule
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
*
|
|
2057
|
-
*
|
|
2058
|
-
*
|
|
2059
|
-
*
|
|
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
|
|
2079
|
-
if (
|
|
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
|
|
2084
|
-
const
|
|
2085
|
-
|
|
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
|
|
2091
|
-
enabled,
|
|
2780
|
+
tasks: tasks.map((candidate) => candidate.id !== id ? candidate : withSchedule(candidate, {
|
|
2781
|
+
enabled: armed,
|
|
2782
|
+
mode: "cron",
|
|
2092
2783
|
cron,
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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
|
-
*
|
|
2101
|
-
*
|
|
2102
|
-
*
|
|
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
|
|
2105
|
-
* @param
|
|
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
|
|
2110
|
-
return tasks.map((task) =>
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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 ===
|
|
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
|
|
2389
|
-
* per-execution acceptance block
|
|
2390
|
-
*
|
|
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
|
-
"
|
|
3358
|
+
"at",
|
|
3359
|
+
"timeZone",
|
|
3360
|
+
"maxRuns"
|
|
2573
3361
|
])) return false;
|
|
2574
|
-
if (typeof schedule.enabled !== "boolean"
|
|
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
|
-
"
|
|
2628
|
-
|
|
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
|
-
|
|
2840
|
-
|
|
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:
|
|
4047
|
+
tasks: tasksForRead(this.document.tasks),
|
|
3206
4048
|
scheduler
|
|
3207
4049
|
};
|
|
3208
4050
|
}
|
|
3209
4051
|
/**
|
|
3210
|
-
* Runtime-only projection for the
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
3324
|
-
|
|
3325
|
-
|
|
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
|
-
|
|
4192
|
+
this.skipOccurrence(taskId, "permission", triggeredAt);
|
|
3331
4193
|
return [];
|
|
3332
4194
|
}
|
|
3333
4195
|
if (hasOpenExecution(task)) {
|
|
3334
|
-
|
|
4196
|
+
this.skipOccurrence(taskId, "busy", triggeredAt);
|
|
3335
4197
|
return [];
|
|
3336
4198
|
}
|
|
3337
|
-
return this.startCascade(task, triggeredAt, void 0, false,
|
|
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
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
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
|
|
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 !==
|
|
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
|
|
3953
|
-
return typeof
|
|
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:
|
|
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:
|
|
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
|
|
4265
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
4550
|
-
*
|
|
4551
|
-
*
|
|
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 (
|
|
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
|
-
/**
|
|
5477
|
-
const
|
|
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
|
-
},
|
|
6683
|
+
}, sessionPollMs(this.sessionPollSeconds));
|
|
5603
6684
|
this.schedulePoll();
|
|
6685
|
+
this.cleanupPendingAcceptanceDetail();
|
|
5604
6686
|
this.recoverSchedule();
|
|
5605
6687
|
}
|
|
5606
|
-
|
|
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:
|
|
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:
|
|
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 :
|
|
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 :
|
|
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" :
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
6999
|
+
const current = this.ledger.runtimeView();
|
|
5876
7000
|
this.power.updateReasons({
|
|
5877
7001
|
runningSessions: running.count,
|
|
5878
|
-
armedSchedules:
|
|
7002
|
+
armedSchedules: current.armedSchedules,
|
|
5879
7003
|
sessionStateKnown: true
|
|
5880
7004
|
});
|
|
5881
|
-
await this.reconcileExecutions(running.items,
|
|
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",
|
|
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
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
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().
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
6716
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
6969
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
-
/**
|
|
7706
|
-
const
|
|
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
|
-
/**
|
|
7862
|
-
function
|
|
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
|
|
7877
|
-
if (
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
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 =
|
|
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
|
-
|
|
7890
|
-
|
|
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 = "[" +
|
|
9310
|
+
const locator = "[" + finding.criterionName + " " + finding.location + "]";
|
|
7900
9311
|
const action = finding.action === void 0 ? "" : " → 建议核实:" + finding.action;
|
|
7901
|
-
|
|
9312
|
+
const detail = finding.body !== "" ? finding.body : finding.quote;
|
|
9313
|
+
return locator + " " + detail + action;
|
|
7902
9314
|
}
|
|
7903
|
-
/**
|
|
7904
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
|
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
|
|
7974
|
-
if (timeout.signal.aborted) lastError = new VerificationError("timeout", "the judge request timed out after " + Math.round(
|
|
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
|
|
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
|
|
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
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
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 <
|
|
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
|
|
9884
|
+
if (exceptionAttempts(spend).length >= 2) return anomalyHeld(task.id, execution.id, spend);
|
|
8377
9885
|
write(task.id, execution.id, spend);
|
|
8378
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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 };
|