@linxin666/dsh-client-ui-task-board 0.4.3 → 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.
Files changed (53) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -10
  3. package/README.zh.md +17 -10
  4. package/lib/client.js +1350 -552
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +655 -134
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/locales.d.ts +38 -2
  11. package/lib/types/client/schedule-zone.d.ts +47 -0
  12. package/lib/types/core/controller.d.ts +1 -0
  13. package/lib/types/core/degraded-reason.d.ts +46 -0
  14. package/lib/types/core/package-name.d.ts +9 -0
  15. package/lib/types/core/schedule.d.ts +95 -20
  16. package/lib/types/core/subtask.d.ts +30 -11
  17. package/lib/types/core/tasks.d.ts +26 -2
  18. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  19. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  20. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  21. package/lib/types/host-ledger.d.ts +56 -4
  22. package/lib/types/host-runner.d.ts +76 -3
  23. package/lib/types/host-service.d.ts +26 -0
  24. package/lib/types/protocol.d.ts +9 -3
  25. package/package.json +25 -25
  26. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  27. package/src/client/board/NewTaskModal.tsx +271 -150
  28. package/src/client/board/TaskCard.tsx +24 -5
  29. package/src/client/board/TaskDetail.tsx +83 -33
  30. package/src/client/board/TaskForm.tsx +48 -3
  31. package/src/client/board/preset-label.ts +44 -0
  32. package/src/client/board.module.css +114 -5
  33. package/src/client/host-api.ts +109 -6
  34. package/src/client/index.ts +5 -1
  35. package/src/client/locales.ts +82 -8
  36. package/src/client/schedule-zone.ts +135 -0
  37. package/src/core/controller.ts +7 -2
  38. package/src/core/degraded-reason.ts +81 -0
  39. package/src/core/package-name.ts +9 -0
  40. package/src/core/schedule.ts +211 -55
  41. package/src/core/store.ts +10 -1
  42. package/src/core/subtask.ts +77 -22
  43. package/src/core/tasks.ts +38 -4
  44. package/src/core/use-cases/task-create.ts +14 -2
  45. package/src/core/use-cases/task-schedule.ts +17 -6
  46. package/src/core/use-cases/task-update.ts +4 -1
  47. package/src/host/agent-tools.ts +40 -8
  48. package/src/host-ledger.ts +203 -23
  49. package/src/host-runner.ts +164 -14
  50. package/src/host-service.ts +94 -17
  51. package/src/protocol.ts +27 -8
  52. package/lib/types/host/run-guarded.d.ts +0 -39
  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
- dayWildcard: fields[2] === "*",
42
- weekdayWildcard: fields[4] === "*"
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` (ms epoch), in local time,
51
- * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
52
- * of the matching minute's start, or undefined when the calendar constraint
53
- * can never match (for example `0 0 30 2 *`). The five-year horizon includes
54
- * a full leap cycle, so a valid February 29 schedule remains reachable from
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. Wall-clock field construction + the final `matches` re-check
61
- * preserve the old minute scan's DST semantics exactly (nonexistent spring
62
- * minutes normalize forward and the repeated fall-back hour is never visited).
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 from = new Date(fromMs);
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 = from.getFullYear();
74
- let month = from.getMonth() + 1;
75
- let day = from.getDate();
76
- let hour = from.getHours();
77
- let minute = from.getMinutes() + 1;
78
- while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
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).getDate();
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, new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0))) continue;
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 candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0);
92
- const time = candidate.getTime();
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
- if (matches(schedule, candidate)) return time;
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
- /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
108
- function dayCandidate(schedule, date) {
109
- const dayMatches = schedule.days.has(date.getDate());
110
- const weekdayMatches = schedule.weekdays.has(date.getDay());
111
- if (schedule.dayWildcard) return weekdayMatches;
112
- if (schedule.weekdayWildcard) return dayMatches;
113
- return dayMatches || weekdayMatches;
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.dayWildcard || !schedule.weekdayWildcard) return true;
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) for (const task of tasks) {
537
- if (task.parentId !== parentId || visited.has(task.id)) continue;
538
- visited.add(task.id);
539
- found.push(task);
540
- next.push(task.id);
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, so the
675
- * run-group token is appended: two runs of the same tree never collide. A title
676
- * with no ASCII word characters (a CJK title, for example) keeps a stable
677
- * generic prefix and is still distinguished by the token.
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
- return `${slug === "" ? "subtask" : slug}-${suffix === "" ? "run" : suffix}`;
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
- task = withSchedule(task, {
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
- nextRunAt: nextRunAtMs(cron, now)
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 an
1226
- * archived task, is rejected (state untouched); an enabled rule computes the
1227
- * next run instant immediately, a disabled one carries no next-run instant.
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, ["enabled", "cron"])) return false;
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, ["enabled", "cron"]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.cron === void 0 || typeof patch.cron === "string");
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 Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
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
  }
@@ -2191,6 +2383,7 @@ var HostTaskLedger = class {
2191
2383
  due.push({
2192
2384
  taskId: task.id,
2193
2385
  cron: schedule.cron,
2386
+ timeZone: scheduleZone(schedule),
2194
2387
  nextRunAt: schedule.nextRunAt
2195
2388
  });
2196
2389
  }
@@ -2263,7 +2456,7 @@ var HostTaskLedger = class {
2263
2456
  ...task,
2264
2457
  schedule: {
2265
2458
  ...schedule,
2266
- nextRunAt: nextRunAtMs(schedule.cron, now)
2459
+ nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
2267
2460
  },
2268
2461
  updatedAt: now
2269
2462
  };
@@ -2316,15 +2509,17 @@ var HostTaskLedger = class {
2316
2509
  });
2317
2510
  changed = true;
2318
2511
  }
2512
+ if (task.teamRun === true && this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true;
2319
2513
  if (this.settleCascade(taskId, groupId, now)) changed = true;
2320
2514
  if (changed) this.commit();
2321
2515
  }
2322
2516
  attachSession(taskId, executionId, sessionId) {
2323
2517
  const now = this.now();
2324
- this.document.tasks = this.document.tasks.map((task) => task.id !== taskId ? task : {
2325
- ...task,
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,
2326
2521
  updatedAt: now,
2327
- executions: task.executions.map((entry) => entry.id === executionId ? {
2522
+ executions: item.executions.map((entry) => entry.id === executionId ? {
2328
2523
  ...entry,
2329
2524
  sessionId
2330
2525
  } : entry)
@@ -2347,11 +2542,12 @@ var HostTaskLedger = class {
2347
2542
  this.document.scheduler.error = invalidScheduleIds.length === 0 ? void 0 : `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}`;
2348
2543
  this.repairSchedules(true, false);
2349
2544
  this.reconcileInterruptedStarts(false);
2545
+ this.finalizeReadyRuns(false);
2350
2546
  break;
2351
2547
  }
2352
2548
  case "create": {
2353
2549
  if (this.document.tasks.some((task) => task.id === action.id)) throw new Error("task id already exists");
2354
- 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");
2355
2551
  const input = action.input.freeze === void 0 || initiator === void 0 || initiator === "" ? action.input : {
2356
2552
  ...action.input,
2357
2553
  freeze: {
@@ -2359,7 +2555,7 @@ var HostTaskLedger = class {
2359
2555
  frozenBy: initiator
2360
2556
  }
2361
2557
  };
2362
- 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());
2363
2559
  if (result.task === void 0) throw new Error(result.error ?? "invalid task");
2364
2560
  this.document.tasks = [...result.tasks];
2365
2561
  break;
@@ -2410,6 +2606,19 @@ var HostTaskLedger = class {
2410
2606
  this.document.tasks = [...result.tasks];
2411
2607
  break;
2412
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
+ }
2413
2622
  case "restore": {
2414
2623
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error("running task cannot be restored");
2415
2624
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth);
@@ -2430,7 +2639,7 @@ var HostTaskLedger = class {
2430
2639
  }
2431
2640
  case "set-schedule": {
2432
2641
  if (this.document.tasks.find((task) => task.id === action.taskId)?.archivedAt !== void 0) throw new Error("archived task is read-only");
2433
- const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now);
2642
+ const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone());
2434
2643
  if (!result.applied) throw new Error("invalid schedule");
2435
2644
  this.document.tasks = [...result.tasks];
2436
2645
  break;
@@ -2574,6 +2783,86 @@ var HostTaskLedger = class {
2574
2783
  }
2575
2784
  return changed;
2576
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
+ }
2577
2866
  repairSchedules(skipPast, persist = true) {
2578
2867
  const now = this.now();
2579
2868
  let changed = false;
@@ -2581,7 +2870,7 @@ var HostTaskLedger = class {
2581
2870
  const schedule = task.schedule;
2582
2871
  if (schedule === void 0 || !schedule.enabled) return task;
2583
2872
  if (!skipPast && schedule.nextRunAt !== void 0) return task;
2584
- const next = nextRunAtMs(schedule.cron, now);
2873
+ const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule));
2585
2874
  if (next === void 0) {
2586
2875
  changed = true;
2587
2876
  this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`;
@@ -2631,14 +2920,36 @@ var HostTaskLedger = class {
2631
2920
  if (changed && persist) this.commit();
2632
2921
  }
2633
2922
  /**
2634
- * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
2635
- * migration reuses the v3 normalization, but it first proves every task
2636
- * row is structurally valid: a v2 document that would silently drop or
2637
- * coerce rows fails loudly instead (no quarantined-empty restart).
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.
2638
2934
  */
2639
2935
  migrateLegacyDocument(parsed) {
2640
- if (!Array.isArray(parsed.tasks) || !parsed.tasks.every((row) => isTaskRecord(row))) throw new Error("v2 document contains structurally invalid task rows");
2641
- return this.normalizeDocument(parsed);
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
+ });
2642
2953
  }
2643
2954
  load(dir) {
2644
2955
  const existed = existsSync(this.file);
@@ -2648,13 +2959,13 @@ var HostTaskLedger = class {
2648
2959
  } catch (error) {
2649
2960
  return this.recoverCorrupt(dir, existed, error);
2650
2961
  }
2651
- if (parsed.schemaVersion === 2) try {
2962
+ if (parsed.schemaVersion === 3 || parsed.schemaVersion === 2) try {
2652
2963
  return this.migrateLegacyDocument(parsed);
2653
2964
  } catch (error) {
2654
- throw new Error(`ledger v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
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)}`);
2655
2966
  }
2656
2967
  try {
2657
- if (parsed.schemaVersion !== 3 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
2968
+ if (parsed.schemaVersion !== 4 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
2658
2969
  return this.normalizeDocument(parsed);
2659
2970
  } catch (error) {
2660
2971
  return this.recoverCorrupt(dir, existed, error);
@@ -2676,7 +2987,7 @@ var HostTaskLedger = class {
2676
2987
  const sidecarLastTickAt = this.readSchedulerSidecar();
2677
2988
  const lastTickAt = sidecarLastTickAt === void 0 || documentLastTickAt !== void 0 && documentLastTickAt >= sidecarLastTickAt ? documentLastTickAt : sidecarLastTickAt;
2678
2989
  return {
2679
- schemaVersion: 3,
2990
+ schemaVersion: 4,
2680
2991
  revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
2681
2992
  tasks,
2682
2993
  scheduler: {
@@ -2702,7 +3013,7 @@ var HostTaskLedger = class {
2702
3013
  if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`);
2703
3014
  mkdirSync(dir, { recursive: true });
2704
3015
  return {
2705
- schemaVersion: 3,
3016
+ schemaVersion: 4,
2706
3017
  revision: 0,
2707
3018
  tasks: [],
2708
3019
  scheduler: {
@@ -2894,6 +3205,16 @@ function isDefinitionWithdrawn(error) {
2894
3205
  }
2895
3206
  const SERVICE_UNAVAILABLE_ATTEMPTS = 5;
2896
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
+ }
2897
3218
  function delay(ms) {
2898
3219
  return new Promise((resolve) => {
2899
3220
  setTimeout(resolve, ms);
@@ -2932,13 +3253,25 @@ function peerPromptPreamble(context) {
2932
3253
  const peers = context.peers ?? [];
2933
3254
  if (peers.length === 0) return void 0;
2934
3255
  if (context.team === true) {
2935
- 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)})`);
2936
3257
  return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join("\n")}`;
2937
3258
  }
2938
3259
  const lines = peers.map((peer) => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`);
2939
3260
  return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join("\n")}`;
2940
3261
  }
2941
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
+ /**
2942
3275
  * Build the execution prompt for one task.
2943
3276
  * @param task - the task being launched.
2944
3277
  * @param context - the run's other members, for the execution-shape section.
@@ -2951,6 +3284,7 @@ function promptText(task, context = {}) {
2951
3284
  const preambles = [
2952
3285
  tagPromptPreamble(task),
2953
3286
  handoverPreamble,
3287
+ schedulePromptPreamble(context),
2954
3288
  peerPromptPreamble(context)
2955
3289
  ].filter((part) => part !== void 0);
2956
3290
  const preamble = preambles.length === 0 ? void 0 : preambles.join("\n\n");
@@ -2961,6 +3295,25 @@ function promptText(task, context = {}) {
2961
3295
  return preamble === void 0 ? declaration : `${preamble}\n\n${declaration}`;
2962
3296
  }
2963
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
+ /**
2964
3317
  * Build the tag section of the execution prompt (issue #1521). Only tags with
2965
3318
  * a non-blank `promptPrefix` contribute; a task whose tags are all bare names
2966
3319
  * (or which has no tags at all) yields undefined and the prompt is byte-for-byte
@@ -3150,6 +3503,73 @@ var HostExecutionRunner = class {
3150
3503
  text: promptText(task, context)
3151
3504
  }]
3152
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
+ }
3153
3573
  }
3154
3574
  async listRunning() {
3155
3575
  for (let attempt = 1;; attempt++) try {
@@ -3181,8 +3601,17 @@ var HostExecutionRunner = class {
3181
3601
  await delay(this.unavailableBackoffMs);
3182
3602
  }
3183
3603
  }
3184
- /** Resolve an execution outcome from the session list and bounded history pages. */
3185
- async inspect(sessionId, startedAt = 0, sessions) {
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 = {}) {
3186
3615
  let items;
3187
3616
  if (sessions !== void 0) items = sessions;
3188
3617
  else {
@@ -3214,7 +3643,7 @@ var HostExecutionRunner = class {
3214
3643
  error: "execution session no longer exists"
3215
3644
  };
3216
3645
  }
3217
- if (summary.running) return { outcome: "pending" };
3646
+ if (summary.running && options.whileRunning !== true) return { outcome: "pending" };
3218
3647
  let opening;
3219
3648
  try {
3220
3649
  const iterator = (await this.stream("session", "follow", {
@@ -3224,7 +3653,11 @@ var HostExecutionRunner = class {
3224
3653
  const next = await iterator.next();
3225
3654
  if (typeof iterator.return === "function") await iterator.return();
3226
3655
  const follow = next.done === true ? void 0 : next.value;
3227
- if (follow === void 0 || follow.type !== "snapshot" || typeof follow.cursor !== "number" || follow.records === void 0 || typeof follow.hasMore !== "boolean") return { outcome: "pending" };
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
+ };
3228
3661
  opening = {
3229
3662
  cursor: follow.cursor,
3230
3663
  records: follow.records,
@@ -3232,7 +3665,11 @@ var HostExecutionRunner = class {
3232
3665
  };
3233
3666
  } catch (error) {
3234
3667
  console.warn("[dsh-task-board] session/follow failed during execution inspection; keeping the outcome pending", error);
3235
- return { outcome: "pending" };
3668
+ return {
3669
+ outcome: "pending",
3670
+ unreadable: true,
3671
+ reason: "session/follow failed: " + failureText(error)
3672
+ };
3236
3673
  }
3237
3674
  const openingEvents = opening.records.map((record) => ({ event: recordEvent(record) }));
3238
3675
  const newestSeq = openingEvents.reduce((newest, entry) => newest === void 0 ? entry.event.seq : Math.max(newest, entry.event.seq), void 0);
@@ -3240,7 +3677,7 @@ var HostExecutionRunner = class {
3240
3677
  const events = [...openingEvents];
3241
3678
  let beforeSeq;
3242
3679
  let reachedExecutionBoundary = !opening.hasMore;
3243
- for (let page = 0; page < 100 && !reachedExecutionBoundary; page += 1) {
3680
+ for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
3244
3681
  let history;
3245
3682
  try {
3246
3683
  history = await this.invoke("session", "page", {
@@ -3251,7 +3688,11 @@ var HostExecutionRunner = class {
3251
3688
  });
3252
3689
  } catch (error) {
3253
3690
  console.warn("[dsh-task-board] session/page failed during execution inspection; keeping the outcome pending", error);
3254
- return { outcome: "pending" };
3691
+ return {
3692
+ outcome: "pending",
3693
+ unreadable: true,
3694
+ reason: "session/page failed: " + failureText(error)
3695
+ };
3255
3696
  }
3256
3697
  const pageEntries = pageEvents(history);
3257
3698
  events.push(...pageEntries);
@@ -3264,7 +3705,11 @@ var HostExecutionRunner = class {
3264
3705
  if (oldestSeq === void 0 || oldestSeq === beforeSeq) return { outcome: "pending" };
3265
3706
  beforeSeq = oldestSeq;
3266
3707
  }
3267
- if (!reachedExecutionBoundary) return { outcome: "pending" };
3708
+ if (!reachedExecutionBoundary) return {
3709
+ outcome: "pending",
3710
+ unreadable: true,
3711
+ reason: "history scan did not reach the execution start within 100 pages"
3712
+ };
3268
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];
3269
3714
  if (turnEnd === void 0) {
3270
3715
  if (newestSeq !== void 0) this.scanMemos.set(sessionId, newestSeq);
@@ -3272,7 +3717,7 @@ var HostExecutionRunner = class {
3272
3717
  }
3273
3718
  this.scanMemos.delete(sessionId);
3274
3719
  const reason = nonCompletedTurnEnd(turnEnd.event.data);
3275
- if (reason === void 0) return { outcome: "succeeded" };
3720
+ if (reason === void 0) return await this.goalVerdict(sessionId) ?? { outcome: "succeeded" };
3276
3721
  return reason === "error" ? {
3277
3722
  outcome: "failed",
3278
3723
  error: "agent turn ended with an error"
@@ -3599,6 +4044,15 @@ const RECOVERY_TOLERANCE_MS = 6e4;
3599
4044
  /** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
3600
4045
  const MAX_TIMER_DELAY_MS = 2147483647;
3601
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
+ /**
3602
4056
  * Actions that can move an armed trigger. Only these re-arm the native timer;
3603
4057
  * an unrelated card edit leaves the pending fire untouched.
3604
4058
  */
@@ -3643,6 +4097,12 @@ var TaskBoardHostService = class {
3643
4097
  * conversation instead of prompting into a session it cannot see.
3644
4098
  */
3645
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();
3646
4106
  preventIdleSleep = false;
3647
4107
  team;
3648
4108
  timers;
@@ -3701,7 +4161,7 @@ var TaskBoardHostService = class {
3701
4161
  snapshot() {
3702
4162
  const state = this.ledger.state();
3703
4163
  return {
3704
- schemaVersion: 3,
4164
+ schemaVersion: 4,
3705
4165
  revision: state.revision,
3706
4166
  tasks: state.tasks,
3707
4167
  scheduler: state.scheduler,
@@ -3735,7 +4195,7 @@ var TaskBoardHostService = class {
3735
4195
  if (result.runs !== void 0) this.dispatchRuns(result.runs);
3736
4196
  if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule();
3737
4197
  return {
3738
- schemaVersion: 3,
4198
+ schemaVersion: 4,
3739
4199
  revision: result.state.revision,
3740
4200
  tasks: result.state.tasks,
3741
4201
  scheduler: result.state.scheduler,
@@ -3751,17 +4211,19 @@ var TaskBoardHostService = class {
3751
4211
  this.ledger.dispose();
3752
4212
  this.listeners.clear();
3753
4213
  }
3754
- async launch(opened, others = []) {
4214
+ async launch(opened, others = [], schedule) {
3755
4215
  try {
3756
4216
  const team = opened.task.teamRun === true;
3757
4217
  const reuseSessionId = team ? void 0 : reusableSessionId(opened.task, this.idleSessionIds);
3758
- const promptContext = others.length === 0 ? void 0 : {
3759
- peers: others.map((other) => ({
3760
- id: other.task.id,
3761
- title: other.task.title,
3762
- ...team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id) } : {}
3763
- })),
3764
- ...team ? { team: true } : {}
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 }
3765
4227
  };
3766
4228
  const sessionId = await this.runner.launch(opened.task, {
3767
4229
  ...reuseSessionId === void 0 ? {} : { reuseSessionId },
@@ -3795,7 +4257,7 @@ var TaskBoardHostService = class {
3795
4257
  try {
3796
4258
  const member = await team.spawn({
3797
4259
  leadSessionId,
3798
- 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),
3799
4261
  description: opened.task.title,
3800
4262
  prompt: promptText(opened.task)
3801
4263
  });
@@ -3823,6 +4285,7 @@ var TaskBoardHostService = class {
3823
4285
  return;
3824
4286
  }
3825
4287
  this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
4288
+ this.ledger.finalizeReadyRuns();
3826
4289
  const runtime = this.ledger.runtimeView();
3827
4290
  this.power.updateReasons({
3828
4291
  runningSessions: running.count,
@@ -3836,11 +4299,36 @@ var TaskBoardHostService = class {
3836
4299
  for (const execution of executions) {
3837
4300
  if (execution.sessionId === void 0) continue;
3838
4301
  try {
3839
- const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
3840
- if (result.outcome === "pending") continue;
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);
3841
4309
  this.ledger.settle(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
3842
4310
  } catch {}
3843
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);
4314
+ }
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);
3844
4332
  }
3845
4333
  /** Drop the armed schedule timer and forget its target. */
3846
4334
  clearScheduleTimer() {
@@ -3899,8 +4387,12 @@ var TaskBoardHostService = class {
3899
4387
  return;
3900
4388
  }
3901
4389
  for (const schedule of this.ledger.dueSchedules(now)) {
3902
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
3903
- 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
+ });
3904
4396
  }
3905
4397
  this.armSchedule();
3906
4398
  }
@@ -3914,15 +4406,15 @@ var TaskBoardHostService = class {
3914
4406
  * others back. A team run's members are spawned inside the root's Lead
3915
4407
  * session instead, once that session exists.
3916
4408
  */
3917
- dispatchRuns(runs) {
4409
+ dispatchRuns(runs, schedule) {
3918
4410
  if (runs.length === 0) return;
3919
4411
  const root = runs.find((run) => run.dispatch !== "teammate") ?? runs[0];
3920
4412
  const others = runs.filter((run) => run !== root);
3921
- this.scheduleLaunch(root, others);
3922
- 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);
3923
4415
  }
3924
- scheduleLaunch(opened, others = []) {
3925
- this.launch(opened, others).catch((error) => {
4416
+ scheduleLaunch(opened, others = [], schedule) {
4417
+ this.launch(opened, others, schedule).catch((error) => {
3926
4418
  safeConsoleError("[dsh-task-board] execution launch settlement failed", error);
3927
4419
  });
3928
4420
  }
@@ -4166,6 +4658,7 @@ function scheduleView(schedule) {
4166
4658
  return {
4167
4659
  enabled: schedule.enabled,
4168
4660
  cron: schedule.cron,
4661
+ ...schedule.timeZone === void 0 ? {} : { timeZone: schedule.timeZone },
4169
4662
  ...schedule.nextRunAt === void 0 ? {} : { nextRunAt: schedule.nextRunAt },
4170
4663
  ...schedule.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }
4171
4664
  };
@@ -4215,6 +4708,7 @@ function taskSummary(task, tasks, sessionDefault) {
4215
4708
  ...task.model === void 0 ? {} : { model: task.model },
4216
4709
  ...task.reuseSession === true ? { reuseSession: true } : {},
4217
4710
  ...task.teamRun === true ? { teamRun: true } : {},
4711
+ ...task.goalRun === false ? { goalRun: false } : {},
4218
4712
  ...task.schedule === void 0 ? {} : { schedule: scheduleView(task.schedule) },
4219
4713
  ...task.freeze === void 0 ? {} : { continuationCard: true },
4220
4714
  ...task.permissionConfirmedAt === void 0 ? {} : { permissionConfirmedAt: task.permissionConfirmedAt },
@@ -4407,11 +4901,12 @@ function buildManageTool(host) {
4407
4901
  return defineTool({
4408
4902
  name: "task_board_manage",
4409
4903
  description: [
4410
- "Move, archive, restore, or delete one task board card.",
4904
+ "Move, archive, restore, delete, or settle one task board card.",
4411
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.",
4412
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.",
4413
4908
  "It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.",
4414
- "Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task."
4909
+ "Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task, 卡住, stuck, 强制结算, settle."
4415
4910
  ].join(" "),
4416
4911
  parameters: {
4417
4912
  taskId: {
@@ -4427,7 +4922,8 @@ function buildManageTool(host) {
4427
4922
  "move-backlog",
4428
4923
  "archive",
4429
4924
  "restore",
4430
- "delete"
4925
+ "delete",
4926
+ "settle"
4431
4927
  ],
4432
4928
  description: "The lifecycle operation to perform."
4433
4929
  }
@@ -4453,6 +4949,10 @@ function buildManageTool(host) {
4453
4949
  kind: "archive",
4454
4950
  taskId: args.taskId
4455
4951
  },
4952
+ settle: {
4953
+ kind: "settle",
4954
+ taskId: args.taskId
4955
+ },
4456
4956
  restore: {
4457
4957
  kind: "restore",
4458
4958
  taskId: args.taskId
@@ -4488,7 +4988,8 @@ function buildScheduleTool(host) {
4488
4988
  return defineTool({
4489
4989
  name: "task_board_schedule",
4490
4990
  description: [
4491
- "Arm, change, or disarm a task scheduled runs (5-field cron in the Host local time zone; day-of-month and day-of-week use OR semantics).",
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.",
4492
4993
  "A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.",
4493
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.",
4494
4995
  "Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.",
@@ -4507,6 +5008,10 @@ function buildScheduleTool(host) {
4507
5008
  cron: {
4508
5009
  type: "string",
4509
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."
4510
5015
  }
4511
5016
  },
4512
5017
  output: {
@@ -4514,11 +5019,12 @@ function buildScheduleTool(host) {
4514
5019
  render: renderJson
4515
5020
  },
4516
5021
  async execute(args, exec) {
4517
- 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");
4518
5023
  if (!host.snapshot().tasks.some((task) => task.id === args.taskId)) return refused("task-not-found", "no task with id " + args.taskId);
4519
5024
  const patch = {
4520
5025
  ...args.enabled === void 0 ? {} : { enabled: args.enabled },
4521
- ...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 }
4522
5028
  };
4523
5029
  try {
4524
5030
  const snapshot = host.apply(crypto.randomUUID(), {
@@ -4703,6 +5209,10 @@ function buildCreateTool(host) {
4703
5209
  type: "boolean",
4704
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."
4705
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
+ },
4706
5216
  tags: {
4707
5217
  type: "array",
4708
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.",
@@ -4762,6 +5272,7 @@ function buildCreateTool(host) {
4762
5272
  ...args.model === void 0 || args.model === "" ? {} : { model: args.model },
4763
5273
  ...args.reuseSession === true ? { reuseSession: true } : {},
4764
5274
  ...args.teamRun === true ? { teamRun: true } : {},
5275
+ ...args.goalRun === false ? { goalRun: false } : {},
4765
5276
  ...tags.length === 0 ? {} : { tags },
4766
5277
  ...args.schedule === void 0 ? {} : { schedule: {
4767
5278
  enabled: args.schedule.enabled,
@@ -4823,8 +5334,13 @@ function buildUpdateTool(host) {
4823
5334
  description: "New agent preset id; an empty string clears it."
4824
5335
  },
4825
5336
  permission: {
4826
- type: "string",
4827
- enum: [...TASK_PERMISSIONS, ""],
5337
+ oneOf: [{
5338
+ type: "string",
5339
+ enum: [...TASK_PERMISSIONS]
5340
+ }, {
5341
+ type: "string",
5342
+ const: ""
5343
+ }],
4828
5344
  description: "New permission preset; an empty string clears it back to the session default."
4829
5345
  },
4830
5346
  model: {
@@ -4839,6 +5355,10 @@ function buildUpdateTool(host) {
4839
5355
  type: "boolean",
4840
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)."
4841
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
+ },
4842
5362
  tags: {
4843
5363
  type: "array",
4844
5364
  description: "Replacement label set; an empty array clears all labels.",
@@ -4874,6 +5394,7 @@ function buildUpdateTool(host) {
4874
5394
  if (has(args, "permission") && typeof args.permission === "string") patch.permission = args.permission === "" ? void 0 : args.permission;
4875
5395
  if (has(args, "reuseSession")) patch.reuseSession = args.reuseSession === true;
4876
5396
  if (has(args, "teamRun")) patch.teamRun = args.teamRun === true;
5397
+ if (has(args, "goalRun")) patch.goalRun = args.goalRun === false ? false : true;
4877
5398
  if (has(args, "tags")) {
4878
5399
  const tags = args.tags ?? [];
4879
5400
  patch.tags = tags.length === 0 ? null : tags.map((tag) => ({