@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.
Files changed (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  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/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -36,6 +36,31 @@ export function isExecutionOutcome(value: unknown): value is ExecutionOutcome {
36
36
  return value === 'succeeded' || value === 'failed' || value === 'cancelled'
37
37
  }
38
38
 
39
+ /**
40
+ * Reusable lineage index over ONE task list: the id lookup and the direct
41
+ * children per parent. A caller that runs several lineage queries over the same
42
+ * ledger (the link-subtask candidate gate runs one per task) builds this once
43
+ * instead of rescanning the list per query.
44
+ */
45
+ export interface LineageIndex {
46
+ readonly byId: ReadonlyMap<string, TaskRecord>
47
+ readonly childrenByParent: ReadonlyMap<string, readonly TaskRecord[]>
48
+ }
49
+
50
+ /** Build the reusable index for a task list (ledger order preserved). */
51
+ export function buildLineageIndex(tasks: readonly TaskRecord[]): LineageIndex {
52
+ const byId = new Map<string, TaskRecord>()
53
+ const childrenByParent = new Map<string, TaskRecord[]>()
54
+ for (const task of tasks) {
55
+ byId.set(task.id, task)
56
+ if (task.parentId === undefined) continue
57
+ const siblings = childrenByParent.get(task.parentId)
58
+ if (siblings === undefined) childrenByParent.set(task.parentId, [task])
59
+ else siblings.push(task)
60
+ }
61
+ return { byId, childrenByParent }
62
+ }
63
+
39
64
  function indexOf(tasks: readonly TaskRecord[]): Map<string, TaskRecord> {
40
65
  return new Map(tasks.map(task => [task.id, task]))
41
66
  }
@@ -46,8 +71,8 @@ function indexOf(tasks: readonly TaskRecord[]): Map<string, TaskRecord> {
46
71
  * under a larger limit survives that limit being lowered, and restore and
47
72
  * inheritance must still see all of it.
48
73
  */
49
- export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): TaskRecord[] {
50
- const index = indexOf(tasks)
74
+ export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord, lineage?: LineageIndex): TaskRecord[] {
75
+ const index = lineage?.byId ?? indexOf(tasks)
51
76
  const chain: TaskRecord[] = []
52
77
  const seen = new Set<string>([task.id])
53
78
  let current = task.parentId === undefined ? undefined : index.get(task.parentId)
@@ -61,13 +86,14 @@ export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): T
61
86
  }
62
87
 
63
88
  /** Root-to-task depth: a root task is 0, its subtask 1, and so on. */
64
- export function taskDepth(tasks: readonly TaskRecord[], id: string): number {
65
- const task = indexOf(tasks).get(id)
66
- return task === undefined ? 0 : ancestorChain(tasks, task).length
89
+ export function taskDepth(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): number {
90
+ const task = (lineage?.byId ?? indexOf(tasks)).get(id)
91
+ return task === undefined ? 0 : ancestorChain(tasks, task, lineage).length
67
92
  }
68
93
 
69
94
  /** Direct subtasks of a task, in ledger order. */
70
- export function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRecord[] {
95
+ export function directSubtasks(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): TaskRecord[] {
96
+ if (lineage !== undefined) return [...(lineage.childrenByParent.get(id) ?? [])]
71
97
  return tasks.filter(task => task.parentId === id)
72
98
  }
73
99
 
@@ -75,15 +101,18 @@ export function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRe
75
101
  * Descendants of a task in breadth-first order, bounded by maxSubtaskDepth
76
102
  * total depth. The visited set keeps a malformed ledger from looping.
77
103
  */
78
- export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number): TaskRecord[] {
104
+ export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number, lineage?: LineageIndex): TaskRecord[] {
79
105
  const visited = new Set<string>([id])
80
106
  const found: TaskRecord[] = []
81
107
  let frontier = [id]
82
108
  for (let depth = 1; depth <= maxSubtaskDepth && frontier.length > 0; depth += 1) {
83
109
  const next: string[] = []
84
110
  for (const parentId of frontier) {
85
- for (const task of tasks) {
86
- if (task.parentId !== parentId || visited.has(task.id)) continue
111
+ const children = lineage === undefined
112
+ ? tasks.filter(task => task.parentId === parentId)
113
+ : lineage.childrenByParent.get(parentId) ?? []
114
+ for (const task of children) {
115
+ if (visited.has(task.id)) continue
87
116
  visited.add(task.id)
88
117
  found.push(task)
89
118
  next.push(task.id)
@@ -99,13 +128,13 @@ export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSub
99
128
  * The visited set makes the walk terminate on a cyclic hand-edited ledger
100
129
  * instead of recursing until the stack overflows.
101
130
  */
102
- export function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited: ReadonlySet<string> = new Set<string>()): number {
131
+ export function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited: ReadonlySet<string> = new Set<string>(), lineage?: LineageIndex): number {
103
132
  if (visited.has(id)) return 0
104
133
  const seen = new Set([...visited, id])
105
- const children = directSubtasks(tasks, id)
134
+ const children = directSubtasks(tasks, id, lineage)
106
135
  if (children.length === 0) return 0
107
136
  let height = 0
108
- for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen))
137
+ for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen, lineage))
109
138
  return Math.min(height, SUBTASK_DEPTH_MAX)
110
139
  }
111
140
 
@@ -137,16 +166,17 @@ export function checkParentLink(
137
166
  childId: string,
138
167
  parentId: string | null,
139
168
  maxSubtaskDepth: number,
169
+ lineage?: LineageIndex,
140
170
  ): ParentLinkCheck {
141
- const child = tasks.find(task => task.id === childId)
171
+ const child = lineage === undefined ? tasks.find(task => task.id === childId) : lineage.byId.get(childId)
142
172
  if (child === undefined) return { ok: false, reason: 'unknown-task' }
143
173
  if (parentId === null || parentId === '') return { ok: true }
144
174
  if (parentId === childId) return { ok: false, reason: 'self-parent' }
145
- const parent = tasks.find(task => task.id === parentId)
175
+ const parent = lineage === undefined ? tasks.find(task => task.id === parentId) : lineage.byId.get(parentId)
146
176
  if (parent === undefined) return { ok: false, reason: 'unknown-parent' }
147
177
  if (parent.archivedAt !== undefined) return { ok: false, reason: 'archived-parent' }
148
- if (descendantTasks(tasks, childId, SUBTASK_DEPTH_MAX).some(task => task.id === parentId)) return { ok: false, reason: 'cycle' }
149
- const depth = taskDepth(tasks, parentId) + 1 + subtreeHeight(tasks, childId)
178
+ if (descendantTasks(tasks, childId, SUBTASK_DEPTH_MAX, lineage).some(task => task.id === parentId)) return { ok: false, reason: 'cycle' }
179
+ const depth = taskDepth(tasks, parentId, lineage) + 1 + subtreeHeight(tasks, childId, new Set<string>(), lineage)
150
180
  if (depth > maxSubtaskDepth) return { ok: false, reason: 'depth-exceeded' }
151
181
  return { ok: true }
152
182
  }
@@ -241,18 +271,43 @@ export function effectiveTaskPermission(task: TaskRecord): TaskRecord['permissio
241
271
  */
242
272
  /**
243
273
  * The immutable teammate name for one team-mode member. Teammate names are
244
- * lower-kebab-case and must stay unique for the lifetime of the Team, so the
245
- * run-group token is appended: two runs of the same tree never collide. A title
246
- * with no ASCII word characters (a CJK title, for example) keeps a stable
247
- * generic prefix and is still distinguished by the token.
274
+ * lower-kebab-case and must stay unique for the lifetime of the Team: the
275
+ * run-group token separates two runs of the same tree, and the member
276
+ * discriminator separates two members of one run that reduce to the same slug.
277
+ *
278
+ * The discriminator is not cosmetic. A title with no ASCII word characters (a
279
+ * CJK title, for example) carries no slug at all, so every such title of one
280
+ * run group collides on the generic prefix; Agent Teams answers every member
281
+ * after the first with `teammate name "..." was already used in this Team`,
282
+ * and those subtasks never start. Two ASCII titles that share a slug
283
+ * (`AI 强度预测` next to `AI 配比优化`) collide the same way.
248
284
  * @param title - the member task's title.
249
285
  * @param token - the run group id (the task id when no group is recorded).
286
+ * @param memberId - the member task's id, which makes the name unique in the run.
250
287
  * @returns the teammate name handed to the Agent Teams service.
251
288
  */
252
- export function teammateName(title: string, token: string): string {
289
+ export function teammateName(title: string, token: string, memberId?: string): string {
253
290
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '')
254
291
  const suffix = token.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase()
255
- return `${slug === '' ? 'subtask' : slug}-${suffix === '' ? 'run' : suffix}`
292
+ const member = memberId === undefined || memberId === '' ? undefined : stableTag(memberId)
293
+ return [slug === '' ? 'subtask' : slug, member, suffix === '' ? 'run' : suffix]
294
+ .filter((part): part is string => part !== undefined)
295
+ .join('-')
296
+ }
297
+
298
+ /**
299
+ * A stable 4-hex tag for one member id. The name is derived twice for the same
300
+ * member — once for the Lead's prompt and once for the spawn request — so the
301
+ * tag must be a pure function of the id, never of a clock or a counter. FNV-1a
302
+ * keeps it dependency-free in `core/`, which both halves compile.
303
+ */
304
+ function stableTag(value: string): string {
305
+ let hash = 0x811c9dc5
306
+ for (let index = 0; index < value.length; index += 1) {
307
+ hash ^= value.charCodeAt(index)
308
+ hash = Math.imul(hash, 0x01000193) >>> 0
309
+ }
310
+ return hash.toString(16).padStart(8, '0').slice(0, 4)
256
311
  }
257
312
 
258
313
  export function resolveExecutionTargets(
package/src/core/tasks.ts CHANGED
@@ -87,6 +87,13 @@ export interface ScheduleRule {
87
87
  enabled: boolean
88
88
  /** 5-field cron expression: `分 时 日 月 周`. */
89
89
  cron: string
90
+ /**
91
+ * IANA zone the cron wall clock is read in (issue #1722). Absent means the
92
+ * Host process zone, which is what a rule written before zones were
93
+ * persisted keeps using; the Host stamps its own zone at ledger migration so
94
+ * a later `TZ` change cannot silently move an existing rule.
95
+ */
96
+ timeZone?: string
90
97
  /** Next due instant (ms epoch); maintained by the scheduler/controller. */
91
98
  nextRunAt: number | undefined
92
99
  /** Instant of the latest scheduled trigger (ms epoch). */
@@ -278,6 +285,16 @@ export interface TaskRecord {
278
285
  * and a subtask's own permission pin cannot be honored in this mode.
279
286
  */
280
287
  teamRun?: boolean
288
+ /**
289
+ * Start every execution of this task with dsh's built-in `/goal` command,
290
+ * armed with the composed execution prompt as its objective. The session then
291
+ * keeps working automatic continuation rounds until the agent marks the goal
292
+ * complete, and this board settles the execution on the goal's own end
293
+ * instead of at the first turn end. Absent means ON — the option is checked
294
+ * by default, so only an explicit `false` runs the task as a single plain
295
+ * turn. A session whose `/goal` command is refused still runs the prompt.
296
+ */
297
+ goalRun?: boolean
281
298
  /**
282
299
  * Frozen context snapshot for a continuation card; absent on plain tasks.
283
300
  * Sanitized before it enters the ledger (redaction, slash-command taint,
@@ -354,12 +371,18 @@ export interface NewTaskInput {
354
371
  reuseSession?: boolean
355
372
  /** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
356
373
  teamRun?: boolean
374
+ /**
375
+ * Start the execution with dsh's built-in `/goal`. Absent/true keeps the
376
+ * default (goal run); an explicit false requests a single plain turn.
377
+ */
378
+ goalRun?: boolean
357
379
  /**
358
380
  * Optional scheduled-run rule requested at creation time (the new-task
359
- * dialog): an enable flag plus a 5-field cron expression. The create use
360
- * case arms it only when enabled and the expression is valid.
381
+ * dialog): an enable flag, a 5-field cron expression, and the IANA zone its
382
+ * wall clock is read in (absent means the Host zone). The create use case
383
+ * arms it only when enabled and the expression is valid.
361
384
  */
362
- schedule?: { enabled: boolean; cron: string }
385
+ schedule?: { enabled: boolean; cron: string; timeZone?: string }
363
386
  /**
364
387
  * Optional frozen context snapshot (goal/progress/next, sanitized by the
365
388
  * protocol gate) turning the new task into a continuation card.
@@ -449,7 +472,11 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
449
472
  permission: isTaskPermission(input.permission) ? input.permission : undefined,
450
473
  model: normalizeTargetId(input.model),
451
474
  reuseSession: input.reuseSession === true ? true : undefined,
452
- teamRun: input.teamRun === true ? true : undefined,
475
+ teamRun: input.teamRun === true ? true : undefined,
476
+ // Default ON: only an explicit false is stored, so a card that never
477
+ // touched the option (and every card written before the field existed)
478
+ // keeps starting its runs with /goal.
479
+ goalRun: input.goalRun === false ? false : undefined,
453
480
  ...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
454
481
  ...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
455
482
  ...(tags === undefined ? {} : { tags }),
@@ -476,11 +503,18 @@ export function withSchedule(
476
503
  const schedule: ScheduleRule = {
477
504
  enabled: current?.enabled ?? false,
478
505
  cron: current?.cron ?? '',
506
+ ...(current?.timeZone === undefined ? {} : { timeZone: current.timeZone }),
479
507
  nextRunAt: current?.nextRunAt,
480
508
  lastTriggeredAt: current?.lastTriggeredAt,
481
509
  }
482
510
  if ('enabled' in patch) schedule.enabled = patch.enabled ?? false
483
511
  if ('cron' in patch) schedule.cron = patch.cron ?? ''
512
+ // An explicit `undefined` drops the stored zone (back to the Host zone);
513
+ // an absent key keeps it.
514
+ if ('timeZone' in patch) {
515
+ if (patch.timeZone === undefined) delete schedule.timeZone
516
+ else schedule.timeZone = patch.timeZone
517
+ }
484
518
  if ('nextRunAt' in patch) schedule.nextRunAt = patch.nextRunAt
485
519
  if ('lastTriggeredAt' in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt
486
520
  return { ...task, updatedAt: now, schedule }
@@ -4,7 +4,7 @@
4
4
  * (no persistence or notify - the controller orchestrates those), so it is
5
5
  * unit-testable without any runtime face.
6
6
  */
7
- import { isValidCron, nextRunAtMs } from '../schedule.ts'
7
+ import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from '../schedule.ts'
8
8
  import { DEFAULT_SUBTASK_DEPTH, normalizeSubtaskDepth, taskDepth } from '../subtask.ts'
9
9
  import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
10
10
 
@@ -32,6 +32,7 @@ export interface CreateTaskResult {
32
32
  * @param now - clock instant (ms epoch).
33
33
  * @param id - minted task id.
34
34
  * @param maxSubtaskDepth - deployment subtask depth limit.
35
+ * @param hostTimeZone - zone a rule that stores no zone of its own follows.
35
36
  */
36
37
  export function applyCreateTask(
37
38
  tasks: readonly TaskRecord[],
@@ -39,6 +40,7 @@ export function applyCreateTask(
39
40
  now: number,
40
41
  id: string,
41
42
  maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
43
+ hostTimeZone: string = resolveHostTimeZone(),
42
44
  ): CreateTaskResult {
43
45
  if (input.title.trim() === '') return { task: undefined, tasks, error: 'title is required' }
44
46
  const limit = normalizeSubtaskDepth(maxSubtaskDepth)
@@ -71,7 +73,17 @@ export function applyCreateTask(
71
73
  const requested = input.schedule
72
74
  if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
73
75
  const cron = requested.cron.trim()
74
- task = withSchedule(task, { enabled: true, cron, nextRunAt: nextRunAtMs(cron, now) }, now)
76
+ const requestedZone = requested.timeZone
77
+ // An unusable zone is refused rather than silently reinterpreted as the
78
+ // Host zone: the caller asked for a specific wall clock.
79
+ if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) {
80
+ return { task: undefined, tasks, error: 'invalid schedule time zone' }
81
+ }
82
+ const zone = requestedZone ?? hostTimeZone
83
+ const nextRunAt = nextRunAtMs(cron, now, zone)
84
+ if (nextRunAt !== undefined) {
85
+ task = withSchedule(task, { enabled: true, cron, timeZone: requestedZone, nextRunAt }, now)
86
+ }
75
87
  }
76
88
  return { task, tasks: [...tasks, task] }
77
89
  }
@@ -4,13 +4,15 @@
4
4
  * orchestrates those). Validation and next-run computation live here, sharing
5
5
  * the core cron parser (schedule.ts) and the withSchedule transition.
6
6
  */
7
- import { isValidCron, nextRunAtMs } from '../schedule.ts'
7
+ import { isValidCron, isValidTimeZone, nextRunAtMs } from '../schedule.ts'
8
8
  import { withSchedule, type TaskRecord } from '../tasks.ts'
9
9
 
10
10
  /** Fields the schedule use case may change on a rule. */
11
11
  export interface SetSchedulePatch {
12
12
  enabled?: boolean
13
13
  cron?: string
14
+ /** IANA zone for the cron wall clock; `null` clears it back to the Host zone. */
15
+ timeZone?: string | null
14
16
  }
15
17
 
16
18
  /** Result of arming/disarming a rule. */
@@ -22,31 +24,40 @@ export interface SetScheduleResult {
22
24
  }
23
25
 
24
26
  /**
25
- * Set an on-board task's schedule rule. A blank or invalid cron, or an
26
- * archived task, is rejected (state untouched); an enabled rule computes the
27
- * next run instant immediately, a disabled one carries no next-run instant.
27
+ * Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
28
+ * archived task, or an unusable zone is rejected (state untouched); an enabled
29
+ * rule computes the next run instant immediately in the rule's own zone, a
30
+ * disabled one carries no next-run instant.
28
31
  * @param tasks - current ledger.
29
32
  * @param id - the task to schedule.
30
33
  * @param patch - rule fields to change (absent fields keep their current value).
31
34
  * @param now - clock instant (ms epoch).
35
+ * @param hostTimeZone - zone a rule with no stored zone follows.
32
36
  */
33
37
  export function applySetSchedule(
34
38
  tasks: readonly TaskRecord[],
35
39
  id: string,
36
40
  patch: SetSchedulePatch,
37
41
  now: number,
42
+ hostTimeZone: string,
38
43
  ): SetScheduleResult {
39
44
  const task = tasks.find(candidate => candidate.id === id)
40
45
  if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
41
46
  const current = task.schedule
42
47
  const cron = (patch.cron ?? current?.cron ?? '').trim()
43
48
  if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
49
+ // An explicit null clears the stored zone; an absent key keeps it.
50
+ const requestedZone = patch.timeZone === undefined ? current?.timeZone : patch.timeZone ?? undefined
51
+ if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) return { tasks, applied: false }
44
52
  const enabled = patch.enabled ?? current?.enabled ?? false
45
- const nextRunAt = enabled ? nextRunAtMs(cron, now) : undefined
53
+ const effectiveZone = requestedZone ?? hostTimeZone
54
+ const nextRunAt = enabled ? nextRunAtMs(cron, now, effectiveZone) : undefined
46
55
  if (enabled && nextRunAt === undefined) return { tasks, applied: false }
47
56
  return {
48
57
  tasks: tasks.map(candidate =>
49
- candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, now) : candidate),
58
+ candidate.id === id
59
+ ? withSchedule(candidate, { enabled, cron, timeZone: requestedZone ?? undefined, nextRunAt }, now)
60
+ : candidate),
50
61
  applied: true,
51
62
  }
52
63
  }
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
16
16
  * Editable fields on a task (the update patch surface). `freeze` replaces the
17
17
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
18
18
  */
19
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun'>> & {
19
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun'>> & {
20
20
  freeze?: FreezeSnapshot & { redacted?: boolean } | null
21
21
  /** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
22
22
  handover?: TaskHandoverInput | null
@@ -108,6 +108,9 @@ export function applyUpdateTask(
108
108
  if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
109
109
  // Team execution is a boolean opt-in with the same clear-by-false rule.
110
110
  if ('teamRun' in patch) next.teamRun = patch.teamRun === true ? true : undefined
111
+ // The /goal opt-in is default-ON: true (or an explicit null) returns the
112
+ // card to the default and stores nothing, only false pins a plain turn.
113
+ if ('goalRun' in patch) next.goalRun = patch.goalRun === false ? false : undefined
111
114
  if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
112
115
  if (mode !== undefined || 'mode' in patch) next.mode = mode
113
116
  if (permission !== undefined || 'permission' in patch) next.permission = permission
@@ -94,6 +94,8 @@ function scheduleView(schedule: ScheduleRule): Record<string, unknown> {
94
94
  return {
95
95
  enabled: schedule.enabled,
96
96
  cron: schedule.cron,
97
+ // Absent means the rule follows the Host zone, which the board view reports.
98
+ ...(schedule.timeZone === undefined ? {} : { timeZone: schedule.timeZone }),
97
99
  ...(schedule.nextRunAt === undefined ? {} : { nextRunAt: schedule.nextRunAt }),
98
100
  ...(schedule.lastTriggeredAt === undefined ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }),
99
101
  }
@@ -150,6 +152,8 @@ function taskSummary(
150
152
  ...(task.model === undefined ? {} : { model: task.model }),
151
153
  ...(task.reuseSession === true ? { reuseSession: true } : {}),
152
154
  ...(task.teamRun === true ? { teamRun: true } : {}),
155
+ // Default-ON switch: only the opt-out is a deviation worth reporting.
156
+ ...(task.goalRun === false ? { goalRun: false } : {}),
153
157
  ...(task.schedule === undefined ? {} : { schedule: scheduleView(task.schedule) }),
154
158
  ...(task.freeze === undefined ? {} : { continuationCard: true }),
155
159
  ...(task.permissionConfirmedAt === undefined ? {} : { permissionConfirmedAt: task.permissionConfirmedAt }),
@@ -334,18 +338,19 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
334
338
  return defineTool({
335
339
  name: 'task_board_manage',
336
340
  description: [
337
- 'Move, archive, restore, or delete one task board card.',
341
+ 'Move, archive, restore, delete, or settle one task board card.',
338
342
  'move-todo and move-backlog are the manual column moves; a running card cannot be moved.',
343
+ '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.',
339
344
  '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.',
340
345
  'It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.',
341
- 'Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task.',
346
+ 'Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task, 卡住, stuck, 强制结算, settle.',
342
347
  ].join(' '),
343
348
  parameters: {
344
349
  taskId: { type: 'string', required: true, description: 'The task to act on.' },
345
350
  action: {
346
351
  type: 'string',
347
352
  required: true,
348
- enum: ['move-todo', 'move-backlog', 'archive', 'restore', 'delete'],
353
+ enum: ['move-todo', 'move-backlog', 'archive', 'restore', 'delete', 'settle'],
349
354
  description: 'The lifecycle operation to perform.',
350
355
  },
351
356
  },
@@ -356,6 +361,7 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
356
361
  'move-todo': { kind: 'move', taskId: args.taskId, status: 'todo' },
357
362
  'move-backlog': { kind: 'move', taskId: args.taskId, status: 'backlog' },
358
363
  archive: { kind: 'archive', taskId: args.taskId },
364
+ settle: { kind: 'settle', taskId: args.taskId },
359
365
  restore: { kind: 'restore', taskId: args.taskId },
360
366
  delete: { kind: 'delete', taskId: args.taskId },
361
367
  }
@@ -381,7 +387,8 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
381
387
  return defineTool({
382
388
  name: 'task_board_schedule',
383
389
  description: [
384
- '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).',
390
+ '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).',
391
+ '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.',
385
392
  'A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.',
386
393
  '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.',
387
394
  'Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.',
@@ -391,20 +398,27 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
391
398
  taskId: { type: 'string', required: true, description: 'The task whose schedule changes.' },
392
399
  enabled: { type: 'boolean', description: 'Arm (true) or disarm (false) the schedule.' },
393
400
  cron: { type: 'string', description: '5-field cron expression: minute hour day-of-month month day-of-week.' },
401
+ timeZone: {
402
+ type: 'string',
403
+ 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.',
404
+ },
394
405
  },
395
406
  output: { schema: { type: 'json' }, render: renderJson },
396
407
  async execute(args, exec) {
397
- if (args.enabled === undefined && args.cron === undefined) {
398
- return refused('nothing-to-change', 'pass enabled and/or cron')
408
+ if (args.enabled === undefined && args.cron === undefined && args.timeZone === undefined) {
409
+ return refused('nothing-to-change', 'pass enabled and/or cron and/or timeZone')
399
410
  }
400
411
  // A missing card must read as a missing card: the ledger reports an
401
412
  // unknown id through the same refusal as a malformed cron.
402
413
  if (!host.snapshot().tasks.some(task => task.id === args.taskId)) {
403
414
  return refused('task-not-found', 'no task with id ' + args.taskId)
404
415
  }
405
- const patch: { enabled?: boolean; cron?: string } = {
416
+ const patch: { enabled?: boolean; cron?: string; timeZone?: string | null } = {
406
417
  ...(args.enabled === undefined ? {} : { enabled: args.enabled }),
407
418
  ...(args.cron === undefined ? {} : { cron: args.cron }),
419
+ // An empty string is the documented way to clear the stored zone, so
420
+ // the model never has to emit an explicit null through JSON args.
421
+ ...(args.timeZone === undefined ? {} : { timeZone: args.timeZone === '' ? null : args.timeZone }),
408
422
  }
409
423
  try {
410
424
  const snapshot = host.apply(crypto.randomUUID(), { kind: 'set-schedule', taskId: args.taskId, patch }, callingSessionId(exec))
@@ -514,6 +528,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
514
528
  model: { type: 'string', description: 'Pinned model as provider/model (or a model id); omit for the host default or the parent value.' },
515
529
  reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session instead of a fresh conversation.' },
516
530
  teamRun: { type: 'boolean', 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.' },
531
+ goalRun: { type: 'boolean', 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.' },
517
532
  tags: {
518
533
  type: 'array',
519
534
  description: 'Labels: the name renders as a badge and drives the board filter; promptPrefix is injected ahead of the execution prompt on every run.',
@@ -555,6 +570,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
555
570
  ...(args.model === undefined || args.model === '' ? {} : { model: args.model }),
556
571
  ...(args.reuseSession === true ? { reuseSession: true } : {}),
557
572
  ...(args.teamRun === true ? { teamRun: true } : {}),
573
+ ...(args.goalRun === false ? { goalRun: false } : {}),
558
574
  ...(tags.length === 0 ? {} : { tags }),
559
575
  ...(args.schedule === undefined ? {} : { schedule: { enabled: args.schedule.enabled, cron: args.schedule.cron } }),
560
576
  }
@@ -587,10 +603,25 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
587
603
  prompt: { type: 'string', description: 'New execution prompt.' },
588
604
  workspaceId: { type: 'string', description: 'New workspace id; an empty string clears it.' },
589
605
  mode: { type: 'string', description: 'New agent preset id; an empty string clears it.' },
590
- permission: { type: 'string', enum: [...TASK_PERMISSIONS, ''], description: 'New permission preset; an empty string clears it back to the session default.' },
606
+ // `enum` with a clearing value would advertise the empty string as a
607
+ // legal member of the enum. Some OpenAI-compatible gateways forward the
608
+ // tool schema to Gemini, which rejects an empty enum member outright
609
+ // ("cannot be empty") and fails the whole request - not just this tool -
610
+ // even for a plain greeting, because the schema ships with every call
611
+ // (issue #1748). Splitting the clear value into its own `const` branch
612
+ // keeps both the real enum intact and the wire representation valid for
613
+ // those gateways.
614
+ permission: {
615
+ oneOf: [
616
+ { type: 'string', enum: [...TASK_PERMISSIONS] },
617
+ { type: 'string', const: '' },
618
+ ],
619
+ description: 'New permission preset; an empty string clears it back to the session default.',
620
+ },
591
621
  model: { type: 'string', description: 'New pinned model; an empty string clears it.' },
592
622
  reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session.' },
593
623
  teamRun: { type: 'boolean', description: 'Switches this task between a plain cascade (one session per member) and an Agent Team run (Lead session plus a teammate per subtask).' },
624
+ goalRun: { type: 'boolean', description: 'Whether each run starts with dsh built-in /goal (default true). Pass false to run one plain turn instead.' },
594
625
  tags: {
595
626
  type: 'array',
596
627
  description: 'Replacement label set; an empty array clears all labels.',
@@ -619,6 +650,7 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
619
650
  }
620
651
  if (has(args, 'reuseSession')) patch.reuseSession = args.reuseSession === true
621
652
  if (has(args, 'teamRun')) patch.teamRun = args.teamRun === true
653
+ if (has(args, 'goalRun')) patch.goalRun = args.goalRun === false ? false : true
622
654
  if (has(args, 'tags')) {
623
655
  const tags = args.tags ?? []
624
656
  patch.tags = tags.length === 0