@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4
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 +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
- package/src/host/run-guarded.ts +0 -76
package/lib/index.js
CHANGED
|
@@ -16,6 +16,112 @@ const FIELD_RANGES = [
|
|
|
16
16
|
[1, 12],
|
|
17
17
|
[0, 7]
|
|
18
18
|
];
|
|
19
|
+
/** IANA `Area/Location`, plus the bare `UTC` the platform reports. */
|
|
20
|
+
const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
|
|
21
|
+
/** Cached formatters: constructing an Intl formatter dominates the cost of a resolve. */
|
|
22
|
+
const FORMATTERS = /* @__PURE__ */ new Map();
|
|
23
|
+
function zoneFormatter(timeZone) {
|
|
24
|
+
let formatter = FORMATTERS.get(timeZone);
|
|
25
|
+
if (formatter === void 0) {
|
|
26
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
27
|
+
timeZone,
|
|
28
|
+
hourCycle: "h23",
|
|
29
|
+
year: "numeric",
|
|
30
|
+
month: "2-digit",
|
|
31
|
+
day: "2-digit",
|
|
32
|
+
hour: "2-digit",
|
|
33
|
+
minute: "2-digit"
|
|
34
|
+
});
|
|
35
|
+
FORMATTERS.set(timeZone, formatter);
|
|
36
|
+
}
|
|
37
|
+
return formatter;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The wall-clock reading of one instant in an explicit zone. Reading through
|
|
41
|
+
* Intl (rather than the process `Date` accessors) is what makes the result
|
|
42
|
+
* independent of the Host's own TZ.
|
|
43
|
+
* @param timeZone - explicit IANA zone.
|
|
44
|
+
* @param epochMs - instant to read.
|
|
45
|
+
* @returns the zone-local calendar and clock fields.
|
|
46
|
+
*/
|
|
47
|
+
function partsInZone(timeZone, epochMs) {
|
|
48
|
+
const fields = {};
|
|
49
|
+
for (const part of zoneFormatter(timeZone).formatToParts(new Date(epochMs))) if (part.type !== "literal") fields[part.type] = part.value;
|
|
50
|
+
return {
|
|
51
|
+
year: Number(fields.year),
|
|
52
|
+
month: Number(fields.month),
|
|
53
|
+
day: Number(fields.day),
|
|
54
|
+
hour: Number(fields.hour),
|
|
55
|
+
minute: Number(fields.minute)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** The zone's UTC offset in effect at one instant, in milliseconds. */
|
|
59
|
+
function offsetAt(timeZone, epochMs) {
|
|
60
|
+
const parts = partsInZone(timeZone, epochMs);
|
|
61
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute) - epochMs;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a wall clock in an explicit zone to its instant.
|
|
65
|
+
*
|
|
66
|
+
* The offset is probed a day either side of the naive guess so both DST
|
|
67
|
+
* transitions are covered, then every candidate offset whose inverse maps back
|
|
68
|
+
* to the requested wall clock is kept. No survivor means the wall clock does
|
|
69
|
+
* not exist (a spring-forward gap); several means it is ambiguous (a fall-back
|
|
70
|
+
* overlap), and the earliest instant wins so the repeated clock is taken once.
|
|
71
|
+
* @param timeZone - explicit IANA zone.
|
|
72
|
+
* @param year - full calendar year.
|
|
73
|
+
* @param month - 1-12.
|
|
74
|
+
* @param day - day of month.
|
|
75
|
+
* @param hour - 0-23.
|
|
76
|
+
* @param minute - 0-59.
|
|
77
|
+
* @returns the instant in ms epoch, or undefined when the wall clock is skipped.
|
|
78
|
+
*/
|
|
79
|
+
function zonedEpoch(timeZone, year, month, day, hour, minute) {
|
|
80
|
+
const naive = Date.UTC(year, month - 1, day, hour, minute);
|
|
81
|
+
const offsets = /* @__PURE__ */ new Set([
|
|
82
|
+
offsetAt(timeZone, naive - 864e5),
|
|
83
|
+
offsetAt(timeZone, naive),
|
|
84
|
+
offsetAt(timeZone, naive + 864e5)
|
|
85
|
+
]);
|
|
86
|
+
let earliest;
|
|
87
|
+
for (const offset of offsets) {
|
|
88
|
+
const candidate = naive - offset;
|
|
89
|
+
const parts = partsInZone(timeZone, candidate);
|
|
90
|
+
if (parts.year !== year || parts.month !== month || parts.day !== day) continue;
|
|
91
|
+
if (parts.hour !== hour || parts.minute !== minute) continue;
|
|
92
|
+
if (earliest === void 0 || candidate < earliest) earliest = candidate;
|
|
93
|
+
}
|
|
94
|
+
return earliest;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The Host process's IANA zone, used for schedules that store no zone of their
|
|
98
|
+
* own (a ledger written before zones were persisted).
|
|
99
|
+
* @returns a zone name Intl accepts, never empty.
|
|
100
|
+
*/
|
|
101
|
+
function resolveHostTimeZone() {
|
|
102
|
+
try {
|
|
103
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
104
|
+
return typeof zone === "string" && zone !== "" ? zone : "UTC";
|
|
105
|
+
} catch {
|
|
106
|
+
return "UTC";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether a value names a zone this runtime can actually resolve. A stored
|
|
111
|
+
* zone is trusted only after it round-trips through Intl, so a typo or a zone
|
|
112
|
+
* that this ICU build does not know can never arm a schedule.
|
|
113
|
+
* @param value - candidate zone name.
|
|
114
|
+
* @returns true when the name is usable.
|
|
115
|
+
*/
|
|
116
|
+
function isValidTimeZone(value) {
|
|
117
|
+
if (value.trim() !== value || value === "") return false;
|
|
118
|
+
if (value !== "UTC" && !IANA_ZONE.test(value)) return false;
|
|
119
|
+
try {
|
|
120
|
+
return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone !== void 0;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
19
125
|
/**
|
|
20
126
|
* Parse a 5-field cron expression.
|
|
21
127
|
* @returns the match sets, or null when the expression is invalid.
|
|
@@ -38,8 +144,8 @@ function parseCron(expr) {
|
|
|
38
144
|
days: sets[2],
|
|
39
145
|
months: sets[3],
|
|
40
146
|
weekdays,
|
|
41
|
-
|
|
42
|
-
|
|
147
|
+
dayStarred: fields[2].startsWith("*"),
|
|
148
|
+
weekdayStarred: fields[4].startsWith("*")
|
|
43
149
|
};
|
|
44
150
|
}
|
|
45
151
|
/** Whether the expression parses. */
|
|
@@ -47,52 +153,57 @@ function isValidCron(expr) {
|
|
|
47
153
|
return parseCron(expr) !== null;
|
|
48
154
|
}
|
|
49
155
|
/**
|
|
50
|
-
* Compute the next matching instant after `fromMs`
|
|
51
|
-
*
|
|
52
|
-
* of the matching minute's start, or undefined
|
|
53
|
-
* can never match (for example `0 0 30 2 *`).
|
|
54
|
-
* a full leap cycle, so a valid February 29
|
|
55
|
-
* every non-leap year.
|
|
156
|
+
* Compute the next matching instant after `fromMs` strictly greater than it,
|
|
157
|
+
* interpreting the expression's wall clock in `timeZone` (the Host zone when
|
|
158
|
+
* omitted). Returns the ms epoch of the matching minute's start, or undefined
|
|
159
|
+
* when the calendar constraint can never match (for example `0 0 30 2 *`).
|
|
160
|
+
* The five-year horizon includes a full leap cycle, so a valid February 29
|
|
161
|
+
* schedule remains reachable from every non-leap year.
|
|
56
162
|
*
|
|
57
163
|
* Walks candidate year/month/day/hour/minute values straight from the parsed
|
|
58
164
|
* field sets instead of scanning every minute: a sparse expression such as
|
|
59
165
|
* `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
|
|
60
|
-
* next leap day.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
166
|
+
* next leap day. A candidate wall clock that the zone skips (spring-forward
|
|
167
|
+
* gap) is passed over, which is what makes the gap semantics fall out of the
|
|
168
|
+
* candidate walk rather than needing a separate rule.
|
|
169
|
+
* @param expr - 5-field cron expression.
|
|
170
|
+
* @param fromMs - exclusive lower bound, ms epoch.
|
|
171
|
+
* @param timeZone - explicit IANA zone; defaults to the Host zone.
|
|
172
|
+
* @returns the next trigger instant, or undefined when unreachable.
|
|
63
173
|
*/
|
|
64
|
-
function nextRunAtMs(expr, fromMs) {
|
|
174
|
+
function nextRunAtMs(expr, fromMs, timeZone) {
|
|
65
175
|
const schedule = parseCron(expr);
|
|
66
176
|
if (schedule === null) return void 0;
|
|
67
177
|
if (!hasPossibleCalendarDay(schedule)) return void 0;
|
|
68
|
-
const
|
|
178
|
+
const zone = timeZone ?? resolveHostTimeZone();
|
|
179
|
+
const start = partsInZone(zone, fromMs);
|
|
69
180
|
const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
|
|
70
181
|
const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b);
|
|
71
182
|
const sortedHours = [...schedule.hours].sort((a, b) => a - b);
|
|
72
183
|
const sortedMonths = [...schedule.months].sort((a, b) => a - b);
|
|
73
|
-
let year =
|
|
74
|
-
let month =
|
|
75
|
-
let day =
|
|
76
|
-
let hour =
|
|
77
|
-
let minute =
|
|
78
|
-
while (
|
|
184
|
+
let year = start.year;
|
|
185
|
+
let month = start.month;
|
|
186
|
+
let day = start.day;
|
|
187
|
+
let hour = start.hour;
|
|
188
|
+
let minute = start.minute + 1;
|
|
189
|
+
while (Date.UTC(year, month - 1, 1) <= limitMs + 864e5) {
|
|
79
190
|
for (const candidateMonth of sortedMonths) {
|
|
80
191
|
if (candidateMonth < month) continue;
|
|
81
|
-
const daysInMonth = new Date(year, candidateMonth, 0).
|
|
192
|
+
const daysInMonth = new Date(Date.UTC(year, candidateMonth, 0)).getUTCDate();
|
|
82
193
|
const dayStart = candidateMonth === month ? day : 1;
|
|
83
194
|
for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
|
|
84
|
-
if (!dayCandidate(schedule,
|
|
195
|
+
if (!dayCandidate(schedule, year, candidateMonth, candidateDay)) continue;
|
|
85
196
|
const hourStart = candidateMonth === month && candidateDay === day ? hour : 0;
|
|
86
197
|
for (const candidateHour of sortedHours) {
|
|
87
198
|
if (candidateHour < hourStart) continue;
|
|
88
199
|
const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0;
|
|
89
200
|
for (const candidateMinute of sortedMinutes) {
|
|
90
201
|
if (candidateMinute < minuteStart) continue;
|
|
91
|
-
const
|
|
92
|
-
|
|
202
|
+
const time = zonedEpoch(zone, year, candidateMonth, candidateDay, candidateHour, candidateMinute);
|
|
203
|
+
if (time === void 0) continue;
|
|
93
204
|
if (time <= fromMs) continue;
|
|
94
205
|
if (time > limitMs) return void 0;
|
|
95
|
-
|
|
206
|
+
return time;
|
|
96
207
|
}
|
|
97
208
|
}
|
|
98
209
|
}
|
|
@@ -104,17 +215,20 @@ function nextRunAtMs(expr, fromMs) {
|
|
|
104
215
|
minute = 0;
|
|
105
216
|
}
|
|
106
217
|
}
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
218
|
+
/**
|
|
219
|
+
* The Vixie day gate shared by the candidate walk. Both fields restricted is
|
|
220
|
+
* OR; any other combination is AND, because a starred field matches every
|
|
221
|
+
* value and so cannot widen the result.
|
|
222
|
+
*/
|
|
223
|
+
function dayCandidate(schedule, year, month, day) {
|
|
224
|
+
const dayMatches = schedule.days.has(day);
|
|
225
|
+
const weekdayMatches = schedule.weekdays.has(new Date(Date.UTC(year, month - 1, day)).getUTCDay());
|
|
226
|
+
if (!schedule.dayStarred && !schedule.weekdayStarred) return dayMatches || weekdayMatches;
|
|
227
|
+
return dayMatches && weekdayMatches;
|
|
114
228
|
}
|
|
115
229
|
/** Reject impossible month/day pairs without spending the multi-year scan. */
|
|
116
230
|
function hasPossibleCalendarDay(schedule) {
|
|
117
|
-
if (schedule.
|
|
231
|
+
if (schedule.dayStarred || !schedule.weekdayStarred) return true;
|
|
118
232
|
const maximumDay = /* @__PURE__ */ new Map([
|
|
119
233
|
[1, 31],
|
|
120
234
|
[2, 29],
|
|
@@ -165,13 +279,6 @@ function parseField(field, min, max, out) {
|
|
|
165
279
|
}
|
|
166
280
|
return true;
|
|
167
281
|
}
|
|
168
|
-
/** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
|
|
169
|
-
function matches(schedule, date) {
|
|
170
|
-
if (!schedule.minutes.has(date.getMinutes())) return false;
|
|
171
|
-
if (!schedule.hours.has(date.getHours())) return false;
|
|
172
|
-
if (!schedule.months.has(date.getMonth() + 1)) return false;
|
|
173
|
-
return dayCandidate(schedule, date);
|
|
174
|
-
}
|
|
175
282
|
function isDigits(value) {
|
|
176
283
|
return /^\d+$/.test(value);
|
|
177
284
|
}
|
|
@@ -379,6 +486,7 @@ function createTask(input, now, id) {
|
|
|
379
486
|
model: normalizeTargetId(input.model),
|
|
380
487
|
reuseSession: input.reuseSession === true ? true : void 0,
|
|
381
488
|
teamRun: input.teamRun === true ? true : void 0,
|
|
489
|
+
goalRun: input.goalRun === false ? false : void 0,
|
|
382
490
|
...input.freeze === void 0 ? {} : { freeze: freezeOf(input.freeze, now) },
|
|
383
491
|
...input.handover === void 0 ? {} : { handover: {
|
|
384
492
|
...input.handover,
|
|
@@ -406,11 +514,14 @@ function withSchedule(task, patch, now) {
|
|
|
406
514
|
const schedule = {
|
|
407
515
|
enabled: current?.enabled ?? false,
|
|
408
516
|
cron: current?.cron ?? "",
|
|
517
|
+
...current?.timeZone === void 0 ? {} : { timeZone: current.timeZone },
|
|
409
518
|
nextRunAt: current?.nextRunAt,
|
|
410
519
|
lastTriggeredAt: current?.lastTriggeredAt
|
|
411
520
|
};
|
|
412
521
|
if ("enabled" in patch) schedule.enabled = patch.enabled ?? false;
|
|
413
522
|
if ("cron" in patch) schedule.cron = patch.cron ?? "";
|
|
523
|
+
if ("timeZone" in patch) if (patch.timeZone === void 0) delete schedule.timeZone;
|
|
524
|
+
else schedule.timeZone = patch.timeZone;
|
|
414
525
|
if ("nextRunAt" in patch) schedule.nextRunAt = patch.nextRunAt;
|
|
415
526
|
if ("lastTriggeredAt" in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt;
|
|
416
527
|
return {
|
|
@@ -501,8 +612,8 @@ function indexOf(tasks) {
|
|
|
501
612
|
* under a larger limit survives that limit being lowered, and restore and
|
|
502
613
|
* inheritance must still see all of it.
|
|
503
614
|
*/
|
|
504
|
-
function ancestorChain(tasks, task) {
|
|
505
|
-
const index = indexOf(tasks);
|
|
615
|
+
function ancestorChain(tasks, task, lineage) {
|
|
616
|
+
const index = lineage?.byId ?? indexOf(tasks);
|
|
506
617
|
const chain = [];
|
|
507
618
|
const seen = /* @__PURE__ */ new Set([task.id]);
|
|
508
619
|
let current = task.parentId === void 0 ? void 0 : index.get(task.parentId);
|
|
@@ -515,29 +626,33 @@ function ancestorChain(tasks, task) {
|
|
|
515
626
|
return chain;
|
|
516
627
|
}
|
|
517
628
|
/** Root-to-task depth: a root task is 0, its subtask 1, and so on. */
|
|
518
|
-
function taskDepth(tasks, id) {
|
|
519
|
-
const task = indexOf(tasks).get(id);
|
|
520
|
-
return task === void 0 ? 0 : ancestorChain(tasks, task).length;
|
|
629
|
+
function taskDepth(tasks, id, lineage) {
|
|
630
|
+
const task = (lineage?.byId ?? indexOf(tasks)).get(id);
|
|
631
|
+
return task === void 0 ? 0 : ancestorChain(tasks, task, lineage).length;
|
|
521
632
|
}
|
|
522
633
|
/** Direct subtasks of a task, in ledger order. */
|
|
523
|
-
function directSubtasks(tasks, id) {
|
|
634
|
+
function directSubtasks(tasks, id, lineage) {
|
|
635
|
+
if (lineage !== void 0) return [...lineage.childrenByParent.get(id) ?? []];
|
|
524
636
|
return tasks.filter((task) => task.parentId === id);
|
|
525
637
|
}
|
|
526
638
|
/**
|
|
527
639
|
* Descendants of a task in breadth-first order, bounded by maxSubtaskDepth
|
|
528
640
|
* total depth. The visited set keeps a malformed ledger from looping.
|
|
529
641
|
*/
|
|
530
|
-
function descendantTasks(tasks, id, maxSubtaskDepth) {
|
|
642
|
+
function descendantTasks(tasks, id, maxSubtaskDepth, lineage) {
|
|
531
643
|
const visited = /* @__PURE__ */ new Set([id]);
|
|
532
644
|
const found = [];
|
|
533
645
|
let frontier = [id];
|
|
534
646
|
for (let depth = 1; depth <= maxSubtaskDepth && frontier.length > 0; depth += 1) {
|
|
535
647
|
const next = [];
|
|
536
|
-
for (const parentId of frontier)
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
648
|
+
for (const parentId of frontier) {
|
|
649
|
+
const children = lineage === void 0 ? tasks.filter((task) => task.parentId === parentId) : lineage.childrenByParent.get(parentId) ?? [];
|
|
650
|
+
for (const task of children) {
|
|
651
|
+
if (visited.has(task.id)) continue;
|
|
652
|
+
visited.add(task.id);
|
|
653
|
+
found.push(task);
|
|
654
|
+
next.push(task.id);
|
|
655
|
+
}
|
|
541
656
|
}
|
|
542
657
|
frontier = next;
|
|
543
658
|
}
|
|
@@ -548,13 +663,13 @@ function descendantTasks(tasks, id, maxSubtaskDepth) {
|
|
|
548
663
|
* The visited set makes the walk terminate on a cyclic hand-edited ledger
|
|
549
664
|
* instead of recursing until the stack overflows.
|
|
550
665
|
*/
|
|
551
|
-
function subtreeHeight(tasks, id, visited = /* @__PURE__ */ new Set()) {
|
|
666
|
+
function subtreeHeight(tasks, id, visited = /* @__PURE__ */ new Set(), lineage) {
|
|
552
667
|
if (visited.has(id)) return 0;
|
|
553
668
|
const seen = /* @__PURE__ */ new Set([...visited, id]);
|
|
554
|
-
const children = directSubtasks(tasks, id);
|
|
669
|
+
const children = directSubtasks(tasks, id, lineage);
|
|
555
670
|
if (children.length === 0) return 0;
|
|
556
671
|
let height = 0;
|
|
557
|
-
for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen));
|
|
672
|
+
for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen, lineage));
|
|
558
673
|
return Math.min(height, 3);
|
|
559
674
|
}
|
|
560
675
|
/**
|
|
@@ -564,8 +679,8 @@ function subtreeHeight(tasks, id, visited = /* @__PURE__ */ new Set()) {
|
|
|
564
679
|
* and the resulting depth (parent depth + 1 + the child own subtree height)
|
|
565
680
|
* must stay within maxSubtaskDepth.
|
|
566
681
|
*/
|
|
567
|
-
function checkParentLink(tasks, childId, parentId, maxSubtaskDepth) {
|
|
568
|
-
if (tasks.find((task) => task.id === childId) === void 0) return {
|
|
682
|
+
function checkParentLink(tasks, childId, parentId, maxSubtaskDepth, lineage) {
|
|
683
|
+
if ((lineage === void 0 ? tasks.find((task) => task.id === childId) : lineage.byId.get(childId)) === void 0) return {
|
|
569
684
|
ok: false,
|
|
570
685
|
reason: "unknown-task"
|
|
571
686
|
};
|
|
@@ -574,7 +689,7 @@ function checkParentLink(tasks, childId, parentId, maxSubtaskDepth) {
|
|
|
574
689
|
ok: false,
|
|
575
690
|
reason: "self-parent"
|
|
576
691
|
};
|
|
577
|
-
const parent = tasks.find((task) => task.id === parentId);
|
|
692
|
+
const parent = lineage === void 0 ? tasks.find((task) => task.id === parentId) : lineage.byId.get(parentId);
|
|
578
693
|
if (parent === void 0) return {
|
|
579
694
|
ok: false,
|
|
580
695
|
reason: "unknown-parent"
|
|
@@ -583,11 +698,11 @@ function checkParentLink(tasks, childId, parentId, maxSubtaskDepth) {
|
|
|
583
698
|
ok: false,
|
|
584
699
|
reason: "archived-parent"
|
|
585
700
|
};
|
|
586
|
-
if (descendantTasks(tasks, childId, 3).some((task) => task.id === parentId)) return {
|
|
701
|
+
if (descendantTasks(tasks, childId, 3, lineage).some((task) => task.id === parentId)) return {
|
|
587
702
|
ok: false,
|
|
588
703
|
reason: "cycle"
|
|
589
704
|
};
|
|
590
|
-
if (taskDepth(tasks, parentId) + 1 + subtreeHeight(tasks, childId) > maxSubtaskDepth) return {
|
|
705
|
+
if (taskDepth(tasks, parentId, lineage) + 1 + subtreeHeight(tasks, childId, /* @__PURE__ */ new Set(), lineage) > maxSubtaskDepth) return {
|
|
591
706
|
ok: false,
|
|
592
707
|
reason: "depth-exceeded"
|
|
593
708
|
};
|
|
@@ -671,18 +786,44 @@ function effectiveTaskPermission(task) {
|
|
|
671
786
|
*/
|
|
672
787
|
/**
|
|
673
788
|
* The immutable teammate name for one team-mode member. Teammate names are
|
|
674
|
-
* lower-kebab-case and must stay unique for the lifetime of the Team
|
|
675
|
-
* run-group token
|
|
676
|
-
*
|
|
677
|
-
*
|
|
789
|
+
* lower-kebab-case and must stay unique for the lifetime of the Team: the
|
|
790
|
+
* run-group token separates two runs of the same tree, and the member
|
|
791
|
+
* discriminator separates two members of one run that reduce to the same slug.
|
|
792
|
+
*
|
|
793
|
+
* The discriminator is not cosmetic. A title with no ASCII word characters (a
|
|
794
|
+
* CJK title, for example) carries no slug at all, so every such title of one
|
|
795
|
+
* run group collides on the generic prefix; Agent Teams answers every member
|
|
796
|
+
* after the first with `teammate name "..." was already used in this Team`,
|
|
797
|
+
* and those subtasks never start. Two ASCII titles that share a slug
|
|
798
|
+
* (`AI 强度预测` next to `AI 配比优化`) collide the same way.
|
|
678
799
|
* @param title - the member task's title.
|
|
679
800
|
* @param token - the run group id (the task id when no group is recorded).
|
|
801
|
+
* @param memberId - the member task's id, which makes the name unique in the run.
|
|
680
802
|
* @returns the teammate name handed to the Agent Teams service.
|
|
681
803
|
*/
|
|
682
|
-
function teammateName(title, token) {
|
|
804
|
+
function teammateName(title, token, memberId) {
|
|
683
805
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/g, "");
|
|
684
806
|
const suffix = token.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
|
|
685
|
-
|
|
807
|
+
const member = memberId === void 0 || memberId === "" ? void 0 : stableTag(memberId);
|
|
808
|
+
return [
|
|
809
|
+
slug === "" ? "subtask" : slug,
|
|
810
|
+
member,
|
|
811
|
+
suffix === "" ? "run" : suffix
|
|
812
|
+
].filter((part) => part !== void 0).join("-");
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* A stable 4-hex tag for one member id. The name is derived twice for the same
|
|
816
|
+
* member — once for the Lead's prompt and once for the spawn request — so the
|
|
817
|
+
* tag must be a pure function of the id, never of a clock or a counter. FNV-1a
|
|
818
|
+
* keeps it dependency-free in `core/`, which both halves compile.
|
|
819
|
+
*/
|
|
820
|
+
function stableTag(value) {
|
|
821
|
+
let hash = 2166136261;
|
|
822
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
823
|
+
hash ^= value.charCodeAt(index);
|
|
824
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
825
|
+
}
|
|
826
|
+
return hash.toString(16).padStart(8, "0").slice(0, 4);
|
|
686
827
|
}
|
|
687
828
|
function resolveExecutionTargets(task, tasks, maxSubtaskDepth = 3) {
|
|
688
829
|
if (task.parentId === void 0) return task;
|
|
@@ -910,6 +1051,7 @@ function isTaskRecordShape(value) {
|
|
|
910
1051
|
if (record.mode !== void 0 && typeof record.mode !== "string") return false;
|
|
911
1052
|
if (record.permission !== void 0 && typeof record.permission !== "string") return false;
|
|
912
1053
|
if (record.reuseSession !== void 0 && typeof record.reuseSession !== "boolean") return false;
|
|
1054
|
+
if (record.goalRun !== void 0 && typeof record.goalRun !== "boolean") return false;
|
|
913
1055
|
if (!Array.isArray(record.executions)) return false;
|
|
914
1056
|
for (const execution of record.executions) {
|
|
915
1057
|
if (typeof execution !== "object" || execution === null) return false;
|
|
@@ -944,9 +1086,11 @@ function normalizeSchedule(schedule) {
|
|
|
944
1086
|
const rule = schedule;
|
|
945
1087
|
if (typeof rule.cron !== "string") return void 0;
|
|
946
1088
|
if (rule.cron.trim() === "" || !isValidCron(rule.cron)) return void 0;
|
|
1089
|
+
const timeZone = typeof rule.timeZone === "string" && isValidTimeZone(rule.timeZone) ? rule.timeZone : void 0;
|
|
947
1090
|
return {
|
|
948
1091
|
enabled: rule.enabled === true,
|
|
949
1092
|
cron: rule.cron,
|
|
1093
|
+
...timeZone === void 0 ? {} : { timeZone },
|
|
950
1094
|
nextRunAt: typeof rule.nextRunAt === "number" ? rule.nextRunAt : void 0,
|
|
951
1095
|
lastTriggeredAt: typeof rule.lastTriggeredAt === "number" ? rule.lastTriggeredAt : void 0
|
|
952
1096
|
};
|
|
@@ -1031,6 +1175,7 @@ function parseLedger(raw) {
|
|
|
1031
1175
|
task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
|
|
1032
1176
|
task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
|
|
1033
1177
|
task.reuseSession = row.reuseSession === true ? true : void 0;
|
|
1178
|
+
task.goalRun = row.goalRun === false ? false : void 0;
|
|
1034
1179
|
task.freeze = normalizeFreeze(row.freeze);
|
|
1035
1180
|
task.handover = normalizeHandover(row.handover);
|
|
1036
1181
|
task.tags = normalizeTags(row.tags);
|
|
@@ -1145,8 +1290,9 @@ function applyRestoreTask(tasks, id, now, maxSubtaskDepth = 1) {
|
|
|
1145
1290
|
* @param now - clock instant (ms epoch).
|
|
1146
1291
|
* @param id - minted task id.
|
|
1147
1292
|
* @param maxSubtaskDepth - deployment subtask depth limit.
|
|
1293
|
+
* @param hostTimeZone - zone a rule that stores no zone of its own follows.
|
|
1148
1294
|
*/
|
|
1149
|
-
function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1) {
|
|
1295
|
+
function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1, hostTimeZone = resolveHostTimeZone()) {
|
|
1150
1296
|
if (input.title.trim() === "") return {
|
|
1151
1297
|
task: void 0,
|
|
1152
1298
|
tasks,
|
|
@@ -1181,10 +1327,18 @@ function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1) {
|
|
|
1181
1327
|
const requested = input.schedule;
|
|
1182
1328
|
if (requested?.enabled === true && requested.cron.trim() !== "" && isValidCron(requested.cron)) {
|
|
1183
1329
|
const cron = requested.cron.trim();
|
|
1184
|
-
|
|
1330
|
+
const requestedZone = requested.timeZone;
|
|
1331
|
+
if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
|
|
1332
|
+
task: void 0,
|
|
1333
|
+
tasks,
|
|
1334
|
+
error: "invalid schedule time zone"
|
|
1335
|
+
};
|
|
1336
|
+
const nextRunAt = nextRunAtMs(cron, now, requestedZone ?? hostTimeZone);
|
|
1337
|
+
if (nextRunAt !== void 0) task = withSchedule(task, {
|
|
1185
1338
|
enabled: true,
|
|
1186
1339
|
cron,
|
|
1187
|
-
|
|
1340
|
+
timeZone: requestedZone,
|
|
1341
|
+
nextRunAt
|
|
1188
1342
|
}, now);
|
|
1189
1343
|
}
|
|
1190
1344
|
return {
|
|
@@ -1222,15 +1376,17 @@ function applyDeleteTask(tasks, selectedTaskId, id) {
|
|
|
1222
1376
|
* the core cron parser (schedule.ts) and the withSchedule transition.
|
|
1223
1377
|
*/
|
|
1224
1378
|
/**
|
|
1225
|
-
* Set an on-board task's schedule rule. A blank or invalid cron, or
|
|
1226
|
-
* archived task, is rejected (state untouched); an enabled
|
|
1227
|
-
* next run instant immediately
|
|
1379
|
+
* Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
|
|
1380
|
+
* archived task, or an unusable zone is rejected (state untouched); an enabled
|
|
1381
|
+
* rule computes the next run instant immediately in the rule's own zone, a
|
|
1382
|
+
* disabled one carries no next-run instant.
|
|
1228
1383
|
* @param tasks - current ledger.
|
|
1229
1384
|
* @param id - the task to schedule.
|
|
1230
1385
|
* @param patch - rule fields to change (absent fields keep their current value).
|
|
1231
1386
|
* @param now - clock instant (ms epoch).
|
|
1387
|
+
* @param hostTimeZone - zone a rule with no stored zone follows.
|
|
1232
1388
|
*/
|
|
1233
|
-
function applySetSchedule(tasks, id, patch, now) {
|
|
1389
|
+
function applySetSchedule(tasks, id, patch, now, hostTimeZone) {
|
|
1234
1390
|
const task = tasks.find((candidate) => candidate.id === id);
|
|
1235
1391
|
if (task === void 0 || task.archivedAt !== void 0) return {
|
|
1236
1392
|
tasks,
|
|
@@ -1242,8 +1398,13 @@ function applySetSchedule(tasks, id, patch, now) {
|
|
|
1242
1398
|
tasks,
|
|
1243
1399
|
applied: false
|
|
1244
1400
|
};
|
|
1401
|
+
const requestedZone = patch.timeZone === void 0 ? current?.timeZone : patch.timeZone ?? void 0;
|
|
1402
|
+
if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
|
|
1403
|
+
tasks,
|
|
1404
|
+
applied: false
|
|
1405
|
+
};
|
|
1245
1406
|
const enabled = patch.enabled ?? current?.enabled ?? false;
|
|
1246
|
-
const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
|
|
1407
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, now, requestedZone ?? hostTimeZone) : void 0;
|
|
1247
1408
|
if (enabled && nextRunAt === void 0) return {
|
|
1248
1409
|
tasks,
|
|
1249
1410
|
applied: false
|
|
@@ -1252,6 +1413,7 @@ function applySetSchedule(tasks, id, patch, now) {
|
|
|
1252
1413
|
tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
|
|
1253
1414
|
enabled,
|
|
1254
1415
|
cron,
|
|
1416
|
+
timeZone: requestedZone ?? void 0,
|
|
1255
1417
|
nextRunAt
|
|
1256
1418
|
}, now) : candidate),
|
|
1257
1419
|
applied: true
|
|
@@ -1411,6 +1573,7 @@ function applyUpdateTask(tasks, id, patch, now) {
|
|
|
1411
1573
|
if ("permission" in patch && patch.permission !== void 0 && patch.permission !== task.permission || "handover" in patch) next.permissionConfirmedAt = void 0;
|
|
1412
1574
|
if ("reuseSession" in patch) next.reuseSession = patch.reuseSession === true ? true : void 0;
|
|
1413
1575
|
if ("teamRun" in patch) next.teamRun = patch.teamRun === true ? true : void 0;
|
|
1576
|
+
if ("goalRun" in patch) next.goalRun = patch.goalRun === false ? false : void 0;
|
|
1414
1577
|
if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
|
|
1415
1578
|
if (mode !== void 0 || "mode" in patch) next.mode = mode;
|
|
1416
1579
|
if (permission !== void 0 || "permission" in patch) next.permission = permission;
|
|
@@ -1464,6 +1627,7 @@ function validImportedKnownFields(value) {
|
|
|
1464
1627
|
const schedule = record(value.schedule);
|
|
1465
1628
|
if (schedule === void 0 || typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
|
|
1466
1629
|
if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false;
|
|
1630
|
+
if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
|
|
1467
1631
|
}
|
|
1468
1632
|
if (value.executions !== void 0) {
|
|
1469
1633
|
if (!Array.isArray(value.executions)) return false;
|
|
@@ -1511,6 +1675,7 @@ function importedTask(value) {
|
|
|
1511
1675
|
...task.schedule === void 0 ? {} : { schedule: {
|
|
1512
1676
|
enabled: task.schedule.enabled,
|
|
1513
1677
|
cron: task.schedule.cron,
|
|
1678
|
+
...task.schedule.timeZone === void 0 ? {} : { timeZone: task.schedule.timeZone },
|
|
1514
1679
|
nextRunAt: task.schedule.nextRunAt,
|
|
1515
1680
|
lastTriggeredAt: task.schedule.lastTriggeredAt
|
|
1516
1681
|
} },
|
|
@@ -1519,6 +1684,7 @@ function importedTask(value) {
|
|
|
1519
1684
|
...task.mode === void 0 ? {} : { mode: task.mode },
|
|
1520
1685
|
...task.permission === void 0 ? {} : { permission: task.permission },
|
|
1521
1686
|
...task.reuseSession === void 0 ? {} : { reuseSession: task.reuseSession },
|
|
1687
|
+
...task.goalRun === void 0 ? {} : { goalRun: task.goalRun },
|
|
1522
1688
|
...task.archivedAt === void 0 ? {} : { archivedAt: task.archivedAt },
|
|
1523
1689
|
...task.freeze === void 0 ? {} : { freeze: task.freeze },
|
|
1524
1690
|
...task.handover === void 0 ? {} : { handover: task.handover },
|
|
@@ -1565,6 +1731,7 @@ function createInput(value) {
|
|
|
1565
1731
|
"model",
|
|
1566
1732
|
"reuseSession",
|
|
1567
1733
|
"teamRun",
|
|
1734
|
+
"goalRun",
|
|
1568
1735
|
"tags"
|
|
1569
1736
|
])) return false;
|
|
1570
1737
|
if (input.parentId !== void 0 && (typeof input.parentId !== "string" || input.parentId.trim() === "")) return false;
|
|
@@ -1572,14 +1739,20 @@ function createInput(value) {
|
|
|
1572
1739
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false;
|
|
1573
1740
|
if (input.reuseSession !== void 0 && typeof input.reuseSession !== "boolean") return false;
|
|
1574
1741
|
if (input.teamRun !== void 0 && typeof input.teamRun !== "boolean") return false;
|
|
1742
|
+
if (input.goalRun !== void 0 && typeof input.goalRun !== "boolean") return false;
|
|
1575
1743
|
if (input.permission !== void 0 && !isTaskPermission(input.permission)) return false;
|
|
1576
1744
|
if (input.tags !== void 0 && !isTaskTagList(input.tags)) return false;
|
|
1577
1745
|
if (input.freeze !== void 0 && freezePayload(input.freeze) === void 0) return false;
|
|
1578
1746
|
if (input.handover !== void 0 && handoverPayload(input.handover) === void 0) return false;
|
|
1579
1747
|
if (input.schedule !== void 0) {
|
|
1580
1748
|
const schedule = record(input.schedule);
|
|
1581
|
-
if (schedule === void 0 || !exactKeys(schedule, [
|
|
1749
|
+
if (schedule === void 0 || !exactKeys(schedule, [
|
|
1750
|
+
"enabled",
|
|
1751
|
+
"cron",
|
|
1752
|
+
"timeZone"
|
|
1753
|
+
])) return false;
|
|
1582
1754
|
if (typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
|
|
1755
|
+
if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
|
|
1583
1756
|
}
|
|
1584
1757
|
return true;
|
|
1585
1758
|
}
|
|
@@ -1597,10 +1770,12 @@ function updatePatch(value) {
|
|
|
1597
1770
|
"model",
|
|
1598
1771
|
"reuseSession",
|
|
1599
1772
|
"teamRun",
|
|
1773
|
+
"goalRun",
|
|
1600
1774
|
"tags"
|
|
1601
1775
|
])) return false;
|
|
1602
1776
|
if (patch.reuseSession !== void 0 && patch.reuseSession !== null && typeof patch.reuseSession !== "boolean") return false;
|
|
1603
1777
|
if (patch.teamRun !== void 0 && patch.teamRun !== null && typeof patch.teamRun !== "boolean") return false;
|
|
1778
|
+
if (patch.goalRun !== void 0 && patch.goalRun !== null && typeof patch.goalRun !== "boolean") return false;
|
|
1604
1779
|
for (const key of [
|
|
1605
1780
|
"title",
|
|
1606
1781
|
"description",
|
|
@@ -1616,7 +1791,11 @@ function updatePatch(value) {
|
|
|
1616
1791
|
}
|
|
1617
1792
|
function schedulePatch(value) {
|
|
1618
1793
|
const patch = record(value);
|
|
1619
|
-
return patch !== void 0 && exactKeys(patch, [
|
|
1794
|
+
return patch !== void 0 && exactKeys(patch, [
|
|
1795
|
+
"enabled",
|
|
1796
|
+
"cron",
|
|
1797
|
+
"timeZone"
|
|
1798
|
+
]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.cron === void 0 || typeof patch.cron === "string") && (patch.timeZone === void 0 || patch.timeZone === null || typeof patch.timeZone === "string" && isValidTimeZone(patch.timeZone));
|
|
1620
1799
|
}
|
|
1621
1800
|
function parseActionEnvelope(value) {
|
|
1622
1801
|
const parsed = parseEnvelopeAction(value);
|
|
@@ -1752,6 +1931,7 @@ function parseEnvelopeAction(value) {
|
|
|
1752
1931
|
case "delete":
|
|
1753
1932
|
case "archive":
|
|
1754
1933
|
case "restore":
|
|
1934
|
+
case "settle":
|
|
1755
1935
|
case "run":
|
|
1756
1936
|
case "rerun":
|
|
1757
1937
|
if (!exactKeys(action, ["kind", "taskId"])) return void 0;
|
|
@@ -1773,7 +1953,11 @@ function bindingRefusalMessage(refusal, sessionDefault, surface) {
|
|
|
1773
1953
|
}
|
|
1774
1954
|
const MAX_REQUEST_CACHE = 256;
|
|
1775
1955
|
function timeZone() {
|
|
1776
|
-
return
|
|
1956
|
+
return resolveHostTimeZone();
|
|
1957
|
+
}
|
|
1958
|
+
/** The zone a schedule is evaluated in: its own, or the Host zone when it stores none. */
|
|
1959
|
+
function scheduleZone(schedule) {
|
|
1960
|
+
return schedule.timeZone ?? timeZone();
|
|
1777
1961
|
}
|
|
1778
1962
|
function cloneTasks(tasks) {
|
|
1779
1963
|
return JSON.parse(JSON.stringify(tasks));
|
|
@@ -2044,6 +2228,7 @@ function parseHostTasks(values) {
|
|
|
2044
2228
|
schedule: {
|
|
2045
2229
|
enabled: false,
|
|
2046
2230
|
cron: schedule.cron,
|
|
2231
|
+
...typeof schedule.timeZone === "string" && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {},
|
|
2047
2232
|
nextRunAt: void 0,
|
|
2048
2233
|
lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0
|
|
2049
2234
|
}
|
|
@@ -2084,6 +2269,7 @@ var HostTaskLedger = class {
|
|
|
2084
2269
|
for (const request of this.document.recentRequests) this.requestCache.set(request.requestId, { fingerprint: request.fingerprint });
|
|
2085
2270
|
this.repairSchedules(true);
|
|
2086
2271
|
this.reconcileInterruptedStarts();
|
|
2272
|
+
this.finalizeReadyRuns(false);
|
|
2087
2273
|
this.commit(false);
|
|
2088
2274
|
} catch (error) {
|
|
2089
2275
|
this.dispose();
|
|
@@ -2137,6 +2323,11 @@ var HostTaskLedger = class {
|
|
|
2137
2323
|
*/
|
|
2138
2324
|
runtimeView() {
|
|
2139
2325
|
let armedSchedules = 0;
|
|
2326
|
+
const teamGroups = /* @__PURE__ */ new Set();
|
|
2327
|
+
for (const task of this.document.tasks) {
|
|
2328
|
+
if (task.teamRun !== true) continue;
|
|
2329
|
+
for (const execution of task.executions) if (execution.runGroupId !== void 0) teamGroups.add(execution.runGroupId);
|
|
2330
|
+
}
|
|
2140
2331
|
const openExecutions = [];
|
|
2141
2332
|
for (const task of this.document.tasks) {
|
|
2142
2333
|
if (task.archivedAt === void 0 && task.schedule?.enabled === true) armedSchedules += 1;
|
|
@@ -2147,7 +2338,8 @@ var HostTaskLedger = class {
|
|
|
2147
2338
|
taskId: task.id,
|
|
2148
2339
|
executionId: execution.id,
|
|
2149
2340
|
sessionId: execution.sessionId,
|
|
2150
|
-
startedAt: execution.startedAt
|
|
2341
|
+
startedAt: execution.startedAt,
|
|
2342
|
+
teamMember: task.teamRun !== true && execution.runGroupId !== void 0 && teamGroups.has(execution.runGroupId)
|
|
2151
2343
|
});
|
|
2152
2344
|
}
|
|
2153
2345
|
}
|
|
@@ -2162,6 +2354,25 @@ var HostTaskLedger = class {
|
|
|
2162
2354
|
for (const task of this.document.tasks) if (task.archivedAt === void 0 && task.schedule?.enabled === true) count += 1;
|
|
2163
2355
|
return count;
|
|
2164
2356
|
}
|
|
2357
|
+
/**
|
|
2358
|
+
* The earliest armed `nextRunAt` strictly after the supplied Host time, or
|
|
2359
|
+
* undefined when no schedule is armed in the future. This is the instant the
|
|
2360
|
+
* Host arms its native timer at, so the board wakes exactly when work is due
|
|
2361
|
+
* instead of polling a fixed heartbeat.
|
|
2362
|
+
* @param now - current Host time in ms epoch.
|
|
2363
|
+
* @returns the nearest future trigger, or undefined when none is armed.
|
|
2364
|
+
*/
|
|
2365
|
+
nextArmedRunAt(now) {
|
|
2366
|
+
let nearest;
|
|
2367
|
+
for (const task of this.document.tasks) {
|
|
2368
|
+
if (task.archivedAt !== void 0) continue;
|
|
2369
|
+
const schedule = task.schedule;
|
|
2370
|
+
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0) continue;
|
|
2371
|
+
if (schedule.nextRunAt <= now) continue;
|
|
2372
|
+
if (nearest === void 0 || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt;
|
|
2373
|
+
}
|
|
2374
|
+
return nearest;
|
|
2375
|
+
}
|
|
2165
2376
|
/** Return value-only references for schedules due at the supplied Host time. */
|
|
2166
2377
|
dueSchedules(now) {
|
|
2167
2378
|
const due = [];
|
|
@@ -2172,6 +2383,7 @@ var HostTaskLedger = class {
|
|
|
2172
2383
|
due.push({
|
|
2173
2384
|
taskId: task.id,
|
|
2174
2385
|
cron: schedule.cron,
|
|
2386
|
+
timeZone: scheduleZone(schedule),
|
|
2175
2387
|
nextRunAt: schedule.nextRunAt
|
|
2176
2388
|
});
|
|
2177
2389
|
}
|
|
@@ -2244,7 +2456,7 @@ var HostTaskLedger = class {
|
|
|
2244
2456
|
...task,
|
|
2245
2457
|
schedule: {
|
|
2246
2458
|
...schedule,
|
|
2247
|
-
nextRunAt: nextRunAtMs(schedule.cron, now)
|
|
2459
|
+
nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
|
|
2248
2460
|
},
|
|
2249
2461
|
updatedAt: now
|
|
2250
2462
|
};
|
|
@@ -2297,15 +2509,17 @@ var HostTaskLedger = class {
|
|
|
2297
2509
|
});
|
|
2298
2510
|
changed = true;
|
|
2299
2511
|
}
|
|
2512
|
+
if (task.teamRun === true && this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true;
|
|
2300
2513
|
if (this.settleCascade(taskId, groupId, now)) changed = true;
|
|
2301
2514
|
if (changed) this.commit();
|
|
2302
2515
|
}
|
|
2303
2516
|
attachSession(taskId, executionId, sessionId) {
|
|
2304
2517
|
const now = this.now();
|
|
2305
|
-
this.document.tasks
|
|
2306
|
-
|
|
2518
|
+
if (this.document.tasks.find((item) => item.id === taskId)?.executions.find((entry) => entry.id === executionId)?.endedAt !== void 0) return;
|
|
2519
|
+
this.document.tasks = this.document.tasks.map((item) => item.id !== taskId ? item : {
|
|
2520
|
+
...item,
|
|
2307
2521
|
updatedAt: now,
|
|
2308
|
-
executions:
|
|
2522
|
+
executions: item.executions.map((entry) => entry.id === executionId ? {
|
|
2309
2523
|
...entry,
|
|
2310
2524
|
sessionId
|
|
2311
2525
|
} : entry)
|
|
@@ -2328,11 +2542,12 @@ var HostTaskLedger = class {
|
|
|
2328
2542
|
this.document.scheduler.error = invalidScheduleIds.length === 0 ? void 0 : `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}`;
|
|
2329
2543
|
this.repairSchedules(true, false);
|
|
2330
2544
|
this.reconcileInterruptedStarts(false);
|
|
2545
|
+
this.finalizeReadyRuns(false);
|
|
2331
2546
|
break;
|
|
2332
2547
|
}
|
|
2333
2548
|
case "create": {
|
|
2334
2549
|
if (this.document.tasks.some((task) => task.id === action.id)) throw new Error("task id already exists");
|
|
2335
|
-
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === void 0)) throw new Error("invalid schedule");
|
|
2550
|
+
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || !isValidTimeZone(action.input.schedule.timeZone ?? timeZone()) || nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === void 0)) throw new Error("invalid schedule");
|
|
2336
2551
|
const input = action.input.freeze === void 0 || initiator === void 0 || initiator === "" ? action.input : {
|
|
2337
2552
|
...action.input,
|
|
2338
2553
|
freeze: {
|
|
@@ -2340,7 +2555,7 @@ var HostTaskLedger = class {
|
|
|
2340
2555
|
frozenBy: initiator
|
|
2341
2556
|
}
|
|
2342
2557
|
};
|
|
2343
|
-
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth);
|
|
2558
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone());
|
|
2344
2559
|
if (result.task === void 0) throw new Error(result.error ?? "invalid task");
|
|
2345
2560
|
this.document.tasks = [...result.tasks];
|
|
2346
2561
|
break;
|
|
@@ -2391,6 +2606,19 @@ var HostTaskLedger = class {
|
|
|
2391
2606
|
this.document.tasks = [...result.tasks];
|
|
2392
2607
|
break;
|
|
2393
2608
|
}
|
|
2609
|
+
case "settle": {
|
|
2610
|
+
const task = this.document.tasks.find((item) => item.id === action.taskId);
|
|
2611
|
+
if (task === void 0) throw new Error("task not found");
|
|
2612
|
+
if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
|
|
2613
|
+
if (!task.executions.some((entry) => entry.endedAt === void 0)) throw new Error("task has no open execution");
|
|
2614
|
+
const reason = "execution closed manually by " + (initiator === void 0 || initiator === "" ? "the operator" : initiator) + ": no verdict was recorded";
|
|
2615
|
+
for (const member of cascadeTargets(this.document.tasks, action.taskId, this.maxSubtaskDepth)) for (const execution of member.executions) {
|
|
2616
|
+
if (execution.endedAt !== void 0) continue;
|
|
2617
|
+
this.document.tasks = this.document.tasks.map((item) => item.id !== member.id ? item : settleExecution(item, execution.id, "cancelled", now, reason));
|
|
2618
|
+
}
|
|
2619
|
+
this.finalizeReadyRuns(false);
|
|
2620
|
+
break;
|
|
2621
|
+
}
|
|
2394
2622
|
case "restore": {
|
|
2395
2623
|
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error("running task cannot be restored");
|
|
2396
2624
|
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth);
|
|
@@ -2411,7 +2639,7 @@ var HostTaskLedger = class {
|
|
|
2411
2639
|
}
|
|
2412
2640
|
case "set-schedule": {
|
|
2413
2641
|
if (this.document.tasks.find((task) => task.id === action.taskId)?.archivedAt !== void 0) throw new Error("archived task is read-only");
|
|
2414
|
-
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now);
|
|
2642
|
+
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone());
|
|
2415
2643
|
if (!result.applied) throw new Error("invalid schedule");
|
|
2416
2644
|
this.document.tasks = [...result.tasks];
|
|
2417
2645
|
break;
|
|
@@ -2555,6 +2783,86 @@ var HostTaskLedger = class {
|
|
|
2555
2783
|
}
|
|
2556
2784
|
return changed;
|
|
2557
2785
|
}
|
|
2786
|
+
/**
|
|
2787
|
+
* Close every run that is already decided, and fold every lineage whose
|
|
2788
|
+
* members are all settled. The Host poll calls this on every tick, so a run
|
|
2789
|
+
* can no longer be left in the running column by a settle nobody observed:
|
|
2790
|
+
*
|
|
2791
|
+
* - A team run is governed by its Lead. Once the Lead's own outcome is
|
|
2792
|
+
* recorded, its still-open members are settled with that verdict. A
|
|
2793
|
+
* teammate is a durable member of the Team and may never report a turn of
|
|
2794
|
+
* its own, and without this the whole lineage waits on it forever.
|
|
2795
|
+
* - A cascade parent whose members are all settled is finalized here even
|
|
2796
|
+
* when the settle that completed the tree happened in another process, or
|
|
2797
|
+
* was interrupted between its child write and its parent write.
|
|
2798
|
+
*
|
|
2799
|
+
* Idempotent: a run that is already folded is left untouched, so a caller may
|
|
2800
|
+
* invoke it on every tick.
|
|
2801
|
+
* @param persist - false to leave the document for the caller to commit.
|
|
2802
|
+
* @returns whether the document changed.
|
|
2803
|
+
*/
|
|
2804
|
+
finalizeReadyRuns(persist = true) {
|
|
2805
|
+
const now = this.now();
|
|
2806
|
+
let changed = false;
|
|
2807
|
+
const groups = /* @__PURE__ */ new Set();
|
|
2808
|
+
for (const task of this.document.tasks) for (const execution of task.executions) if (execution.endedAt === void 0 && execution.runGroupId !== void 0) groups.add(execution.runGroupId);
|
|
2809
|
+
for (const groupId of groups) {
|
|
2810
|
+
const lead = this.document.tasks.find((task) => task.teamRun === true && task.executions.some((entry) => entry.runGroupId === groupId));
|
|
2811
|
+
const verdict = lead === void 0 ? void 0 : this.leadVerdict(lead, groupId);
|
|
2812
|
+
if (lead !== void 0 && verdict !== void 0 && this.closeTeamMembers(lead.id, groupId, verdict.result, verdict.error, now)) changed = true;
|
|
2813
|
+
for (const task of this.document.tasks) if (this.settleCascade(task.id, groupId, now)) changed = true;
|
|
2814
|
+
}
|
|
2815
|
+
if (changed && persist) this.commit();
|
|
2816
|
+
return changed;
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* The verdict a team run's Lead has already recorded: its own turn outcome
|
|
2820
|
+
* once that turn ended, or the folded outcome once its card settled.
|
|
2821
|
+
*/
|
|
2822
|
+
leadVerdict(lead, groupId) {
|
|
2823
|
+
const execution = lead.executions.find((entry) => entry.runGroupId === groupId);
|
|
2824
|
+
const result = execution?.ownResult ?? execution?.result;
|
|
2825
|
+
if (result === void 0) return void 0;
|
|
2826
|
+
return {
|
|
2827
|
+
result,
|
|
2828
|
+
error: execution?.ownError ?? execution?.error
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Settle every still-open member of a team run with the Lead's verdict.
|
|
2833
|
+
* A teammate that already reported its own outcome keeps it: the ordinary
|
|
2834
|
+
* fold still lets a failure dominate.
|
|
2835
|
+
* @param leadId - the Lead card's task id (never settled here).
|
|
2836
|
+
* @param groupId - the team run's group.
|
|
2837
|
+
* @param verdict - the Lead's outcome, inherited by members that never reported.
|
|
2838
|
+
* @param error - the Lead's failure text, inherited with a failed verdict.
|
|
2839
|
+
* @param now - clock instant (ms epoch).
|
|
2840
|
+
* @returns whether the document changed.
|
|
2841
|
+
*/
|
|
2842
|
+
closeTeamMembers(leadId, groupId, verdict, error, now) {
|
|
2843
|
+
const members = [];
|
|
2844
|
+
let recorded = false;
|
|
2845
|
+
this.document.tasks = this.document.tasks.map((task) => {
|
|
2846
|
+
if (task.id === leadId) return task;
|
|
2847
|
+
const execution = openGroupExecution(task, groupId);
|
|
2848
|
+
if (execution === void 0) return task;
|
|
2849
|
+
members.push(task.id);
|
|
2850
|
+
if (execution.ownResult !== void 0) return task;
|
|
2851
|
+
recorded = true;
|
|
2852
|
+
return {
|
|
2853
|
+
...task,
|
|
2854
|
+
updatedAt: now,
|
|
2855
|
+
executions: task.executions.map((entry) => entry.id === execution.id ? {
|
|
2856
|
+
...entry,
|
|
2857
|
+
ownResult: verdict,
|
|
2858
|
+
ownError: error
|
|
2859
|
+
} : entry)
|
|
2860
|
+
};
|
|
2861
|
+
});
|
|
2862
|
+
let changed = recorded;
|
|
2863
|
+
for (const memberId of members) if (this.settleCascade(memberId, groupId, now)) changed = true;
|
|
2864
|
+
return changed;
|
|
2865
|
+
}
|
|
2558
2866
|
repairSchedules(skipPast, persist = true) {
|
|
2559
2867
|
const now = this.now();
|
|
2560
2868
|
let changed = false;
|
|
@@ -2562,7 +2870,7 @@ var HostTaskLedger = class {
|
|
|
2562
2870
|
const schedule = task.schedule;
|
|
2563
2871
|
if (schedule === void 0 || !schedule.enabled) return task;
|
|
2564
2872
|
if (!skipPast && schedule.nextRunAt !== void 0) return task;
|
|
2565
|
-
const next = nextRunAtMs(schedule.cron, now);
|
|
2873
|
+
const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule));
|
|
2566
2874
|
if (next === void 0) {
|
|
2567
2875
|
changed = true;
|
|
2568
2876
|
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`;
|
|
@@ -2612,14 +2920,36 @@ var HostTaskLedger = class {
|
|
|
2612
2920
|
if (changed && persist) this.commit();
|
|
2613
2921
|
}
|
|
2614
2922
|
/**
|
|
2615
|
-
* Field-preserving
|
|
2616
|
-
*
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
2923
|
+
* Field-preserving migration of a pre-v4 document. It first proves every task
|
|
2924
|
+
* row is structurally valid, so a document that would silently drop or coerce
|
|
2925
|
+
* rows fails loudly instead (no quarantined-empty restart), and then reuses
|
|
2926
|
+
* the current normalization.
|
|
2927
|
+
*
|
|
2928
|
+
* v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
|
|
2929
|
+
* whatever zone the Host process happened to report, so the migration stamps
|
|
2930
|
+
* that zone onto every enabled rule: the trigger instant is preserved exactly
|
|
2931
|
+
* (the stored `nextRunAt` already encodes the old zone), but the rule stops
|
|
2932
|
+
* following a later `TZ` change, which is what made an existing schedule
|
|
2933
|
+
* silently move when the Host's zone changed.
|
|
2619
2934
|
*/
|
|
2620
2935
|
migrateLegacyDocument(parsed) {
|
|
2621
|
-
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every((row) => isTaskRecord(row))) throw new Error(
|
|
2622
|
-
|
|
2936
|
+
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every((row) => isTaskRecord(row))) throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`);
|
|
2937
|
+
const rows = parsed.tasks.map((value) => {
|
|
2938
|
+
const schedule = value.schedule;
|
|
2939
|
+
if (typeof schedule !== "object" || schedule === null) return value;
|
|
2940
|
+
if (typeof schedule.timeZone === "string") return value;
|
|
2941
|
+
return {
|
|
2942
|
+
...value,
|
|
2943
|
+
schedule: {
|
|
2944
|
+
...schedule,
|
|
2945
|
+
timeZone: timeZone()
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
});
|
|
2949
|
+
return this.normalizeDocument({
|
|
2950
|
+
...parsed,
|
|
2951
|
+
tasks: rows
|
|
2952
|
+
});
|
|
2623
2953
|
}
|
|
2624
2954
|
load(dir) {
|
|
2625
2955
|
const existed = existsSync(this.file);
|
|
@@ -2629,13 +2959,13 @@ var HostTaskLedger = class {
|
|
|
2629
2959
|
} catch (error) {
|
|
2630
2960
|
return this.recoverCorrupt(dir, existed, error);
|
|
2631
2961
|
}
|
|
2632
|
-
if (parsed.schemaVersion === 2) try {
|
|
2962
|
+
if (parsed.schemaVersion === 3 || parsed.schemaVersion === 2) try {
|
|
2633
2963
|
return this.migrateLegacyDocument(parsed);
|
|
2634
2964
|
} catch (error) {
|
|
2635
|
-
throw new Error(`ledger
|
|
2965
|
+
throw new Error(`ledger v${String(parsed.schemaVersion)} to v4 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2636
2966
|
}
|
|
2637
2967
|
try {
|
|
2638
|
-
if (parsed.schemaVersion !==
|
|
2968
|
+
if (parsed.schemaVersion !== 4 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
|
|
2639
2969
|
return this.normalizeDocument(parsed);
|
|
2640
2970
|
} catch (error) {
|
|
2641
2971
|
return this.recoverCorrupt(dir, existed, error);
|
|
@@ -2657,7 +2987,7 @@ var HostTaskLedger = class {
|
|
|
2657
2987
|
const sidecarLastTickAt = this.readSchedulerSidecar();
|
|
2658
2988
|
const lastTickAt = sidecarLastTickAt === void 0 || documentLastTickAt !== void 0 && documentLastTickAt >= sidecarLastTickAt ? documentLastTickAt : sidecarLastTickAt;
|
|
2659
2989
|
return {
|
|
2660
|
-
schemaVersion:
|
|
2990
|
+
schemaVersion: 4,
|
|
2661
2991
|
revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
|
|
2662
2992
|
tasks,
|
|
2663
2993
|
scheduler: {
|
|
@@ -2683,7 +3013,7 @@ var HostTaskLedger = class {
|
|
|
2683
3013
|
if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`);
|
|
2684
3014
|
mkdirSync(dir, { recursive: true });
|
|
2685
3015
|
return {
|
|
2686
|
-
schemaVersion:
|
|
3016
|
+
schemaVersion: 4,
|
|
2687
3017
|
revision: 0,
|
|
2688
3018
|
tasks: [],
|
|
2689
3019
|
scheduler: {
|
|
@@ -2875,6 +3205,16 @@ function isDefinitionWithdrawn(error) {
|
|
|
2875
3205
|
}
|
|
2876
3206
|
const SERVICE_UNAVAILABLE_ATTEMPTS = 5;
|
|
2877
3207
|
const SERVICE_UNAVAILABLE_BACKOFF_MS = 2e3;
|
|
3208
|
+
/**
|
|
3209
|
+
* Largest number of history pages one inspection walks back. A session whose
|
|
3210
|
+
* window is wider than this cannot be resolved from its log, which the caller
|
|
3211
|
+
* treats as an unreadable history rather than as pending work.
|
|
3212
|
+
*/
|
|
3213
|
+
const HISTORY_PAGE_LIMIT = 100;
|
|
3214
|
+
/** One-line text of a gateway or stream failure, for a decideable reason. */
|
|
3215
|
+
function failureText(error) {
|
|
3216
|
+
return error instanceof Error ? error.message : String(error);
|
|
3217
|
+
}
|
|
2878
3218
|
function delay(ms) {
|
|
2879
3219
|
return new Promise((resolve) => {
|
|
2880
3220
|
setTimeout(resolve, ms);
|
|
@@ -2913,13 +3253,25 @@ function peerPromptPreamble(context) {
|
|
|
2913
3253
|
const peers = context.peers ?? [];
|
|
2914
3254
|
if (peers.length === 0) return void 0;
|
|
2915
3255
|
if (context.team === true) {
|
|
2916
|
-
const lines = peers.map((peer) => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id)})`);
|
|
3256
|
+
const lines = peers.map((peer) => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id, peer.id)})`);
|
|
2917
3257
|
return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join("\n")}`;
|
|
2918
3258
|
}
|
|
2919
3259
|
const lines = peers.map((peer) => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`);
|
|
2920
3260
|
return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join("\n")}`;
|
|
2921
3261
|
}
|
|
2922
3262
|
/**
|
|
3263
|
+
* The scheduled-run section: this execution was triggered by the card's cron
|
|
3264
|
+
* rule rather than by a person. It states the firing instant, the rule's zone,
|
|
3265
|
+
* and the rule text, so the run's own clock is unambiguous — the board's
|
|
3266
|
+
* scheduler fires on a wall clock in a specific IANA zone, which is not
|
|
3267
|
+
* necessarily the zone of whatever session the run lands in.
|
|
3268
|
+
*/
|
|
3269
|
+
function schedulePromptPreamble(context) {
|
|
3270
|
+
const schedule = context.schedule;
|
|
3271
|
+
if (schedule === void 0) return void 0;
|
|
3272
|
+
return `本次执行由任务看板的定时规则自动触发:触发时间 ${new Date(schedule.triggeredAt).toISOString()}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
2923
3275
|
* Build the execution prompt for one task.
|
|
2924
3276
|
* @param task - the task being launched.
|
|
2925
3277
|
* @param context - the run's other members, for the execution-shape section.
|
|
@@ -2932,6 +3284,7 @@ function promptText(task, context = {}) {
|
|
|
2932
3284
|
const preambles = [
|
|
2933
3285
|
tagPromptPreamble(task),
|
|
2934
3286
|
handoverPreamble,
|
|
3287
|
+
schedulePromptPreamble(context),
|
|
2935
3288
|
peerPromptPreamble(context)
|
|
2936
3289
|
].filter((part) => part !== void 0);
|
|
2937
3290
|
const preamble = preambles.length === 0 ? void 0 : preambles.join("\n\n");
|
|
@@ -2942,6 +3295,25 @@ function promptText(task, context = {}) {
|
|
|
2942
3295
|
return preamble === void 0 ? declaration : `${preamble}\n\n${declaration}`;
|
|
2943
3296
|
}
|
|
2944
3297
|
/**
|
|
3298
|
+
* The parts of an objective the built-in `/goal` command reads as an operation
|
|
3299
|
+
* rather than as an objective: an exact `clear`/`pause`/`resume`/`edit`
|
|
3300
|
+
* (case-insensitive) and a leading `edit <objective>`.
|
|
3301
|
+
*/
|
|
3302
|
+
const GOAL_COMMAND_KEYWORD = /^(?:clear|pause|resume|edit)$|^edit\s/i;
|
|
3303
|
+
/**
|
|
3304
|
+
* Build the objective the built-in `/goal` command receives. A prompt whose
|
|
3305
|
+
* text would be read as a goal operation is prefixed with a neutral label: the
|
|
3306
|
+
* instruction stays intact, and the command arms an objective instead of
|
|
3307
|
+
* clearing, pausing, or editing an unrelated goal (or refusing for a missing
|
|
3308
|
+
* one).
|
|
3309
|
+
* @param prompt - the composed execution prompt.
|
|
3310
|
+
* @returns the objective text for `/goal <objective>`.
|
|
3311
|
+
*/
|
|
3312
|
+
function goalObjective(prompt) {
|
|
3313
|
+
const text = prompt.trim();
|
|
3314
|
+
return GOAL_COMMAND_KEYWORD.test(text) ? `Goal: ${text}` : text;
|
|
3315
|
+
}
|
|
3316
|
+
/**
|
|
2945
3317
|
* Build the tag section of the execution prompt (issue #1521). Only tags with
|
|
2946
3318
|
* a non-blank `promptPrefix` contribute; a task whose tags are all bare names
|
|
2947
3319
|
* (or which has no tags at all) yields undefined and the prompt is byte-for-byte
|
|
@@ -3131,6 +3503,73 @@ var HostExecutionRunner = class {
|
|
|
3131
3503
|
text: promptText(task, context)
|
|
3132
3504
|
}]
|
|
3133
3505
|
});
|
|
3506
|
+
await this.armGoal(sessionId, task, context);
|
|
3507
|
+
}
|
|
3508
|
+
/**
|
|
3509
|
+
* Arm dsh's built-in goal on the session. The task prompt is queued FIRST, so
|
|
3510
|
+
* the session's first turn carries the instruction verbatim; `/goal
|
|
3511
|
+
* <objective>` then turns the same text into a persistent objective, and
|
|
3512
|
+
* dsh's goal-round driver keeps starting continuation rounds until the agent
|
|
3513
|
+
* marks it complete (see {@link goalVerdict} for how the run settles).
|
|
3514
|
+
*
|
|
3515
|
+
* Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
|
|
3516
|
+
* A refusal (no command dispatcher, no `/goal` command in this cohort, an
|
|
3517
|
+
* objective the command rejects) is reported and the run continues as that
|
|
3518
|
+
* plain turn: the task was asked to run, and a missing goal mode must not
|
|
3519
|
+
* lose the work.
|
|
3520
|
+
*/
|
|
3521
|
+
async armGoal(sessionId, task, context) {
|
|
3522
|
+
if (task.goalRun === false) return;
|
|
3523
|
+
if (this.commands === void 0) {
|
|
3524
|
+
console.warn("[dsh-task-board] no command dispatcher is available; task " + task.id + " runs without /goal");
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
try {
|
|
3528
|
+
const command = await this.commands.execute(sessionId, "/goal " + goalObjective(promptText(task, context)), AbortSignal.timeout(3e4));
|
|
3529
|
+
if (command === void 0) {
|
|
3530
|
+
console.warn("[dsh-task-board] /goal was not acknowledged for session " + sessionId + "; the run continues as a plain turn");
|
|
3531
|
+
return;
|
|
3532
|
+
}
|
|
3533
|
+
if (command.kind !== "success") console.warn("[dsh-task-board] /goal was refused for session " + sessionId + ": " + (command.text ?? "no reason reported") + "; the run continues as a plain turn");
|
|
3534
|
+
} catch (error) {
|
|
3535
|
+
console.warn("[dsh-task-board] could not arm /goal on session " + sessionId + "; the run continues as a plain turn", error);
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
/**
|
|
3539
|
+
* The verdict an armed dsh goal imposes on an otherwise completed turn.
|
|
3540
|
+
*
|
|
3541
|
+
* A goal run is one session working many automatic continuation rounds:
|
|
3542
|
+
* dsh's goal-round driver queues the next round whenever the agent goes
|
|
3543
|
+
* idle, so the first completed `turn/end` is not the execution's result.
|
|
3544
|
+
* Settling there would leave the card in `done` while the session keeps
|
|
3545
|
+
* working, and a scheduled task would fire a second, concurrent session.
|
|
3546
|
+
* The session projection is the authority: an `active` goal keeps the
|
|
3547
|
+
* execution pending, a `blocked` goal fails it with the recorded reason, and
|
|
3548
|
+
* any other phase (complete, paused, or no goal at all) leaves the turn
|
|
3549
|
+
* verdict in place.
|
|
3550
|
+
*
|
|
3551
|
+
* An unreadable projection falls back to that turn verdict: a cohort whose
|
|
3552
|
+
* gateway withdrew `session/projections` must not hang every execution.
|
|
3553
|
+
*/
|
|
3554
|
+
async goalVerdict(sessionId) {
|
|
3555
|
+
let projections;
|
|
3556
|
+
try {
|
|
3557
|
+
projections = await this.invoke("session", "projections", { sessionId });
|
|
3558
|
+
} catch (error) {
|
|
3559
|
+
console.warn("[dsh-task-board] session/projections failed while reading the goal phase; settling from the turn result", error);
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
const goal = projections?.values?.goal;
|
|
3563
|
+
if (goal === void 0 || goal === null) return void 0;
|
|
3564
|
+
const view = goal.goal;
|
|
3565
|
+
if (view?.phase === "active") return { outcome: "pending" };
|
|
3566
|
+
if (view?.phase === "blocked") {
|
|
3567
|
+
const message = view.blockedReason?.message;
|
|
3568
|
+
return {
|
|
3569
|
+
outcome: "failed",
|
|
3570
|
+
error: "goal is blocked: " + (typeof message === "string" && message !== "" ? message : "no reason recorded")
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3134
3573
|
}
|
|
3135
3574
|
async listRunning() {
|
|
3136
3575
|
for (let attempt = 1;; attempt++) try {
|
|
@@ -3162,8 +3601,17 @@ var HostExecutionRunner = class {
|
|
|
3162
3601
|
await delay(this.unavailableBackoffMs);
|
|
3163
3602
|
}
|
|
3164
3603
|
}
|
|
3165
|
-
/**
|
|
3166
|
-
|
|
3604
|
+
/**
|
|
3605
|
+
* Resolve an execution outcome from the session list and bounded history pages.
|
|
3606
|
+
* @param sessionId - the session the execution runs in.
|
|
3607
|
+
* @param startedAt - instant the execution opened; earlier events are not its own.
|
|
3608
|
+
* @param sessions - roster from the caller's own poll, when it already has one.
|
|
3609
|
+
* @param options.whileRunning - read the first completed turn even while the
|
|
3610
|
+
* roster reports the session as running. A durable Agent Teams teammate
|
|
3611
|
+
* stays alive after its turn ends, so `running` never clears for it and the
|
|
3612
|
+
* turn it completed is the only verdict it will ever expose.
|
|
3613
|
+
*/
|
|
3614
|
+
async inspect(sessionId, startedAt = 0, sessions, options = {}) {
|
|
3167
3615
|
let items;
|
|
3168
3616
|
if (sessions !== void 0) items = sessions;
|
|
3169
3617
|
else {
|
|
@@ -3195,7 +3643,7 @@ var HostExecutionRunner = class {
|
|
|
3195
3643
|
error: "execution session no longer exists"
|
|
3196
3644
|
};
|
|
3197
3645
|
}
|
|
3198
|
-
if (summary.running) return { outcome: "pending" };
|
|
3646
|
+
if (summary.running && options.whileRunning !== true) return { outcome: "pending" };
|
|
3199
3647
|
let opening;
|
|
3200
3648
|
try {
|
|
3201
3649
|
const iterator = (await this.stream("session", "follow", {
|
|
@@ -3205,7 +3653,11 @@ var HostExecutionRunner = class {
|
|
|
3205
3653
|
const next = await iterator.next();
|
|
3206
3654
|
if (typeof iterator.return === "function") await iterator.return();
|
|
3207
3655
|
const follow = next.done === true ? void 0 : next.value;
|
|
3208
|
-
if (follow === void 0 || follow.type !== "snapshot" || typeof follow.cursor !== "number" || follow.records === void 0 || typeof follow.hasMore !== "boolean") return {
|
|
3656
|
+
if (follow === void 0 || follow.type !== "snapshot" || typeof follow.cursor !== "number" || follow.records === void 0 || typeof follow.hasMore !== "boolean") return {
|
|
3657
|
+
outcome: "pending",
|
|
3658
|
+
unreadable: true,
|
|
3659
|
+
reason: "session/follow returned no opening frame"
|
|
3660
|
+
};
|
|
3209
3661
|
opening = {
|
|
3210
3662
|
cursor: follow.cursor,
|
|
3211
3663
|
records: follow.records,
|
|
@@ -3213,7 +3665,11 @@ var HostExecutionRunner = class {
|
|
|
3213
3665
|
};
|
|
3214
3666
|
} catch (error) {
|
|
3215
3667
|
console.warn("[dsh-task-board] session/follow failed during execution inspection; keeping the outcome pending", error);
|
|
3216
|
-
return {
|
|
3668
|
+
return {
|
|
3669
|
+
outcome: "pending",
|
|
3670
|
+
unreadable: true,
|
|
3671
|
+
reason: "session/follow failed: " + failureText(error)
|
|
3672
|
+
};
|
|
3217
3673
|
}
|
|
3218
3674
|
const openingEvents = opening.records.map((record) => ({ event: recordEvent(record) }));
|
|
3219
3675
|
const newestSeq = openingEvents.reduce((newest, entry) => newest === void 0 ? entry.event.seq : Math.max(newest, entry.event.seq), void 0);
|
|
@@ -3221,7 +3677,7 @@ var HostExecutionRunner = class {
|
|
|
3221
3677
|
const events = [...openingEvents];
|
|
3222
3678
|
let beforeSeq;
|
|
3223
3679
|
let reachedExecutionBoundary = !opening.hasMore;
|
|
3224
|
-
for (let page = 0; page <
|
|
3680
|
+
for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
|
|
3225
3681
|
let history;
|
|
3226
3682
|
try {
|
|
3227
3683
|
history = await this.invoke("session", "page", {
|
|
@@ -3232,7 +3688,11 @@ var HostExecutionRunner = class {
|
|
|
3232
3688
|
});
|
|
3233
3689
|
} catch (error) {
|
|
3234
3690
|
console.warn("[dsh-task-board] session/page failed during execution inspection; keeping the outcome pending", error);
|
|
3235
|
-
return {
|
|
3691
|
+
return {
|
|
3692
|
+
outcome: "pending",
|
|
3693
|
+
unreadable: true,
|
|
3694
|
+
reason: "session/page failed: " + failureText(error)
|
|
3695
|
+
};
|
|
3236
3696
|
}
|
|
3237
3697
|
const pageEntries = pageEvents(history);
|
|
3238
3698
|
events.push(...pageEntries);
|
|
@@ -3245,7 +3705,11 @@ var HostExecutionRunner = class {
|
|
|
3245
3705
|
if (oldestSeq === void 0 || oldestSeq === beforeSeq) return { outcome: "pending" };
|
|
3246
3706
|
beforeSeq = oldestSeq;
|
|
3247
3707
|
}
|
|
3248
|
-
if (!reachedExecutionBoundary) return {
|
|
3708
|
+
if (!reachedExecutionBoundary) return {
|
|
3709
|
+
outcome: "pending",
|
|
3710
|
+
unreadable: true,
|
|
3711
|
+
reason: "history scan did not reach the execution start within 100 pages"
|
|
3712
|
+
};
|
|
3249
3713
|
const turnEnd = events.filter((entry) => entry.event.type === "turn/end" && (startedAt <= 0 || entry.event.time >= startedAt)).sort((a, b) => a.event.seq - b.event.seq)[0];
|
|
3250
3714
|
if (turnEnd === void 0) {
|
|
3251
3715
|
if (newestSeq !== void 0) this.scanMemos.set(sessionId, newestSeq);
|
|
@@ -3253,7 +3717,7 @@ var HostExecutionRunner = class {
|
|
|
3253
3717
|
}
|
|
3254
3718
|
this.scanMemos.delete(sessionId);
|
|
3255
3719
|
const reason = nonCompletedTurnEnd(turnEnd.event.data);
|
|
3256
|
-
if (reason === void 0) return { outcome: "succeeded" };
|
|
3720
|
+
if (reason === void 0) return await this.goalVerdict(sessionId) ?? { outcome: "succeeded" };
|
|
3257
3721
|
return reason === "error" ? {
|
|
3258
3722
|
outcome: "failed",
|
|
3259
3723
|
error: "agent turn ended with an error"
|
|
@@ -3567,19 +4031,64 @@ var PowerInhibitor = class {
|
|
|
3567
4031
|
};
|
|
3568
4032
|
//#endregion
|
|
3569
4033
|
//#region src/host-service.ts
|
|
4034
|
+
/** Session-roster poll cadence — the one recurring Host timer this service still holds. */
|
|
3570
4035
|
const SESSION_POLL_MS = 5e3;
|
|
3571
|
-
|
|
3572
|
-
|
|
4036
|
+
/**
|
|
4037
|
+
* How late an armed schedule fire may be before it counts as a resume rather
|
|
4038
|
+
* than a normal occurrence. The schedule timer is armed AT the next due
|
|
4039
|
+
* instant, so landing this far past its target means the Host was suspended,
|
|
4040
|
+
* the process throttled, or the wall clock jumped forward — the same condition
|
|
4041
|
+
* the old fixed 30 s heartbeat detected through its own gap threshold.
|
|
4042
|
+
*/
|
|
4043
|
+
const RECOVERY_TOLERANCE_MS = 6e4;
|
|
4044
|
+
/** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
|
|
4045
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
4046
|
+
/**
|
|
4047
|
+
* Consecutive polls that may report one execution's session history as
|
|
4048
|
+
* unreadable before it is reported failed. The roster poll runs every 5 s, so
|
|
4049
|
+
* this is two minutes of a session that is NOT running: no turn is executing
|
|
4050
|
+
* there and its history cannot be read, which means no verdict will ever
|
|
4051
|
+
* arrive. Reporting that as a failure keeps a card — and every ancestor of it —
|
|
4052
|
+
* out of the running column, which nothing else can rescue.
|
|
4053
|
+
*/
|
|
4054
|
+
const UNREADABLE_SETTLE_POLLS = 24;
|
|
4055
|
+
/**
|
|
4056
|
+
* Actions that can move an armed trigger. Only these re-arm the native timer;
|
|
4057
|
+
* an unrelated card edit leaves the pending fire untouched.
|
|
4058
|
+
*/
|
|
4059
|
+
const SCHEDULE_WRITE_ACTIONS = /* @__PURE__ */ new Set([
|
|
4060
|
+
"set-schedule",
|
|
4061
|
+
"delete",
|
|
4062
|
+
"archive"
|
|
4063
|
+
]);
|
|
4064
|
+
/** Process-global fallback used when the deployment serves no cordis timer service. */
|
|
4065
|
+
const PROCESS_TIMERS = {
|
|
4066
|
+
timeout(callback, delay) {
|
|
4067
|
+
const handle = setTimeout(callback, delay);
|
|
4068
|
+
return () => {
|
|
4069
|
+
clearTimeout(handle);
|
|
4070
|
+
};
|
|
4071
|
+
},
|
|
4072
|
+
interval(callback, delay) {
|
|
4073
|
+
const handle = setInterval(callback, delay);
|
|
4074
|
+
return () => {
|
|
4075
|
+
clearInterval(handle);
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
};
|
|
3573
4079
|
var TaskBoardHostService = class {
|
|
3574
4080
|
ledger;
|
|
3575
4081
|
runner;
|
|
3576
4082
|
power;
|
|
3577
4083
|
listeners = /* @__PURE__ */ new Set();
|
|
3578
|
-
|
|
3579
|
-
|
|
4084
|
+
/** The one recurring timer: the session-roster poll. */
|
|
4085
|
+
pollTimer;
|
|
4086
|
+
/** The armed schedule timer, if a trigger is pending. */
|
|
4087
|
+
scheduleTimer;
|
|
4088
|
+
/** The instant the armed schedule timer targets (ms epoch), for resume detection. */
|
|
4089
|
+
scheduleTarget;
|
|
3580
4090
|
disposed = false;
|
|
3581
4091
|
pollInFlight = false;
|
|
3582
|
-
tickInFlight = false;
|
|
3583
4092
|
active = true;
|
|
3584
4093
|
/**
|
|
3585
4094
|
* Ids the last roster poll saw as present and idle; undefined while the
|
|
@@ -3588,8 +4097,15 @@ var TaskBoardHostService = class {
|
|
|
3588
4097
|
* conversation instead of prompting into a session it cannot see.
|
|
3589
4098
|
*/
|
|
3590
4099
|
idleSessionIds;
|
|
4100
|
+
/**
|
|
4101
|
+
* Consecutive unreadable-history polls per open execution (see
|
|
4102
|
+
* {@link noteUnreadableInspection}). Cleared as soon as an inspection
|
|
4103
|
+
* resolves, so a transient reader failure never fails a card.
|
|
4104
|
+
*/
|
|
4105
|
+
unreadablePolls = /* @__PURE__ */ new Map();
|
|
3591
4106
|
preventIdleSleep = false;
|
|
3592
4107
|
team;
|
|
4108
|
+
timers;
|
|
3593
4109
|
lastPowerJson = "";
|
|
3594
4110
|
now;
|
|
3595
4111
|
constructor(gateway, options = {}) {
|
|
@@ -3599,6 +4115,7 @@ var TaskBoardHostService = class {
|
|
|
3599
4115
|
});
|
|
3600
4116
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry);
|
|
3601
4117
|
this.team = options.team;
|
|
4118
|
+
this.timers = options.timers ?? PROCESS_TIMERS;
|
|
3602
4119
|
this.power = options.power ?? new PowerInhibitor();
|
|
3603
4120
|
this.now = options.now ?? Date.now;
|
|
3604
4121
|
installStreamErrorGuards();
|
|
@@ -3614,16 +4131,13 @@ var TaskBoardHostService = class {
|
|
|
3614
4131
|
});
|
|
3615
4132
|
}
|
|
3616
4133
|
start() {
|
|
3617
|
-
if (this.disposed || this.
|
|
4134
|
+
if (this.disposed || this.pollTimer !== void 0) return;
|
|
3618
4135
|
this.syncPowerReasons();
|
|
3619
|
-
this.timers.
|
|
4136
|
+
this.pollTimer = this.timers.interval(() => {
|
|
3620
4137
|
this.schedulePoll();
|
|
3621
|
-
}, SESSION_POLL_MS)
|
|
3622
|
-
this.timers.push(setInterval(() => {
|
|
3623
|
-
this.scheduleTick(false);
|
|
3624
|
-
}, SCHEDULE_TICK_MS));
|
|
4138
|
+
}, SESSION_POLL_MS);
|
|
3625
4139
|
this.schedulePoll();
|
|
3626
|
-
this.
|
|
4140
|
+
this.recoverSchedule();
|
|
3627
4141
|
}
|
|
3628
4142
|
setConfiguration(active, preventIdleSleep) {
|
|
3629
4143
|
const resumed = !this.active && active;
|
|
@@ -3640,14 +4154,14 @@ var TaskBoardHostService = class {
|
|
|
3640
4154
|
this.power.setEnabled(active && preventIdleSleep);
|
|
3641
4155
|
if (resumed) {
|
|
3642
4156
|
this.schedulePoll();
|
|
3643
|
-
this.
|
|
3644
|
-
}
|
|
4157
|
+
this.recoverSchedule();
|
|
4158
|
+
} else if (!active) this.clearScheduleTimer();
|
|
3645
4159
|
this.emit();
|
|
3646
4160
|
}
|
|
3647
4161
|
snapshot() {
|
|
3648
4162
|
const state = this.ledger.state();
|
|
3649
4163
|
return {
|
|
3650
|
-
schemaVersion:
|
|
4164
|
+
schemaVersion: 4,
|
|
3651
4165
|
revision: state.revision,
|
|
3652
4166
|
tasks: state.tasks,
|
|
3653
4167
|
scheduler: state.scheduler,
|
|
@@ -3679,8 +4193,9 @@ var TaskBoardHostService = class {
|
|
|
3679
4193
|
}
|
|
3680
4194
|
const result = this.ledger.applyRequest(requestId, action, initiator);
|
|
3681
4195
|
if (result.runs !== void 0) this.dispatchRuns(result.runs);
|
|
4196
|
+
if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule();
|
|
3682
4197
|
return {
|
|
3683
|
-
schemaVersion:
|
|
4198
|
+
schemaVersion: 4,
|
|
3684
4199
|
revision: result.state.revision,
|
|
3685
4200
|
tasks: result.state.tasks,
|
|
3686
4201
|
scheduler: result.state.scheduler,
|
|
@@ -3689,22 +4204,26 @@ var TaskBoardHostService = class {
|
|
|
3689
4204
|
}
|
|
3690
4205
|
dispose() {
|
|
3691
4206
|
this.disposed = true;
|
|
3692
|
-
|
|
4207
|
+
this.clearScheduleTimer();
|
|
4208
|
+
this.pollTimer?.();
|
|
4209
|
+
this.pollTimer = void 0;
|
|
3693
4210
|
this.power.dispose();
|
|
3694
4211
|
this.ledger.dispose();
|
|
3695
4212
|
this.listeners.clear();
|
|
3696
4213
|
}
|
|
3697
|
-
async launch(opened, others = []) {
|
|
4214
|
+
async launch(opened, others = [], schedule) {
|
|
3698
4215
|
try {
|
|
3699
4216
|
const team = opened.task.teamRun === true;
|
|
3700
4217
|
const reuseSessionId = team ? void 0 : reusableSessionId(opened.task, this.idleSessionIds);
|
|
3701
|
-
const
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
...
|
|
4218
|
+
const peers = others.length === 0 ? void 0 : others.map((other) => ({
|
|
4219
|
+
id: other.task.id,
|
|
4220
|
+
title: other.task.title,
|
|
4221
|
+
...team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id, other.task.id) } : {}
|
|
4222
|
+
}));
|
|
4223
|
+
const promptContext = peers === void 0 && schedule === void 0 ? void 0 : {
|
|
4224
|
+
...peers === void 0 ? {} : { peers },
|
|
4225
|
+
...team ? { team: true } : {},
|
|
4226
|
+
...schedule === void 0 ? {} : { schedule }
|
|
3708
4227
|
};
|
|
3709
4228
|
const sessionId = await this.runner.launch(opened.task, {
|
|
3710
4229
|
...reuseSessionId === void 0 ? {} : { reuseSessionId },
|
|
@@ -3738,7 +4257,7 @@ var TaskBoardHostService = class {
|
|
|
3738
4257
|
try {
|
|
3739
4258
|
const member = await team.spawn({
|
|
3740
4259
|
leadSessionId,
|
|
3741
|
-
name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
|
|
4260
|
+
name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id, opened.task.id),
|
|
3742
4261
|
description: opened.task.title,
|
|
3743
4262
|
prompt: promptText(opened.task)
|
|
3744
4263
|
});
|
|
@@ -3766,6 +4285,7 @@ var TaskBoardHostService = class {
|
|
|
3766
4285
|
return;
|
|
3767
4286
|
}
|
|
3768
4287
|
this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
|
|
4288
|
+
this.ledger.finalizeReadyRuns();
|
|
3769
4289
|
const runtime = this.ledger.runtimeView();
|
|
3770
4290
|
this.power.updateReasons({
|
|
3771
4291
|
runningSessions: running.count,
|
|
@@ -3779,26 +4299,102 @@ var TaskBoardHostService = class {
|
|
|
3779
4299
|
for (const execution of executions) {
|
|
3780
4300
|
if (execution.sessionId === void 0) continue;
|
|
3781
4301
|
try {
|
|
3782
|
-
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
|
|
3783
|
-
if (result.outcome === "pending")
|
|
4302
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions, { whileRunning: execution.teamMember });
|
|
4303
|
+
if (result.outcome === "pending") {
|
|
4304
|
+
if (result.unreadable === true) this.noteUnreadableInspection(execution, result.reason);
|
|
4305
|
+
else this.unreadablePolls.delete(execution.executionId);
|
|
4306
|
+
continue;
|
|
4307
|
+
}
|
|
4308
|
+
this.unreadablePolls.delete(execution.executionId);
|
|
3784
4309
|
this.ledger.settle(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
|
|
3785
4310
|
} catch {}
|
|
3786
4311
|
}
|
|
4312
|
+
const open = new Set(executions.map((execution) => execution.executionId));
|
|
4313
|
+
for (const executionId of [...this.unreadablePolls.keys()]) if (!open.has(executionId)) this.unreadablePolls.delete(executionId);
|
|
3787
4314
|
}
|
|
3788
|
-
|
|
4315
|
+
/**
|
|
4316
|
+
* Count one poll whose session history could not be read. A reader failure is
|
|
4317
|
+
* not progress: the session is not running (the runner only reads history for
|
|
4318
|
+
* one that is idle), so nothing will ever change that verdict. After
|
|
4319
|
+
* {@link UNREADABLE_SETTLE_POLLS} consecutive polls the execution is reported
|
|
4320
|
+
* failed with the recorded reason, instead of holding its card — and every
|
|
4321
|
+
* ancestor of it — in the running column with no way out. The first poll of
|
|
4322
|
+
* each streak is logged, so the Host log names the session.
|
|
4323
|
+
*/
|
|
4324
|
+
noteUnreadableInspection(execution, reason) {
|
|
4325
|
+
const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1;
|
|
4326
|
+
this.unreadablePolls.set(execution.executionId, polls);
|
|
4327
|
+
const detail = reason ?? "no reason reported";
|
|
4328
|
+
if (polls === 1) safeConsoleError("[dsh-task-board] execution session " + (execution.sessionId ?? "unknown") + " history is unreadable; it stays pending for up to 24 polls: " + detail);
|
|
4329
|
+
if (polls < UNREADABLE_SETTLE_POLLS) return;
|
|
4330
|
+
this.unreadablePolls.delete(execution.executionId);
|
|
4331
|
+
this.ledger.settle(execution.taskId, execution.executionId, "failed", "execution session history is unreadable (24 consecutive polls); the outcome cannot be determined: " + detail);
|
|
4332
|
+
}
|
|
4333
|
+
/** Drop the armed schedule timer and forget its target. */
|
|
4334
|
+
clearScheduleTimer() {
|
|
4335
|
+
this.scheduleTimer?.();
|
|
4336
|
+
this.scheduleTimer = void 0;
|
|
4337
|
+
this.scheduleTarget = void 0;
|
|
4338
|
+
}
|
|
4339
|
+
/**
|
|
4340
|
+
* Boot / resume recovery: skip every occurrence that came due while the
|
|
4341
|
+
* board was not running and roll each schedule to its next future target,
|
|
4342
|
+
* then arm the timer for the nearest one. Rendering the occurrence is
|
|
4343
|
+
* deliberately not attempted: the ACL of a card that fired hours ago is
|
|
4344
|
+
* stale, and the board's own recovery contract is "missed triggers are
|
|
4345
|
+
* skipped, never replayed".
|
|
4346
|
+
*/
|
|
4347
|
+
recoverSchedule() {
|
|
4348
|
+
if (this.disposed) return;
|
|
4349
|
+
this.clearScheduleTimer();
|
|
4350
|
+
const now = this.now();
|
|
4351
|
+
this.ledger.setScheduler({ lastTickAt: now });
|
|
4352
|
+
this.ledger.skipMissed(now);
|
|
4353
|
+
this.armSchedule();
|
|
4354
|
+
}
|
|
4355
|
+
/**
|
|
4356
|
+
* Arm the native timer at the nearest armed future trigger. One timer serves
|
|
4357
|
+
* every schedule: the ledger's next target is the only instant the Host has
|
|
4358
|
+
* to wake for. A target beyond the platform's timer ceiling re-arms in
|
|
4359
|
+
* segments, and a target already past (the wall clock jumped, or the process
|
|
4360
|
+
* was suspended) is handled immediately as a recovery.
|
|
4361
|
+
*/
|
|
4362
|
+
armSchedule() {
|
|
4363
|
+
if (this.disposed || !this.active) return;
|
|
4364
|
+
this.clearScheduleTimer();
|
|
4365
|
+
const target = this.ledger.nextArmedRunAt(this.now());
|
|
4366
|
+
if (target === void 0) return;
|
|
4367
|
+
this.scheduleTarget = target;
|
|
4368
|
+
const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS));
|
|
4369
|
+
this.scheduleTimer = this.timers.timeout(() => {
|
|
4370
|
+
this.scheduleTimer = void 0;
|
|
4371
|
+
this.onScheduleFire(target);
|
|
4372
|
+
}, delay);
|
|
4373
|
+
}
|
|
4374
|
+
/**
|
|
4375
|
+
* One armed target became due. A fire landing well past its target is a
|
|
4376
|
+
* resume (suspend, throttle, forward clock jump) rather than a normal
|
|
4377
|
+
* occurrence, so it takes the recovery path instead of launching a run for a
|
|
4378
|
+
* long-stale instant.
|
|
4379
|
+
*/
|
|
4380
|
+
onScheduleFire(target) {
|
|
3789
4381
|
if (this.disposed || !this.active) return;
|
|
3790
4382
|
const now = this.now();
|
|
3791
|
-
|
|
3792
|
-
this.lastScheduleTick = now;
|
|
4383
|
+
this.scheduleTarget = void 0;
|
|
3793
4384
|
this.ledger.setScheduler({ lastTickAt: now });
|
|
3794
|
-
if (
|
|
3795
|
-
this.
|
|
4385
|
+
if (now - target > RECOVERY_TOLERANCE_MS) {
|
|
4386
|
+
this.recoverSchedule();
|
|
3796
4387
|
return;
|
|
3797
4388
|
}
|
|
3798
4389
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
3799
|
-
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
|
|
3800
|
-
this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now)
|
|
4390
|
+
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone);
|
|
4391
|
+
this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now), {
|
|
4392
|
+
triggeredAt: now,
|
|
4393
|
+
timeZone: schedule.timeZone,
|
|
4394
|
+
cron: schedule.cron
|
|
4395
|
+
});
|
|
3801
4396
|
}
|
|
4397
|
+
this.armSchedule();
|
|
3802
4398
|
}
|
|
3803
4399
|
armedSchedules() {
|
|
3804
4400
|
return this.ledger.armedScheduleCount();
|
|
@@ -3810,15 +4406,15 @@ var TaskBoardHostService = class {
|
|
|
3810
4406
|
* others back. A team run's members are spawned inside the root's Lead
|
|
3811
4407
|
* session instead, once that session exists.
|
|
3812
4408
|
*/
|
|
3813
|
-
dispatchRuns(runs) {
|
|
4409
|
+
dispatchRuns(runs, schedule) {
|
|
3814
4410
|
if (runs.length === 0) return;
|
|
3815
4411
|
const root = runs.find((run) => run.dispatch !== "teammate") ?? runs[0];
|
|
3816
4412
|
const others = runs.filter((run) => run !== root);
|
|
3817
|
-
this.scheduleLaunch(root, others);
|
|
3818
|
-
for (const run of others) if (run.dispatch !== "teammate") this.scheduleLaunch(run);
|
|
4413
|
+
this.scheduleLaunch(root, others, schedule);
|
|
4414
|
+
for (const run of others) if (run.dispatch !== "teammate") this.scheduleLaunch(run, [], schedule);
|
|
3819
4415
|
}
|
|
3820
|
-
scheduleLaunch(opened, others = []) {
|
|
3821
|
-
this.launch(opened, others).catch((error) => {
|
|
4416
|
+
scheduleLaunch(opened, others = [], schedule) {
|
|
4417
|
+
this.launch(opened, others, schedule).catch((error) => {
|
|
3822
4418
|
safeConsoleError("[dsh-task-board] execution launch settlement failed", error);
|
|
3823
4419
|
});
|
|
3824
4420
|
}
|
|
@@ -3831,14 +4427,18 @@ var TaskBoardHostService = class {
|
|
|
3831
4427
|
this.pollInFlight = false;
|
|
3832
4428
|
});
|
|
3833
4429
|
}
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
4430
|
+
/**
|
|
4431
|
+
* Re-arm from the ledger's current targets. Callers that just changed a
|
|
4432
|
+
* schedule (the host routes, the agent tools) invoke this after the write
|
|
4433
|
+
* commits, so a new or edited trigger arms without waiting for the next fire.
|
|
4434
|
+
*/
|
|
4435
|
+
refreshSchedule() {
|
|
4436
|
+
if (this.disposed) return;
|
|
4437
|
+
if (!this.active) {
|
|
4438
|
+
this.clearScheduleTimer();
|
|
4439
|
+
return;
|
|
4440
|
+
}
|
|
4441
|
+
this.armSchedule();
|
|
3842
4442
|
}
|
|
3843
4443
|
syncPowerReasons() {
|
|
3844
4444
|
const current = this.power.snapshot();
|
|
@@ -4058,6 +4658,7 @@ function scheduleView(schedule) {
|
|
|
4058
4658
|
return {
|
|
4059
4659
|
enabled: schedule.enabled,
|
|
4060
4660
|
cron: schedule.cron,
|
|
4661
|
+
...schedule.timeZone === void 0 ? {} : { timeZone: schedule.timeZone },
|
|
4061
4662
|
...schedule.nextRunAt === void 0 ? {} : { nextRunAt: schedule.nextRunAt },
|
|
4062
4663
|
...schedule.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }
|
|
4063
4664
|
};
|
|
@@ -4107,6 +4708,7 @@ function taskSummary(task, tasks, sessionDefault) {
|
|
|
4107
4708
|
...task.model === void 0 ? {} : { model: task.model },
|
|
4108
4709
|
...task.reuseSession === true ? { reuseSession: true } : {},
|
|
4109
4710
|
...task.teamRun === true ? { teamRun: true } : {},
|
|
4711
|
+
...task.goalRun === false ? { goalRun: false } : {},
|
|
4110
4712
|
...task.schedule === void 0 ? {} : { schedule: scheduleView(task.schedule) },
|
|
4111
4713
|
...task.freeze === void 0 ? {} : { continuationCard: true },
|
|
4112
4714
|
...task.permissionConfirmedAt === void 0 ? {} : { permissionConfirmedAt: task.permissionConfirmedAt },
|
|
@@ -4299,11 +4901,12 @@ function buildManageTool(host) {
|
|
|
4299
4901
|
return defineTool({
|
|
4300
4902
|
name: "task_board_manage",
|
|
4301
4903
|
description: [
|
|
4302
|
-
"Move, archive, restore, or
|
|
4904
|
+
"Move, archive, restore, delete, or settle one task board card.",
|
|
4303
4905
|
"move-todo and move-backlog are the manual column moves; a running card cannot be moved.",
|
|
4906
|
+
"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.",
|
|
4304
4907
|
"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.",
|
|
4305
4908
|
"It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.",
|
|
4306
|
-
"Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task."
|
|
4909
|
+
"Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task, 卡住, stuck, 强制结算, settle."
|
|
4307
4910
|
].join(" "),
|
|
4308
4911
|
parameters: {
|
|
4309
4912
|
taskId: {
|
|
@@ -4319,7 +4922,8 @@ function buildManageTool(host) {
|
|
|
4319
4922
|
"move-backlog",
|
|
4320
4923
|
"archive",
|
|
4321
4924
|
"restore",
|
|
4322
|
-
"delete"
|
|
4925
|
+
"delete",
|
|
4926
|
+
"settle"
|
|
4323
4927
|
],
|
|
4324
4928
|
description: "The lifecycle operation to perform."
|
|
4325
4929
|
}
|
|
@@ -4345,6 +4949,10 @@ function buildManageTool(host) {
|
|
|
4345
4949
|
kind: "archive",
|
|
4346
4950
|
taskId: args.taskId
|
|
4347
4951
|
},
|
|
4952
|
+
settle: {
|
|
4953
|
+
kind: "settle",
|
|
4954
|
+
taskId: args.taskId
|
|
4955
|
+
},
|
|
4348
4956
|
restore: {
|
|
4349
4957
|
kind: "restore",
|
|
4350
4958
|
taskId: args.taskId
|
|
@@ -4380,7 +4988,8 @@ function buildScheduleTool(host) {
|
|
|
4380
4988
|
return defineTool({
|
|
4381
4989
|
name: "task_board_schedule",
|
|
4382
4990
|
description: [
|
|
4383
|
-
"Arm, change, or disarm a task scheduled runs (5-field cron
|
|
4991
|
+
"Arm, change, or disarm a task scheduled runs (5-field cron; day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND).",
|
|
4992
|
+
"The cron wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.",
|
|
4384
4993
|
"A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.",
|
|
4385
4994
|
"A schedule whose tree contains an unconfirmed above-default permission is refused and rolls to its next occurrence; the reason is reported in the list board summary as schedulerError.",
|
|
4386
4995
|
"Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.",
|
|
@@ -4399,6 +5008,10 @@ function buildScheduleTool(host) {
|
|
|
4399
5008
|
cron: {
|
|
4400
5009
|
type: "string",
|
|
4401
5010
|
description: "5-field cron expression: minute hour day-of-month month day-of-week."
|
|
5011
|
+
},
|
|
5012
|
+
timeZone: {
|
|
5013
|
+
type: "string",
|
|
5014
|
+
description: "IANA zone the cron wall clock is read in (for example Asia/Shanghai). Omit to keep the stored zone, or pass an empty string to clear it back to the Host zone."
|
|
4402
5015
|
}
|
|
4403
5016
|
},
|
|
4404
5017
|
output: {
|
|
@@ -4406,11 +5019,12 @@ function buildScheduleTool(host) {
|
|
|
4406
5019
|
render: renderJson
|
|
4407
5020
|
},
|
|
4408
5021
|
async execute(args, exec) {
|
|
4409
|
-
if (args.enabled === void 0 && args.cron === void 0) return refused("nothing-to-change", "pass enabled and/or cron");
|
|
5022
|
+
if (args.enabled === void 0 && args.cron === void 0 && args.timeZone === void 0) return refused("nothing-to-change", "pass enabled and/or cron and/or timeZone");
|
|
4410
5023
|
if (!host.snapshot().tasks.some((task) => task.id === args.taskId)) return refused("task-not-found", "no task with id " + args.taskId);
|
|
4411
5024
|
const patch = {
|
|
4412
5025
|
...args.enabled === void 0 ? {} : { enabled: args.enabled },
|
|
4413
|
-
...args.cron === void 0 ? {} : { cron: args.cron }
|
|
5026
|
+
...args.cron === void 0 ? {} : { cron: args.cron },
|
|
5027
|
+
...args.timeZone === void 0 ? {} : { timeZone: args.timeZone === "" ? null : args.timeZone }
|
|
4414
5028
|
};
|
|
4415
5029
|
try {
|
|
4416
5030
|
const snapshot = host.apply(crypto.randomUUID(), {
|
|
@@ -4595,6 +5209,10 @@ function buildCreateTool(host) {
|
|
|
4595
5209
|
type: "boolean",
|
|
4596
5210
|
description: "Run this task as an Agent Team: running it starts one Team Lead session and the Host spawns a teammate per subtask inside it. Omit or false for one independent session per member. Refused when the deployment serves no Agent Teams service."
|
|
4597
5211
|
},
|
|
5212
|
+
goalRun: {
|
|
5213
|
+
type: "boolean",
|
|
5214
|
+
description: "Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Default true (omit to keep it); pass false for a single plain turn."
|
|
5215
|
+
},
|
|
4598
5216
|
tags: {
|
|
4599
5217
|
type: "array",
|
|
4600
5218
|
description: "Labels: the name renders as a badge and drives the board filter; promptPrefix is injected ahead of the execution prompt on every run.",
|
|
@@ -4654,6 +5272,7 @@ function buildCreateTool(host) {
|
|
|
4654
5272
|
...args.model === void 0 || args.model === "" ? {} : { model: args.model },
|
|
4655
5273
|
...args.reuseSession === true ? { reuseSession: true } : {},
|
|
4656
5274
|
...args.teamRun === true ? { teamRun: true } : {},
|
|
5275
|
+
...args.goalRun === false ? { goalRun: false } : {},
|
|
4657
5276
|
...tags.length === 0 ? {} : { tags },
|
|
4658
5277
|
...args.schedule === void 0 ? {} : { schedule: {
|
|
4659
5278
|
enabled: args.schedule.enabled,
|
|
@@ -4715,8 +5334,13 @@ function buildUpdateTool(host) {
|
|
|
4715
5334
|
description: "New agent preset id; an empty string clears it."
|
|
4716
5335
|
},
|
|
4717
5336
|
permission: {
|
|
4718
|
-
|
|
4719
|
-
|
|
5337
|
+
oneOf: [{
|
|
5338
|
+
type: "string",
|
|
5339
|
+
enum: [...TASK_PERMISSIONS]
|
|
5340
|
+
}, {
|
|
5341
|
+
type: "string",
|
|
5342
|
+
const: ""
|
|
5343
|
+
}],
|
|
4720
5344
|
description: "New permission preset; an empty string clears it back to the session default."
|
|
4721
5345
|
},
|
|
4722
5346
|
model: {
|
|
@@ -4731,6 +5355,10 @@ function buildUpdateTool(host) {
|
|
|
4731
5355
|
type: "boolean",
|
|
4732
5356
|
description: "Switches this task between a plain cascade (one session per member) and an Agent Team run (Lead session plus a teammate per subtask)."
|
|
4733
5357
|
},
|
|
5358
|
+
goalRun: {
|
|
5359
|
+
type: "boolean",
|
|
5360
|
+
description: "Whether each run starts with dsh built-in /goal (default true). Pass false to run one plain turn instead."
|
|
5361
|
+
},
|
|
4734
5362
|
tags: {
|
|
4735
5363
|
type: "array",
|
|
4736
5364
|
description: "Replacement label set; an empty array clears all labels.",
|
|
@@ -4766,6 +5394,7 @@ function buildUpdateTool(host) {
|
|
|
4766
5394
|
if (has(args, "permission") && typeof args.permission === "string") patch.permission = args.permission === "" ? void 0 : args.permission;
|
|
4767
5395
|
if (has(args, "reuseSession")) patch.reuseSession = args.reuseSession === true;
|
|
4768
5396
|
if (has(args, "teamRun")) patch.teamRun = args.teamRun === true;
|
|
5397
|
+
if (has(args, "goalRun")) patch.goalRun = args.goalRun === false ? false : true;
|
|
4769
5398
|
if (has(args, "tags")) {
|
|
4770
5399
|
const tags = args.tags ?? [];
|
|
4771
5400
|
patch.tags = tags.length === 0 ? null : tags.map((tag) => ({
|
|
@@ -4895,13 +5524,47 @@ function resolveAccess(access) {
|
|
|
4895
5524
|
};
|
|
4896
5525
|
}
|
|
4897
5526
|
/**
|
|
5527
|
+
* Harness browser-auth cookie prefix (dsh-client-connection): the
|
|
5528
|
+
* authority-bound signed cookie the Host mints in exchange for its launch
|
|
5529
|
+
* token. dsh-web's remote channel redeems that token before re-issuing paired
|
|
5530
|
+
* traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
|
|
5531
|
+
* official DSH Desktop shell keeps its own copy from the same exchange and
|
|
5532
|
+
* attaches it to every request it forwards for the dsh-app://app/ page. The
|
|
5533
|
+
* two constants describe one fact and move together.
|
|
5534
|
+
*/
|
|
5535
|
+
const BROWSER_AUTH_COOKIE_PREFIX = "dsh-auth-";
|
|
5536
|
+
/**
|
|
5537
|
+
* Whether a Cookie header carries the Host's browser-auth credential. Only an
|
|
5538
|
+
* application on this machine can hold it: the desktop shell never lets it
|
|
5539
|
+
* reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
|
|
5540
|
+
* from attaching it.
|
|
5541
|
+
* @param header - the raw Cookie header value.
|
|
5542
|
+
*/
|
|
5543
|
+
function carriesBrowserAuthCookie(header) {
|
|
5544
|
+
if (header === void 0) return false;
|
|
5545
|
+
return header.split(";").some((segment) => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX));
|
|
5546
|
+
}
|
|
5547
|
+
/**
|
|
4898
5548
|
* Browser-signal tripwire, NOT an authority check: a bare curl sends neither
|
|
4899
5549
|
* header and is refused, but a curl with a forged Origin passes this too.
|
|
4900
5550
|
* The real boundary is the loopback socket + Host + origin-equality checks
|
|
4901
5551
|
* in isTrustedTaskBoardRequest below; do not rely on this marker alone.
|
|
5552
|
+
*
|
|
5553
|
+
* A first-party client that presents NEITHER header must still pass. The DSH
|
|
5554
|
+
* Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
|
|
5555
|
+
* page's Host requests itself, deleting origin and sec-fetch-site on the
|
|
5556
|
+
* way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
|
|
5557
|
+
* arrives marker-less and every route behind this guard answered 403 - which
|
|
5558
|
+
* the panel renders as its board.hostError.unauthorized message. The shell
|
|
5559
|
+
* does attach the Host's browser-auth cookie, redeemed from the Host's launch
|
|
5560
|
+
* URL at startup and deliberately withheld from the page. That credential,
|
|
5561
|
+
* not a header the shell strips, is the browser signal of an application on
|
|
5562
|
+
* this machine; a marker-less, credential-less request stays refused.
|
|
4902
5563
|
*/
|
|
4903
5564
|
function browserSameOriginMarker(req) {
|
|
4904
|
-
|
|
5565
|
+
if (req.headers["sec-fetch-site"] === "same-origin") return true;
|
|
5566
|
+
if (typeof req.headers.origin === "string") return true;
|
|
5567
|
+
return carriesBrowserAuthCookie(req.headers.cookie);
|
|
4905
5568
|
}
|
|
4906
5569
|
function sameAuthority(req, host) {
|
|
4907
5570
|
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
@@ -5113,38 +5776,111 @@ function makeTaskBoardRoutes(service, access = {}, options = {}) {
|
|
|
5113
5776
|
* the loader accepts a standalone install of the same package side by side;
|
|
5114
5777
|
* without this guard the second instance would still re-register the same
|
|
5115
5778
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
5116
|
-
* and fail the boot. mountOnce makes
|
|
5117
|
-
*
|
|
5118
|
-
* package name in the client module host).
|
|
5779
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
5780
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
5781
|
+
* deduped by package name in the client module host).
|
|
5782
|
+
*
|
|
5783
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
5784
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
5785
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
5786
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
5787
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
5788
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
5789
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
5790
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
5791
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
5792
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
5793
|
+
* restart recovered it).
|
|
5794
|
+
*
|
|
5795
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
5796
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
5797
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
5798
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
5799
|
+
* dedupes against it.
|
|
5119
5800
|
*
|
|
5120
5801
|
* The registry rides a global symbol so two module instances of the same
|
|
5121
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
5122
|
-
*
|
|
5802
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
5803
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
5804
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
5805
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
5806
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
5807
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
5808
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
5809
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
5810
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
5811
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
5812
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
5813
|
+
* mounted after it failed with "claims.get is not a function".
|
|
5814
|
+
*
|
|
5815
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
5123
5816
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
5124
5817
|
*/
|
|
5818
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
5125
5819
|
const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
|
|
5820
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
5821
|
+
const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
|
|
5822
|
+
/**
|
|
5823
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
5824
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
5825
|
+
* family plugin down with it.
|
|
5826
|
+
* @returns the process-wide set of mounted package names.
|
|
5827
|
+
*/
|
|
5126
5828
|
function mountedSet() {
|
|
5127
5829
|
const registry = globalThis;
|
|
5128
|
-
|
|
5830
|
+
const existing = registry[MOUNTED];
|
|
5831
|
+
if (existing instanceof Set) return existing;
|
|
5832
|
+
const created = /* @__PURE__ */ new Set();
|
|
5833
|
+
registry[MOUNTED] = created;
|
|
5834
|
+
return created;
|
|
5835
|
+
}
|
|
5836
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
5837
|
+
function mountWaiters() {
|
|
5838
|
+
const registry = globalThis;
|
|
5839
|
+
return registry[WAITERS] ??= /* @__PURE__ */ new Map();
|
|
5129
5840
|
}
|
|
5130
5841
|
/**
|
|
5131
5842
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
5132
|
-
* The first mount registers normally and
|
|
5133
|
-
*
|
|
5843
|
+
* The first mount registers normally and releases the name when its fiber
|
|
5844
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
5845
|
+
* then runs, unless its own fiber disposes first.
|
|
5134
5846
|
* @param packageName - npm package identity shared by every install source.
|
|
5135
5847
|
* @param fn - the original plugin apply.
|
|
5136
5848
|
* @returns an apply of the same shape.
|
|
5137
5849
|
*/
|
|
5138
5850
|
function mountOnce(packageName, fn) {
|
|
5139
|
-
|
|
5851
|
+
const mount = (...args) => {
|
|
5140
5852
|
const mounted = mountedSet();
|
|
5141
|
-
|
|
5853
|
+
const ctx = args[0];
|
|
5854
|
+
if (mounted.has(packageName)) {
|
|
5855
|
+
const waiters = mountWaiters();
|
|
5856
|
+
const queue = waiters.get(packageName) ?? [];
|
|
5857
|
+
let alive = true;
|
|
5858
|
+
const pending = { run: () => {
|
|
5859
|
+
if (alive) mount(...args);
|
|
5860
|
+
} };
|
|
5861
|
+
ctx?.effect?.(() => () => {
|
|
5862
|
+
alive = false;
|
|
5863
|
+
const index = queue.indexOf(pending);
|
|
5864
|
+
if (index >= 0) queue.splice(index, 1);
|
|
5865
|
+
});
|
|
5866
|
+
queue.push(pending);
|
|
5867
|
+
waiters.set(packageName, queue);
|
|
5868
|
+
return;
|
|
5869
|
+
}
|
|
5142
5870
|
mounted.add(packageName);
|
|
5143
|
-
|
|
5871
|
+
ctx?.effect?.(() => () => {
|
|
5144
5872
|
mounted.delete(packageName);
|
|
5873
|
+
const waiters = mountWaiters();
|
|
5874
|
+
const queue = waiters.get(packageName);
|
|
5875
|
+
if (queue === void 0) return;
|
|
5876
|
+
waiters.delete(packageName);
|
|
5877
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => {
|
|
5878
|
+
waiter.run();
|
|
5879
|
+
});
|
|
5145
5880
|
});
|
|
5146
5881
|
return fn(...args);
|
|
5147
|
-
}
|
|
5882
|
+
};
|
|
5883
|
+
return mount;
|
|
5148
5884
|
}
|
|
5149
5885
|
//#endregion
|
|
5150
5886
|
//#region src/index.ts
|
|
@@ -5228,6 +5964,27 @@ function resolveToolRegistry(ctx) {
|
|
|
5228
5964
|
return;
|
|
5229
5965
|
}
|
|
5230
5966
|
}
|
|
5967
|
+
/**
|
|
5968
|
+
* Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
|
|
5969
|
+
* row). The board arms its schedule through it so unloading the row clears
|
|
5970
|
+
* every pending timer with the owning fiber, instead of the row tracking raw
|
|
5971
|
+
* process handles by hand. A composition that serves no timer service keeps
|
|
5972
|
+
* working on the process globals.
|
|
5973
|
+
* @param ctx - the plugin context.
|
|
5974
|
+
* @returns the timer face, or undefined when this deployment serves none.
|
|
5975
|
+
*/
|
|
5976
|
+
function resolveHostTimers(ctx) {
|
|
5977
|
+
try {
|
|
5978
|
+
const timer = ctx.get("timer");
|
|
5979
|
+
if (timer === void 0) return void 0;
|
|
5980
|
+
return typeof timer.timeout === "function" && typeof timer.interval === "function" ? {
|
|
5981
|
+
timeout: timer.timeout.bind(timer),
|
|
5982
|
+
interval: timer.interval.bind(timer)
|
|
5983
|
+
} : void 0;
|
|
5984
|
+
} catch {
|
|
5985
|
+
return;
|
|
5986
|
+
}
|
|
5987
|
+
}
|
|
5231
5988
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
5232
5989
|
const DEFAULT_ANNOUNCE = false;
|
|
5233
5990
|
/**
|
|
@@ -5331,6 +6088,7 @@ function applyImpl(ctx, config) {
|
|
|
5331
6088
|
workspaceRegistry: ctx.workspaceRegistry,
|
|
5332
6089
|
sessionDefaultPermission: config?.sessionDefaultPermission ?? "read-only",
|
|
5333
6090
|
maxSubtaskDepth: maxSubtaskDepth(),
|
|
6091
|
+
timers: resolveHostTimers(ctx),
|
|
5334
6092
|
team: buildTeamDispatcher(ctx, config?.teamProvider ?? "spawn"),
|
|
5335
6093
|
commandDispatcher: { async execute(sessionId, line, signal) {
|
|
5336
6094
|
const agent = ctx.agents.get(sessionId);
|
|
@@ -5416,4 +6174,4 @@ function applyImpl(ctx, config) {
|
|
|
5416
6174
|
sync();
|
|
5417
6175
|
}
|
|
5418
6176
|
//#endregion
|
|
5419
|
-
export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
|
|
6177
|
+
export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveHostTimers, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
|