@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
@@ -60,6 +60,17 @@ function isDefinitionWithdrawn(error: unknown): boolean {
60
60
 
61
61
  const SERVICE_UNAVAILABLE_ATTEMPTS = 5
62
62
  const SERVICE_UNAVAILABLE_BACKOFF_MS = 2_000
63
+ /**
64
+ * Largest number of history pages one inspection walks back. A session whose
65
+ * window is wider than this cannot be resolved from its log, which the caller
66
+ * treats as an unreadable history rather than as pending work.
67
+ */
68
+ const HISTORY_PAGE_LIMIT = 100
69
+
70
+ /** One-line text of a gateway or stream failure, for a decideable reason. */
71
+ function failureText(error: unknown): string {
72
+ return error instanceof Error ? error.message : String(error)
73
+ }
63
74
 
64
75
  function delay(ms: number): Promise<void> {
65
76
  return new Promise(resolve => { setTimeout(resolve, ms) })
@@ -87,7 +98,14 @@ export interface SessionCommandDispatcher {
87
98
  }
88
99
 
89
100
  export type ExecutionInspection =
90
- | { outcome: 'pending' }
101
+ /**
102
+ * No verdict yet. `unreadable` separates "the work may still be in progress"
103
+ * from "this session's history could not be read at all": a reader failure is
104
+ * not progress, and the caller decides how long to keep waiting before it
105
+ * reports the execution as undeterminable instead of holding a card open
106
+ * forever.
107
+ */
108
+ | { outcome: 'pending'; unreadable?: true; reason?: string }
91
109
  | { outcome: 'succeeded' }
92
110
  | { outcome: 'failed'; error: string }
93
111
  | { outcome: 'cancelled'; error: string }
@@ -136,6 +154,13 @@ export interface PromptContext {
136
154
  peers?: readonly PromptPeer[]
137
155
  /** True when those members run as teammates inside this session (Team Lead). */
138
156
  team?: boolean
157
+ /**
158
+ * Scheduled-run provenance: the instant the cron rule actually fired and the
159
+ * IANA zone its wall clock was read in. Present only for a cron-triggered
160
+ * run, so the model can resolve an unqualified date in the run's own terms
161
+ * instead of guessing from the Host clock (issue #1722).
162
+ */
163
+ schedule?: { triggeredAt: number; timeZone: string; cron: string }
139
164
  }
140
165
 
141
166
  /**
@@ -147,13 +172,27 @@ function peerPromptPreamble(context: PromptContext): string | undefined {
147
172
  const peers = context.peers ?? []
148
173
  if (peers.length === 0) return undefined
149
174
  if (context.team === true) {
150
- const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id)})`)
175
+ const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id, peer.id)})`)
151
176
  return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join('\n')}`
152
177
  }
153
178
  const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`)
154
179
  return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join('\n')}`
155
180
  }
156
181
 
182
+ /**
183
+ * The scheduled-run section: this execution was triggered by the card's cron
184
+ * rule rather than by a person. It states the firing instant, the rule's zone,
185
+ * and the rule text, so the run's own clock is unambiguous — the board's
186
+ * scheduler fires on a wall clock in a specific IANA zone, which is not
187
+ * necessarily the zone of whatever session the run lands in.
188
+ */
189
+ function schedulePromptPreamble(context: PromptContext): string | undefined {
190
+ const schedule = context.schedule
191
+ if (schedule === undefined) return undefined
192
+ const stamp = new Date(schedule.triggeredAt).toISOString()
193
+ return `本次执行由任务看板的定时规则自动触发:触发时间 ${stamp}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`
194
+ }
195
+
157
196
  /**
158
197
  * Build the execution prompt for one task.
159
198
  * @param task - the task being launched.
@@ -167,11 +206,14 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
167
206
  ? undefined
168
207
  : `交接包引用(来自任务看板续接卡片,冻结于 ${new Date(handover.bundledAt).toISOString()}):\n${handover.references.map(reference => `- ${reference}`).join('\n')}`
169
208
  // Tag prompts come first: they are the run's standing context (business line,
170
- // output location), the handover preamble is a per-card note, and the task
171
- // body is the instruction itself.
209
+ // output location), the handover preamble is a per-card note, the scheduled
210
+ // section states why this run exists, and the task body is the instruction.
172
211
  const tagPreamble = tagPromptPreamble(task)
212
+ const handoverSection = handoverPreamble
213
+ const schedulePreamble = schedulePromptPreamble(context)
173
214
  const runPreamble = peerPromptPreamble(context)
174
- const preambles = [tagPreamble, handoverPreamble, runPreamble].filter((part): part is string => part !== undefined)
215
+ const preambles = [tagPreamble, handoverSection, schedulePreamble, runPreamble]
216
+ .filter((part): part is string => part !== undefined)
175
217
  const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
176
218
  const freeze = task.freeze
177
219
  if (freeze === undefined) {
@@ -182,6 +224,27 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
182
224
  return preamble === undefined ? declaration : `${preamble}\n\n${declaration}`
183
225
  }
184
226
 
227
+ /**
228
+ * The parts of an objective the built-in `/goal` command reads as an operation
229
+ * rather than as an objective: an exact `clear`/`pause`/`resume`/`edit`
230
+ * (case-insensitive) and a leading `edit <objective>`.
231
+ */
232
+ const GOAL_COMMAND_KEYWORD = /^(?:clear|pause|resume|edit)$|^edit\s/i
233
+
234
+ /**
235
+ * Build the objective the built-in `/goal` command receives. A prompt whose
236
+ * text would be read as a goal operation is prefixed with a neutral label: the
237
+ * instruction stays intact, and the command arms an objective instead of
238
+ * clearing, pausing, or editing an unrelated goal (or refusing for a missing
239
+ * one).
240
+ * @param prompt - the composed execution prompt.
241
+ * @returns the objective text for `/goal <objective>`.
242
+ */
243
+ export function goalObjective(prompt: string): string {
244
+ const text = prompt.trim()
245
+ return GOAL_COMMAND_KEYWORD.test(text) ? `Goal: ${text}` : text
246
+ }
247
+
185
248
  /**
186
249
  * Build the tag section of the execution prompt (issue #1521). Only tags with
187
250
  * a non-blank `promptPrefix` contribute; a task whose tags are all bare names
@@ -393,6 +456,82 @@ export class HostExecutionRunner {
393
456
  mode: 'queue' as const,
394
457
  content: [{ type: 'text' as const, text: promptText(task, context) }],
395
458
  })
459
+ await this.armGoal(sessionId, task, context)
460
+ }
461
+
462
+ /**
463
+ * Arm dsh's built-in goal on the session. The task prompt is queued FIRST, so
464
+ * the session's first turn carries the instruction verbatim; `/goal
465
+ * <objective>` then turns the same text into a persistent objective, and
466
+ * dsh's goal-round driver keeps starting continuation rounds until the agent
467
+ * marks it complete (see {@link goalVerdict} for how the run settles).
468
+ *
469
+ * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
470
+ * A refusal (no command dispatcher, no `/goal` command in this cohort, an
471
+ * objective the command rejects) is reported and the run continues as that
472
+ * plain turn: the task was asked to run, and a missing goal mode must not
473
+ * lose the work.
474
+ */
475
+ private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<void> {
476
+ if (task.goalRun === false) return
477
+ if (this.commands === undefined) {
478
+ console.warn('[dsh-task-board] no command dispatcher is available; task ' + task.id + ' runs without /goal')
479
+ return
480
+ }
481
+ try {
482
+ const command = await this.commands.execute(
483
+ sessionId,
484
+ '/goal ' + goalObjective(promptText(task, context)),
485
+ AbortSignal.timeout(30_000),
486
+ )
487
+ if (command === undefined) {
488
+ console.warn('[dsh-task-board] /goal was not acknowledged for session ' + sessionId + '; the run continues as a plain turn')
489
+ return
490
+ }
491
+ if (command.kind !== 'success') {
492
+ console.warn('[dsh-task-board] /goal was refused for session ' + sessionId + ': ' + (command.text ?? 'no reason reported') + '; the run continues as a plain turn')
493
+ }
494
+ } catch (error) {
495
+ console.warn('[dsh-task-board] could not arm /goal on session ' + sessionId + '; the run continues as a plain turn', error)
496
+ }
497
+ }
498
+
499
+ /**
500
+ * The verdict an armed dsh goal imposes on an otherwise completed turn.
501
+ *
502
+ * A goal run is one session working many automatic continuation rounds:
503
+ * dsh's goal-round driver queues the next round whenever the agent goes
504
+ * idle, so the first completed `turn/end` is not the execution's result.
505
+ * Settling there would leave the card in `done` while the session keeps
506
+ * working, and a scheduled task would fire a second, concurrent session.
507
+ * The session projection is the authority: an `active` goal keeps the
508
+ * execution pending, a `blocked` goal fails it with the recorded reason, and
509
+ * any other phase (complete, paused, or no goal at all) leaves the turn
510
+ * verdict in place.
511
+ *
512
+ * An unreadable projection falls back to that turn verdict: a cohort whose
513
+ * gateway withdrew `session/projections` must not hang every execution.
514
+ */
515
+ private async goalVerdict(sessionId: string): Promise<ExecutionInspection | undefined> {
516
+ let projections: { values?: { goal?: unknown } } | null
517
+ try {
518
+ projections = await this.invoke('session', 'projections', { sessionId }) as { values?: { goal?: unknown } } | null
519
+ } catch (error) {
520
+ console.warn('[dsh-task-board] session/projections failed while reading the goal phase; settling from the turn result', error)
521
+ return undefined
522
+ }
523
+ const goal = projections?.values?.goal
524
+ if (goal === undefined || goal === null) return undefined
525
+ const view = (goal as { goal?: { phase?: unknown; blockedReason?: { message?: unknown } } }).goal
526
+ if (view?.phase === 'active') return { outcome: 'pending' }
527
+ if (view?.phase === 'blocked') {
528
+ const message = view.blockedReason?.message
529
+ return {
530
+ outcome: 'failed',
531
+ error: 'goal is blocked: ' + (typeof message === 'string' && message !== '' ? message : 'no reason recorded'),
532
+ }
533
+ }
534
+ return undefined
396
535
  }
397
536
 
398
537
  async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
@@ -424,8 +563,17 @@ export class HostExecutionRunner {
424
563
  }
425
564
  }
426
565
 
427
- /** Resolve an execution outcome from the session list and bounded history pages. */
428
- async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection> {
566
+ /**
567
+ * Resolve an execution outcome from the session list and bounded history pages.
568
+ * @param sessionId - the session the execution runs in.
569
+ * @param startedAt - instant the execution opened; earlier events are not its own.
570
+ * @param sessions - roster from the caller's own poll, when it already has one.
571
+ * @param options.whileRunning - read the first completed turn even while the
572
+ * roster reports the session as running. A durable Agent Teams teammate
573
+ * stays alive after its turn ends, so `running` never clears for it and the
574
+ * turn it completed is the only verdict it will ever expose.
575
+ */
576
+ async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[], options: { whileRunning?: boolean } = {}): Promise<ExecutionInspection> {
429
577
  let items: readonly SessionSummary[]
430
578
  if (sessions !== undefined) {
431
579
  items = sessions
@@ -455,7 +603,7 @@ export class HostExecutionRunner {
455
603
  this.scanMemos.delete(sessionId)
456
604
  return { outcome: 'cancelled', error: 'execution session no longer exists' }
457
605
  }
458
- if (summary.running) return { outcome: 'pending' }
606
+ if (summary.running && options.whileRunning !== true) return { outcome: 'pending' }
459
607
 
460
608
  let opening: { cursor: number; records: readonly SessionHistoryRecord[]; hasMore: boolean }
461
609
  try {
@@ -465,12 +613,12 @@ export class HostExecutionRunner {
465
613
  if (typeof iterator.return === 'function') await iterator.return()
466
614
  const follow = next.done === true ? undefined : next.value as { type?: string; cursor?: number; records?: readonly SessionHistoryRecord[]; hasMore?: boolean }
467
615
  if (follow === undefined || follow.type !== 'snapshot' || typeof follow.cursor !== 'number' || follow.records === undefined || typeof follow.hasMore !== 'boolean') {
468
- return { outcome: 'pending' }
616
+ return { outcome: 'pending', unreadable: true, reason: 'session/follow returned no opening frame' }
469
617
  }
470
618
  opening = { cursor: follow.cursor, records: follow.records, hasMore: follow.hasMore }
471
619
  } catch (error) {
472
620
  console.warn('[dsh-task-board] session/follow failed during execution inspection; keeping the outcome pending', error)
473
- return { outcome: 'pending' }
621
+ return { outcome: 'pending', unreadable: true, reason: 'session/follow failed: ' + failureText(error) }
474
622
  }
475
623
  const openingEvents = opening.records.map(record => ({ event: recordEvent(record) }))
476
624
  const newestSeq = openingEvents.reduce<number | undefined>((newest, entry) => newest === undefined ? entry.event.seq : Math.max(newest, entry.event.seq), undefined)
@@ -478,7 +626,7 @@ export class HostExecutionRunner {
478
626
  const events: Array<{ event: { type: string; seq: number; time: number; data: unknown } }> = [...openingEvents]
479
627
  let beforeSeq: number | undefined
480
628
  let reachedExecutionBoundary = !opening.hasMore
481
- for (let page = 0; page < 100 && !reachedExecutionBoundary; page += 1) {
629
+ for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
482
630
  let history: SessionPage
483
631
  try {
484
632
  history = await this.invoke('session', 'page', {
@@ -489,7 +637,7 @@ export class HostExecutionRunner {
489
637
  }) as SessionPage
490
638
  } catch (error) {
491
639
  console.warn('[dsh-task-board] session/page failed during execution inspection; keeping the outcome pending', error)
492
- return { outcome: 'pending' }
640
+ return { outcome: 'pending', unreadable: true, reason: 'session/page failed: ' + failureText(error) }
493
641
  }
494
642
  const pageEntries = pageEvents(history)
495
643
  events.push(...pageEntries)
@@ -502,7 +650,9 @@ export class HostExecutionRunner {
502
650
  if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
503
651
  beforeSeq = oldestSeq
504
652
  }
505
- if (!reachedExecutionBoundary) return { outcome: 'pending' }
653
+ if (!reachedExecutionBoundary) {
654
+ return { outcome: 'pending', unreadable: true, reason: 'history scan did not reach the execution start within ' + HISTORY_PAGE_LIMIT + ' pages' }
655
+ }
506
656
  const turnEnd = events
507
657
  .filter(entry => entry.event.type === 'turn/end' && (startedAt <= 0 || entry.event.time >= startedAt))
508
658
  .sort((a, b) => a.event.seq - b.event.seq)[0]
@@ -512,7 +662,7 @@ export class HostExecutionRunner {
512
662
  }
513
663
  this.scanMemos.delete(sessionId)
514
664
  const reason = nonCompletedTurnEnd(turnEnd.event.data)
515
- if (reason === undefined) return { outcome: 'succeeded' }
665
+ if (reason === undefined) return await this.goalVerdict(sessionId) ?? { outcome: 'succeeded' }
516
666
  // `error` keeps its historical wording; every other non-completed reason
517
667
  // (aborted / blocked / max-tokens / interrupted) now reports instead of
518
668
  // silently counting as success.
@@ -50,6 +50,26 @@ const SESSION_POLL_MS = 5_000
50
50
  const RECOVERY_TOLERANCE_MS = 60_000
51
51
  /** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
52
52
  const MAX_TIMER_DELAY_MS = 2_147_483_647
53
+ /**
54
+ * Consecutive polls that may report one execution's session history as
55
+ * unreadable before it is reported failed. The roster poll runs every 5 s, so
56
+ * this is two minutes of a session that is NOT running: no turn is executing
57
+ * there and its history cannot be read, which means no verdict will ever
58
+ * arrive. Reporting that as a failure keeps a card — and every ancestor of it —
59
+ * out of the running column, which nothing else can rescue.
60
+ */
61
+ const UNREADABLE_SETTLE_POLLS = 24
62
+
63
+ /**
64
+ * Provenance of one cron-triggered cascade: when the rule fired and the zone
65
+ * its wall clock was read in. Passed into every launched prompt of that run so
66
+ * a scheduled job knows its own clock rather than inferring one.
67
+ */
68
+ export interface ScheduledRunContext {
69
+ triggeredAt: number
70
+ timeZone: string
71
+ cron: string
72
+ }
53
73
 
54
74
  /**
55
75
  * Actions that can move an armed trigger. Only these re-arm the native timer;
@@ -102,6 +122,12 @@ export class TaskBoardHostService {
102
122
  * conversation instead of prompting into a session it cannot see.
103
123
  */
104
124
  private idleSessionIds: ReadonlySet<string> | undefined
125
+ /**
126
+ * Consecutive unreadable-history polls per open execution (see
127
+ * {@link noteUnreadableInspection}). Cleared as soon as an inspection
128
+ * resolves, so a transient reader failure never fails a card.
129
+ */
130
+ private readonly unreadablePolls = new Map<string, number>()
105
131
  private preventIdleSleep = false
106
132
  private readonly team: TaskBoardTeamDispatcher | undefined
107
133
  private readonly timers: HostTimerFace
@@ -239,7 +265,7 @@ export class TaskBoardHostService {
239
265
  this.listeners.clear()
240
266
  }
241
267
 
242
- private async launch(opened: OpenedRun, others: readonly OpenedRun[] = []): Promise<void> {
268
+ private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
243
269
  try {
244
270
  // A team run always mints a fresh Lead session: teammates are immutable
245
271
  // children of that session, so reusing an older one would collide on
@@ -248,13 +274,17 @@ export class TaskBoardHostService {
248
274
  const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
249
275
  // Both modes tell the launched agent what else this run opens; only a team
250
276
  // run names teammates, because only then does this session own them.
251
- const promptContext = others.length === 0 ? undefined : {
252
- peers: others.map(other => ({
253
- id: other.task.id,
254
- title: other.task.title,
255
- ...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id) } : {}),
256
- })),
277
+ const peers = others.length === 0 ? undefined : others.map(other => ({
278
+ id: other.task.id,
279
+ title: other.task.title,
280
+ ...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id, other.task.id) } : {}),
281
+ }))
282
+ // A cron-triggered run additionally states its own firing instant and
283
+ // rule zone, so a scheduled job can resolve "today" without guessing.
284
+ const promptContext = peers === undefined && schedule === undefined ? undefined : {
285
+ ...(peers === undefined ? {} : { peers }),
257
286
  ...(team ? { team: true } : {}),
287
+ ...(schedule === undefined ? {} : { schedule }),
258
288
  }
259
289
  const sessionId = await this.runner.launch(opened.task, {
260
290
  ...(reuseSessionId === undefined ? {} : { reuseSessionId }),
@@ -292,7 +322,7 @@ export class TaskBoardHostService {
292
322
  try {
293
323
  const member = await team.spawn({
294
324
  leadSessionId,
295
- name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
325
+ name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id, opened.task.id),
296
326
  description: opened.task.title,
297
327
  prompt: promptText(opened.task),
298
328
  })
@@ -321,6 +351,10 @@ export class TaskBoardHostService {
321
351
  return
322
352
  }
323
353
  this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
354
+ // Fold whatever the board can already decide before spending inspection
355
+ // RPCs: a team run whose Lead recorded its verdict, and any lineage whose
356
+ // members are all settled. Idempotent, so an already folded board is free.
357
+ this.ledger.finalizeReadyRuns()
324
358
  // Read after the RPC so executions attached while it was in flight are
325
359
  // included in this pass, matching the former full-state snapshot timing.
326
360
  const runtime = this.ledger.runtimeView()
@@ -342,13 +376,53 @@ export class TaskBoardHostService {
342
376
  for (const execution of executions) {
343
377
  if (execution.sessionId === undefined) continue
344
378
  try {
345
- const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
346
- if (result.outcome === 'pending') continue
379
+ // A team member's turn is read even while the roster calls its session
380
+ // running: a durable teammate never goes idle for good.
381
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions, {
382
+ whileRunning: execution.teamMember,
383
+ })
384
+ if (result.outcome === 'pending') {
385
+ if (result.unreadable === true) this.noteUnreadableInspection(execution, result.reason)
386
+ else this.unreadablePolls.delete(execution.executionId)
387
+ continue
388
+ }
389
+ this.unreadablePolls.delete(execution.executionId)
347
390
  this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
348
391
  } catch {
349
392
  // A transient inspection failure never settles a running execution.
350
393
  }
351
394
  }
395
+ const open = new Set(executions.map(execution => execution.executionId))
396
+ for (const executionId of [...this.unreadablePolls.keys()]) {
397
+ if (!open.has(executionId)) this.unreadablePolls.delete(executionId)
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Count one poll whose session history could not be read. A reader failure is
403
+ * not progress: the session is not running (the runner only reads history for
404
+ * one that is idle), so nothing will ever change that verdict. After
405
+ * {@link UNREADABLE_SETTLE_POLLS} consecutive polls the execution is reported
406
+ * failed with the recorded reason, instead of holding its card — and every
407
+ * ancestor of it — in the running column with no way out. The first poll of
408
+ * each streak is logged, so the Host log names the session.
409
+ */
410
+ private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
411
+ const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1
412
+ this.unreadablePolls.set(execution.executionId, polls)
413
+ const detail = reason ?? 'no reason reported'
414
+ if (polls === 1) {
415
+ safeConsoleError('[dsh-task-board] execution session ' + (execution.sessionId ?? 'unknown')
416
+ + ' history is unreadable; it stays pending for up to ' + UNREADABLE_SETTLE_POLLS + ' polls: ' + detail)
417
+ }
418
+ if (polls < UNREADABLE_SETTLE_POLLS) return
419
+ this.unreadablePolls.delete(execution.executionId)
420
+ this.ledger.settle(
421
+ execution.taskId,
422
+ execution.executionId,
423
+ 'failed',
424
+ 'execution session history is unreadable (' + UNREADABLE_SETTLE_POLLS + ' consecutive polls); the outcome cannot be determined: ' + detail,
425
+ )
352
426
  }
353
427
 
354
428
  /** Drop the armed schedule timer and forget its target. */
@@ -411,8 +485,11 @@ export class TaskBoardHostService {
411
485
  return
412
486
  }
413
487
  for (const schedule of this.ledger.dueSchedules(now)) {
414
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
415
- this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now))
488
+ const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone)
489
+ this.dispatchRuns(
490
+ this.ledger.openScheduled(schedule.taskId, next, now),
491
+ { triggeredAt: now, timeZone: schedule.timeZone, cron: schedule.cron },
492
+ )
416
493
  }
417
494
  // The launched run (or the rolled-forward target) moved every due schedule,
418
495
  // so the next nearest target has to be recomputed from the ledger.
@@ -430,18 +507,18 @@ export class TaskBoardHostService {
430
507
  * others back. A team run's members are spawned inside the root's Lead
431
508
  * session instead, once that session exists.
432
509
  */
433
- private dispatchRuns(runs: readonly OpenedRun[]): void {
510
+ private dispatchRuns(runs: readonly OpenedRun[], schedule?: ScheduledRunContext): void {
434
511
  if (runs.length === 0) return
435
512
  const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
436
513
  const others = runs.filter(run => run !== root)
437
- this.scheduleLaunch(root, others)
514
+ this.scheduleLaunch(root, others, schedule)
438
515
  for (const run of others) {
439
- if (run.dispatch !== 'teammate') this.scheduleLaunch(run)
516
+ if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule)
440
517
  }
441
518
  }
442
519
 
443
- private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = []): void {
444
- void this.launch(opened, others).catch(error => {
520
+ private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
521
+ void this.launch(opened, others, schedule).catch(error => {
445
522
  safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
446
523
  })
447
524
  }
package/src/protocol.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
2
2
  import { isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
3
3
  import { parseLedger } from './core/store.ts'
4
+ import { isValidTimeZone } from './core/schedule.ts'
4
5
  import { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
5
6
  import { sanitizeHandover, type TaskHandoverInput } from './core/handover.ts'
6
7
 
7
8
  /** Freeze payload carried by create/update actions after the gate (redacted in place). */
8
9
  type FreezePayload = FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
9
10
 
10
- export const TASK_BOARD_SCHEMA_VERSION = 3 as const
11
- /** Ledger documents written before v3; loaded once and migrated on startup. */
12
- export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 2 as const
11
+ export const TASK_BOARD_SCHEMA_VERSION = 4 as const
12
+ /** Ledger documents written before v4; loaded once and migrated on startup. */
13
+ export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 3 as const
14
+ /** Ledger documents written before v3; migrated through the v3 normalization too. */
15
+ export const TASK_BOARD_OLDER_SCHEMA_VERSION = 2 as const
13
16
  export const TASK_BOARD_API_PREFIX = '/api/task-board'
14
17
 
15
18
  export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
@@ -101,7 +104,8 @@ export type TaskBoardAction =
101
104
  | { kind: 'move'; taskId: string; status: TaskStatus }
102
105
  | { kind: 'archive'; taskId: string }
103
106
  | { kind: 'restore'; taskId: string }
104
- | { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string } }
107
+ | { kind: 'settle'; taskId: string }
108
+ | { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string; timeZone?: string | null } }
105
109
  | { kind: 'run'; taskId: string }
106
110
  | { kind: 'rerun'; taskId: string }
107
111
  | { kind: 'confirm-permission'; taskId: string }
@@ -154,6 +158,8 @@ function validImportedKnownFields(value: Record<string, unknown>): boolean {
154
158
  const schedule = record(value.schedule)
155
159
  if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
156
160
  if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
161
+ if (schedule.timeZone !== undefined
162
+ && (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
157
163
  }
158
164
  if (value.executions !== undefined) {
159
165
  if (!Array.isArray(value.executions)) return false
@@ -199,6 +205,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
199
205
  schedule: {
200
206
  enabled: task.schedule.enabled,
201
207
  cron: task.schedule.cron,
208
+ ...(task.schedule.timeZone === undefined ? {} : { timeZone: task.schedule.timeZone }),
202
209
  nextRunAt: task.schedule.nextRunAt,
203
210
  lastTriggeredAt: task.schedule.lastTriggeredAt,
204
211
  },
@@ -210,6 +217,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
210
217
  ...(task.mode === undefined ? {} : { mode: task.mode }),
211
218
  ...(task.permission === undefined ? {} : { permission: task.permission }),
212
219
  ...(task.reuseSession === undefined ? {} : { reuseSession: task.reuseSession }),
220
+ ...(task.goalRun === undefined ? {} : { goalRun: task.goalRun }),
213
221
  ...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
214
222
  ...(task.freeze === undefined ? {} : { freeze: task.freeze }),
215
223
  ...(task.handover === undefined ? {} : { handover: task.handover }),
@@ -247,30 +255,36 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
247
255
 
248
256
  function createInput(value: unknown): value is NewTaskInput {
249
257
  const input = record(value)
250
- if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
258
+ if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', 'tags'])) return false
251
259
  if (input.parentId !== undefined && (typeof input.parentId !== 'string' || input.parentId.trim() === '')) return false
252
260
  if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
253
261
  if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
254
262
  if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
255
263
  if (input.teamRun !== undefined && typeof input.teamRun !== 'boolean') return false
264
+ if (input.goalRun !== undefined && typeof input.goalRun !== 'boolean') return false
256
265
  if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
257
266
  if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
258
267
  if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
259
268
  if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
260
269
  if (input.schedule !== undefined) {
261
270
  const schedule = record(input.schedule)
262
- if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron'])) return false
271
+ if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron', 'timeZone'])) return false
263
272
  if (typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
273
+ if (schedule.timeZone !== undefined
274
+ && (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
264
275
  }
265
276
  return true
266
277
  }
267
278
 
268
279
  function updatePatch(value: unknown): boolean {
269
280
  const patch = record(value)
270
- if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
281
+ if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', 'tags'])) return false
271
282
  // null (or false) clears the reuse opt-in; only a real boolean is accepted.
272
283
  if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
273
284
  if (patch.teamRun !== undefined && patch.teamRun !== null && typeof patch.teamRun !== 'boolean') return false
285
+ // The goal opt-in is tri-state: null/true return the card to its default
286
+ // (goal run), false pins a single plain turn.
287
+ if (patch.goalRun !== undefined && patch.goalRun !== null && typeof patch.goalRun !== 'boolean') return false
274
288
  for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
275
289
  if (!optionalString(patch[key])) return false
276
290
  }
@@ -286,9 +300,13 @@ function updatePatch(value: unknown): boolean {
286
300
  function schedulePatch(value: unknown): boolean {
287
301
  const patch = record(value)
288
302
  return patch !== undefined
289
- && exactKeys(patch, ['enabled', 'cron'])
303
+ && exactKeys(patch, ['enabled', 'cron', 'timeZone'])
290
304
  && (patch.enabled === undefined || typeof patch.enabled === 'boolean')
291
305
  && (patch.cron === undefined || typeof patch.cron === 'string')
306
+ // `null` clears the stored zone back to the Host zone; an unknown name is
307
+ // rejected here so the Host never has to guess at an unusable zone.
308
+ && (patch.timeZone === undefined || patch.timeZone === null
309
+ || (typeof patch.timeZone === 'string' && isValidTimeZone(patch.timeZone)))
292
310
  }
293
311
 
294
312
  export function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined {
@@ -364,6 +382,7 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
364
382
  case 'delete':
365
383
  case 'archive':
366
384
  case 'restore':
385
+ case 'settle':
367
386
  case 'run':
368
387
  case 'rerun':
369
388
  if (!exactKeys(action, ['kind', 'taskId'])) return undefined
@@ -1,39 +0,0 @@
1
- /**
2
- * Async-boundary guard shared by the plugin family's Host halves: every
3
- * fire-and-forget promise chain and callback the host runtime does not own
4
- * (route handlers, timers, event listeners, spawned work) funnels through
5
- * these helpers. The dsh host installs a process-level fail-loud guard that
6
- * turns ANY unhandled promise rejection into a whole-process exit — one
7
- * plugin's stray rejection would otherwise take every plugin down. These
8
- * helpers exist so that failure mode is structurally impossible in family
9
- * code: the rejection becomes a logged error at the plugin boundary instead.
10
- *
11
- * Complements the aggregate's shell isolation (packages/dsh-web-all): the
12
- * shell contains import/activation failures at boot; runGuarded contains
13
- * run-time failures after activation.
14
- * @module dsh-web-shared/host/run-guarded
15
- */
16
- /**
17
- * Run one async operation, logging (never propagating) a rejection. Use for
18
- * promises whose lifecycle the caller does not await: spawned work, retry
19
- * loops, background flushes. Returns the original promise so callers can
20
- * still chain when they want to.
21
- * @param promise - the work to shield; any thenable.
22
- * @param label - log prefix naming the work site.
23
- * @param log - error sink; defaults to console.error.
24
- * @returns the input promise (rejection already consumed).
25
- */
26
- export declare function runGuarded<T>(promise: PromiseLike<T>, label: string, log?: (error: unknown) => void): PromiseLike<T>;
27
- /**
28
- * Wrap one callback so every invocation is individually guarded: a rejection
29
- * inside one call is logged and swallowed instead of escaping into whatever
30
- * infrastructure invoked the callback (HTTP server, EventEmitter, interval).
31
- * Sync throws are caught identically; a returned promise is replaced by
32
- * `undefined` after guarding (callers that need the original rejection should
33
- * await inside their own try/catch instead).
34
- * @param label - log prefix naming the callback site.
35
- * @param handler - the work to guard.
36
- * @param log - error sink; defaults to console.error.
37
- * @returns a wrapped callback with the same parameter list.
38
- */
39
- export declare function guardedHandler<A extends unknown[]>(label: string, handler: (...args: A) => unknown, log?: (error: unknown) => void): (...args: A) => unknown;