@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
@@ -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
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
- import { isValidCron, nextRunAtMs } from './core/schedule.ts'
6
+ import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
7
7
  import { isTaskRecord, parseLedger } from './core/store.ts'
8
8
  import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
9
9
  import {
@@ -21,7 +21,7 @@ import { applyDeleteTask } from './core/use-cases/task-delete.ts'
21
21
  import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
22
22
  import { applySetParent } from './core/use-cases/task-parent.ts'
23
23
  import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
24
- import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
24
+ import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_OLDER_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
25
25
  import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
26
26
 
27
27
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
@@ -84,12 +84,21 @@ export interface OpenExecutionReference {
84
84
  readonly executionId: string
85
85
  readonly sessionId: string | undefined
86
86
  readonly startedAt: number
87
+ /**
88
+ * True for a member of a team-mode run other than its Lead. A teammate is a
89
+ * durable member of the Team: it stays alive after its turn ends, so the
90
+ * roster may keep reporting its session as running, and the turn it completed
91
+ * is the only verdict it exposes.
92
+ */
93
+ readonly teamMember: boolean
87
94
  }
88
95
 
89
96
  /** Minimal value copy used by the Host scheduler. */
90
97
  export interface DueScheduleReference {
91
98
  readonly taskId: string
92
99
  readonly cron: string
100
+ /** Zone this rule's wall clock is read in. */
101
+ readonly timeZone: string
93
102
  readonly nextRunAt: number
94
103
  }
95
104
 
@@ -106,7 +115,12 @@ interface CachedRequest {
106
115
  }
107
116
 
108
117
  function timeZone(): string {
109
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'
118
+ return resolveHostTimeZone()
119
+ }
120
+
121
+ /** The zone a schedule is evaluated in: its own, or the Host zone when it stores none. */
122
+ function scheduleZone(schedule: { timeZone?: string }): string {
123
+ return schedule.timeZone ?? timeZone()
110
124
  }
111
125
 
112
126
  function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
@@ -381,6 +395,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
381
395
  schedule: {
382
396
  enabled: false,
383
397
  cron: schedule.cron,
398
+ ...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
384
399
  nextRunAt: undefined,
385
400
  lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
386
401
  ? schedule.lastTriggeredAt
@@ -426,6 +441,10 @@ export class HostTaskLedger {
426
441
  }
427
442
  this.repairSchedules(true)
428
443
  this.reconcileInterruptedStarts()
444
+ // Boot recovery also folds every already-decided run: a team run whose
445
+ // Lead recorded its verdict in an earlier process, and a lineage whose
446
+ // last settle never reached its parent, both leave the running column here.
447
+ this.finalizeReadyRuns(false)
429
448
  // Persist a freshly generated ledger identity and any recovery error
430
449
  // immediately, even when there are no tasks to trigger a later action.
431
450
  this.commit(false)
@@ -488,6 +507,15 @@ export class HostTaskLedger {
488
507
  */
489
508
  runtimeView(): LedgerRuntimeView {
490
509
  let armedSchedules = 0
510
+ // Run groups opened by a team-mode card: every other member of those groups
511
+ // runs as a teammate inside that card's Lead session.
512
+ const teamGroups = new Set<string>()
513
+ for (const task of this.document.tasks) {
514
+ if (task.teamRun !== true) continue
515
+ for (const execution of task.executions) {
516
+ if (execution.runGroupId !== undefined) teamGroups.add(execution.runGroupId)
517
+ }
518
+ }
491
519
  const openExecutions: OpenExecutionReference[] = []
492
520
  for (const task of this.document.tasks) {
493
521
  if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
@@ -501,6 +529,9 @@ export class HostTaskLedger {
501
529
  executionId: execution.id,
502
530
  sessionId: execution.sessionId,
503
531
  startedAt: execution.startedAt,
532
+ teamMember: task.teamRun !== true
533
+ && execution.runGroupId !== undefined
534
+ && teamGroups.has(execution.runGroupId),
504
535
  })
505
536
  }
506
537
  }
@@ -543,7 +574,7 @@ export class HostTaskLedger {
543
574
  if (task.archivedAt !== undefined) continue
544
575
  const schedule = task.schedule
545
576
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
546
- due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
577
+ due.push({ taskId: task.id, cron: schedule.cron, timeZone: scheduleZone(schedule), nextRunAt: schedule.nextRunAt })
547
578
  }
548
579
  return due
549
580
  }
@@ -626,7 +657,7 @@ export class HostTaskLedger {
626
657
  const schedule = task.schedule
627
658
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
628
659
  changed = true
629
- return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now }
660
+ return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule)) }, updatedAt: now }
630
661
  })
631
662
  if (changed) this.commit()
632
663
  }
@@ -681,16 +712,26 @@ export class HostTaskLedger {
681
712
  })
682
713
  changed = true
683
714
  }
715
+ // A team run is governed by its Lead: the Lead's own outcome closes whatever
716
+ // the Team still holds open, so the tree can never wait on a teammate that
717
+ // will never report one.
718
+ if (task.teamRun === true
719
+ && this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true
684
720
  if (this.settleCascade(taskId, groupId, now)) changed = true
685
721
  if (changed) this.commit()
686
722
  }
687
723
 
688
724
  attachSession(taskId: string, executionId: string, sessionId: string): void {
689
725
  const now = this.now()
690
- this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
691
- ...task,
726
+ const task = this.document.tasks.find(item => item.id === taskId)
727
+ // A run the Lead's verdict already closed keeps its record: attaching a
728
+ // session to a settled execution would rewrite history the Host no longer
729
+ // observes (a teammate spawn that resolved after its Team was closed).
730
+ if (task?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) return
731
+ this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
732
+ ...item,
692
733
  updatedAt: now,
693
- executions: task.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
734
+ executions: item.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
694
735
  })
695
736
  this.commit()
696
737
  }
@@ -717,17 +758,22 @@ export class HostTaskLedger {
717
758
  : `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}`
718
759
  this.repairSchedules(true, false)
719
760
  this.reconcileInterruptedStarts(false)
761
+ // An imported document may carry a decided-but-open run; fold it in the
762
+ // same action (apply() commits once at the end).
763
+ this.finalizeReadyRuns(false)
720
764
  break
721
765
  }
722
766
  case 'create': {
723
767
  if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
724
- if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) {
768
+ if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
769
+ || !isValidTimeZone(action.input.schedule.timeZone ?? timeZone())
770
+ || nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
725
771
  throw new Error('invalid schedule')
726
772
  }
727
773
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
728
774
  ? action.input
729
775
  : { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
730
- const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
776
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
731
777
  if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
732
778
  this.document.tasks = [...result.tasks]
733
779
  break
@@ -786,6 +832,34 @@ export class HostTaskLedger {
786
832
  this.document.tasks = [...result.tasks]
787
833
  break
788
834
  }
835
+ case 'settle': {
836
+ const task = this.document.tasks.find(item => item.id === action.taskId)
837
+ if (task === undefined) throw new Error('task not found')
838
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
839
+ if (!task.executions.some(entry => entry.endedAt === undefined)) throw new Error('task has no open execution')
840
+ // Force-close the executions this card governs. The verdict is
841
+ // 'cancelled', never 'succeeded': a card whose execution produced no
842
+ // evidence of a completed turn must not be recorded as finished work,
843
+ // and a cancelled settlement returns the card to the todo column so the
844
+ // operator can run it again. Nothing else clears a running card: move,
845
+ // archive and delete all refuse one, which is what made a stuck card
846
+ // unrecoverable from the board.
847
+ const reason = 'execution closed manually by '
848
+ + (initiator === undefined || initiator === '' ? 'the operator' : initiator)
849
+ + ': no verdict was recorded'
850
+ for (const member of cascadeTargets(this.document.tasks, action.taskId, this.maxSubtaskDepth)) {
851
+ for (const execution of member.executions) {
852
+ if (execution.endedAt !== undefined) continue
853
+ this.document.tasks = this.document.tasks.map(item => item.id !== member.id
854
+ ? item
855
+ : settleExecution(item, execution.id, 'cancelled', now, reason))
856
+ }
857
+ }
858
+ // Fold whatever became ready in the same action: an ancestor whose last
859
+ // member this closed leaves the running column with it.
860
+ this.finalizeReadyRuns(false)
861
+ break
862
+ }
789
863
  case 'restore': {
790
864
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
791
865
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
@@ -805,7 +879,7 @@ export class HostTaskLedger {
805
879
  case 'set-schedule': {
806
880
  const task = this.document.tasks.find(task => task.id === action.taskId)
807
881
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
808
- const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now)
882
+ const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone())
809
883
  if (!result.applied) throw new Error('invalid schedule')
810
884
  this.document.tasks = [...result.tasks]
811
885
  break
@@ -959,6 +1033,95 @@ export class HostTaskLedger {
959
1033
  return changed
960
1034
  }
961
1035
 
1036
+ /**
1037
+ * Close every run that is already decided, and fold every lineage whose
1038
+ * members are all settled. The Host poll calls this on every tick, so a run
1039
+ * can no longer be left in the running column by a settle nobody observed:
1040
+ *
1041
+ * - A team run is governed by its Lead. Once the Lead's own outcome is
1042
+ * recorded, its still-open members are settled with that verdict. A
1043
+ * teammate is a durable member of the Team and may never report a turn of
1044
+ * its own, and without this the whole lineage waits on it forever.
1045
+ * - A cascade parent whose members are all settled is finalized here even
1046
+ * when the settle that completed the tree happened in another process, or
1047
+ * was interrupted between its child write and its parent write.
1048
+ *
1049
+ * Idempotent: a run that is already folded is left untouched, so a caller may
1050
+ * invoke it on every tick.
1051
+ * @param persist - false to leave the document for the caller to commit.
1052
+ * @returns whether the document changed.
1053
+ */
1054
+ finalizeReadyRuns(persist = true): boolean {
1055
+ const now = this.now()
1056
+ let changed = false
1057
+ const groups = new Set<string>()
1058
+ for (const task of this.document.tasks) {
1059
+ for (const execution of task.executions) {
1060
+ if (execution.endedAt === undefined && execution.runGroupId !== undefined) groups.add(execution.runGroupId)
1061
+ }
1062
+ }
1063
+ for (const groupId of groups) {
1064
+ const lead = this.document.tasks.find(task => task.teamRun === true
1065
+ && task.executions.some(entry => entry.runGroupId === groupId))
1066
+ const verdict = lead === undefined ? undefined : this.leadVerdict(lead, groupId)
1067
+ if (lead !== undefined && verdict !== undefined
1068
+ && this.closeTeamMembers(lead.id, groupId, verdict.result, verdict.error, now)) changed = true
1069
+ // Fold the lineage: a member may itself be a parent of a deeper member.
1070
+ for (const task of this.document.tasks) {
1071
+ if (this.settleCascade(task.id, groupId, now)) changed = true
1072
+ }
1073
+ }
1074
+ if (changed && persist) this.commit()
1075
+ return changed
1076
+ }
1077
+
1078
+ /**
1079
+ * The verdict a team run's Lead has already recorded: its own turn outcome
1080
+ * once that turn ended, or the folded outcome once its card settled.
1081
+ */
1082
+ private leadVerdict(lead: TaskRecord, groupId: string): { result: ExecutionOutcome; error: string | undefined } | undefined {
1083
+ const execution = lead.executions.find(entry => entry.runGroupId === groupId)
1084
+ const result = execution?.ownResult ?? execution?.result
1085
+ if (result === undefined) return undefined
1086
+ return { result, error: execution?.ownError ?? execution?.error }
1087
+ }
1088
+
1089
+ /**
1090
+ * Settle every still-open member of a team run with the Lead's verdict.
1091
+ * A teammate that already reported its own outcome keeps it: the ordinary
1092
+ * fold still lets a failure dominate.
1093
+ * @param leadId - the Lead card's task id (never settled here).
1094
+ * @param groupId - the team run's group.
1095
+ * @param verdict - the Lead's outcome, inherited by members that never reported.
1096
+ * @param error - the Lead's failure text, inherited with a failed verdict.
1097
+ * @param now - clock instant (ms epoch).
1098
+ * @returns whether the document changed.
1099
+ */
1100
+ private closeTeamMembers(leadId: string, groupId: string, verdict: ExecutionOutcome, error: string | undefined, now: number): boolean {
1101
+ const members: string[] = []
1102
+ let recorded = false
1103
+ this.document.tasks = this.document.tasks.map(task => {
1104
+ if (task.id === leadId) return task
1105
+ const execution = openGroupExecution(task, groupId)
1106
+ if (execution === undefined) return task
1107
+ members.push(task.id)
1108
+ if (execution.ownResult !== undefined) return task
1109
+ recorded = true
1110
+ return {
1111
+ ...task,
1112
+ updatedAt: now,
1113
+ executions: task.executions.map(entry => entry.id === execution.id
1114
+ ? { ...entry, ownResult: verdict, ownError: error }
1115
+ : entry),
1116
+ }
1117
+ })
1118
+ let changed = recorded
1119
+ for (const memberId of members) {
1120
+ if (this.settleCascade(memberId, groupId, now)) changed = true
1121
+ }
1122
+ return changed
1123
+ }
1124
+
962
1125
  private repairSchedules(skipPast: boolean, persist = true): void {
963
1126
  const now = this.now()
964
1127
  let changed = false
@@ -966,7 +1129,7 @@ export class HostTaskLedger {
966
1129
  const schedule = task.schedule
967
1130
  if (schedule === undefined || !schedule.enabled) return task
968
1131
  if (!skipPast && schedule.nextRunAt !== undefined) return task
969
- const next = nextRunAtMs(schedule.cron, now)
1132
+ const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
970
1133
  if (next === undefined) {
971
1134
  changed = true
972
1135
  this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
@@ -1003,35 +1166,52 @@ export class HostTaskLedger {
1003
1166
  }
1004
1167
 
1005
1168
  /**
1006
- * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
1007
- * migration reuses the v3 normalization, but it first proves every task
1008
- * row is structurally valid: a v2 document that would silently drop or
1009
- * coerce rows fails loudly instead (no quarantined-empty restart).
1169
+ * Field-preserving migration of a pre-v4 document. It first proves every task
1170
+ * row is structurally valid, so a document that would silently drop or coerce
1171
+ * rows fails loudly instead (no quarantined-empty restart), and then reuses
1172
+ * the current normalization.
1173
+ *
1174
+ * v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
1175
+ * whatever zone the Host process happened to report, so the migration stamps
1176
+ * that zone onto every enabled rule: the trigger instant is preserved exactly
1177
+ * (the stored `nextRunAt` already encodes the old zone), but the rule stops
1178
+ * following a later `TZ` change, which is what made an existing schedule
1179
+ * silently move when the Host's zone changed.
1010
1180
  */
1011
1181
  private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
1012
1182
  if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
1013
- throw new Error('v2 document contains structurally invalid task rows')
1183
+ throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
1014
1184
  }
1015
- return this.normalizeDocument(parsed)
1185
+ // Every row passed `isTaskRecord` above, so the stamped rows are still
1186
+ // task records; the cast only re-narrows the unknown-typed on-disk array.
1187
+ const rows = (parsed.tasks as readonly unknown[]).map((value) => {
1188
+ const row = value as { schedule?: unknown }
1189
+ const schedule = row.schedule
1190
+ if (typeof schedule !== 'object' || schedule === null) return value
1191
+ if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
1192
+ return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
1193
+ })
1194
+ return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
1016
1195
  }
1017
1196
 
1018
1197
  private load(dir: string): LedgerDocument {
1019
1198
  const existed = existsSync(this.file)
1020
- // schemaVersion stays unknown-typed here: on-disk documents may be v2
1021
- // (legacy), v3, or any future/invalid value the branches below sort out.
1199
+ // schemaVersion stays unknown-typed here: on-disk documents may be v2 or
1200
+ // v3 (legacy), v4, or any future/invalid value the branches below sort out.
1022
1201
  let parsed: ParsedLedgerDocument
1023
1202
  try {
1024
1203
  parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument
1025
1204
  } catch (error) {
1026
1205
  return this.recoverCorrupt(dir, existed, error)
1027
1206
  }
1028
- if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION) {
1207
+ if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION
1208
+ || parsed.schemaVersion === TASK_BOARD_OLDER_SCHEMA_VERSION) {
1029
1209
  try {
1030
1210
  return this.migrateLegacyDocument(parsed)
1031
1211
  } catch (error) {
1032
- // Migration failure is explicit: the original v2 file stays in place
1212
+ // Migration failure is explicit: the original file stays in place
1033
1213
  // for manual recovery and the ledger refuses to start (fail closed).
1034
- throw new Error(`ledger v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
1214
+ throw new Error(`ledger v${String(parsed.schemaVersion)} to v${TASK_BOARD_SCHEMA_VERSION} migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
1035
1215
  }
1036
1216
  }
1037
1217
  try {