@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
@@ -38,20 +38,82 @@ export interface TaskBoardTeamDispatcher {
38
38
  spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>
39
39
  }
40
40
 
41
+ /** Session-roster poll cadence — the one recurring Host timer this service still holds. */
41
42
  const SESSION_POLL_MS = 5_000
42
- const SCHEDULE_TICK_MS = 30_000
43
- const RESUME_GAP_MS = SCHEDULE_TICK_MS + 15_000
43
+ /**
44
+ * How late an armed schedule fire may be before it counts as a resume rather
45
+ * than a normal occurrence. The schedule timer is armed AT the next due
46
+ * instant, so landing this far past its target means the Host was suspended,
47
+ * the process throttled, or the wall clock jumped forward — the same condition
48
+ * the old fixed 30 s heartbeat detected through its own gap threshold.
49
+ */
50
+ const RECOVERY_TOLERANCE_MS = 60_000
51
+ /** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
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
+ }
73
+
74
+ /**
75
+ * Actions that can move an armed trigger. Only these re-arm the native timer;
76
+ * an unrelated card edit leaves the pending fire untouched.
77
+ */
78
+ const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['set-schedule', 'delete', 'archive'])
79
+
80
+ /**
81
+ * The native timer face the Host arms through. The cordis `timer` service
82
+ * (dsh-base's own `cordis-plugin-timer` row) provides it: its handles are
83
+ * registered on the owning fiber, so unloading the board clears every armed
84
+ * timer without this service tracking handles by hand. A composition that
85
+ * serves no timer service falls back to the process globals.
86
+ */
87
+ export interface HostTimerFace {
88
+ timeout(callback: () => void, delay: number): () => void
89
+ interval(callback: () => void, delay: number): () => void
90
+ }
91
+
92
+ /** Process-global fallback used when the deployment serves no cordis timer service. */
93
+ const PROCESS_TIMERS: HostTimerFace = {
94
+ timeout(callback: () => void, delay: number): () => void {
95
+ const handle = setTimeout(callback, delay)
96
+ return () => { clearTimeout(handle) }
97
+ },
98
+ interval(callback: () => void, delay: number): () => void {
99
+ const handle = setInterval(callback, delay)
100
+ return () => { clearInterval(handle) }
101
+ },
102
+ }
44
103
 
45
104
  export class TaskBoardHostService {
46
105
  readonly ledger: HostTaskLedger
47
106
  readonly runner: HostExecutionRunner
48
107
  readonly power: PowerInhibitor
49
108
  private readonly listeners = new Set<() => void>()
50
- private timers: Array<ReturnType<typeof setInterval>> = []
51
- private lastScheduleTick: number | undefined
109
+ /** The one recurring timer: the session-roster poll. */
110
+ private pollTimer: (() => void) | undefined
111
+ /** The armed schedule timer, if a trigger is pending. */
112
+ private scheduleTimer: (() => void) | undefined
113
+ /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
114
+ private scheduleTarget: number | undefined
52
115
  private disposed = false
53
116
  private pollInFlight = false
54
- private tickInFlight = false
55
117
  private active = true
56
118
  /**
57
119
  * Ids the last roster poll saw as present and idle; undefined while the
@@ -60,8 +122,15 @@ export class TaskBoardHostService {
60
122
  * conversation instead of prompting into a session it cannot see.
61
123
  */
62
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>()
63
131
  private preventIdleSleep = false
64
132
  private readonly team: TaskBoardTeamDispatcher | undefined
133
+ private readonly timers: HostTimerFace
65
134
  private lastPowerJson = ''
66
135
  private readonly now: () => number
67
136
 
@@ -74,6 +143,7 @@ export class TaskBoardHostService {
74
143
  sessionDefaultPermission?: TaskPermission
75
144
  maxSubtaskDepth?: number
76
145
  team?: TaskBoardTeamDispatcher
146
+ timers?: HostTimerFace
77
147
  } = {}) {
78
148
  this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
79
149
  sessionDefaultPermission: options.sessionDefaultPermission,
@@ -81,6 +151,7 @@ export class TaskBoardHostService {
81
151
  })
82
152
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
83
153
  this.team = options.team
154
+ this.timers = options.timers ?? PROCESS_TIMERS
84
155
  this.power = options.power ?? new PowerInhibitor()
85
156
  this.now = options.now ?? Date.now
86
157
  installStreamErrorGuards()
@@ -100,12 +171,15 @@ export class TaskBoardHostService {
100
171
  }
101
172
 
102
173
  start(): void {
103
- if (this.disposed || this.timers.length > 0) return
174
+ if (this.disposed || this.pollTimer !== undefined) return
104
175
  this.syncPowerReasons()
105
- this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS))
106
- this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
176
+ this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, SESSION_POLL_MS)
107
177
  this.schedulePoll()
108
- this.scheduleTick(true)
178
+ // Boot is a recovery point: an occurrence armed while the Host was down is
179
+ // not replayed, and each schedule rolls to its next future target. A
180
+ // schedule the Board should have served while running is then armed
181
+ // normally by the timer below.
182
+ this.recoverSchedule()
109
183
  }
110
184
 
111
185
  setConfiguration(active: boolean, preventIdleSleep: boolean): void {
@@ -123,7 +197,11 @@ export class TaskBoardHostService {
123
197
  this.power.setEnabled(active && preventIdleSleep)
124
198
  if (resumed) {
125
199
  this.schedulePoll()
126
- this.scheduleTick(true)
200
+ this.recoverSchedule()
201
+ } else if (!active) {
202
+ // A disabled board holds no timer: its schedules must not fire while the
203
+ // master switch is off.
204
+ this.clearScheduleTimer()
127
205
  }
128
206
  this.emit()
129
207
  }
@@ -164,6 +242,10 @@ export class TaskBoardHostService {
164
242
  }
165
243
  const result = this.ledger.applyRequest(requestId, action, initiator)
166
244
  if (result.runs !== undefined) this.dispatchRuns(result.runs)
245
+ // A committed schedule write (create / update / toggle / delete) moves the
246
+ // nearest trigger; re-arm on every action so a newly enabled schedule fires
247
+ // at its own instant without waiting for the previous target to elapse.
248
+ if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
167
249
  return {
168
250
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
169
251
  revision: result.state.revision,
@@ -175,13 +257,15 @@ export class TaskBoardHostService {
175
257
 
176
258
  dispose(): void {
177
259
  this.disposed = true
178
- for (const timer of this.timers.splice(0)) clearInterval(timer)
260
+ this.clearScheduleTimer()
261
+ this.pollTimer?.()
262
+ this.pollTimer = undefined
179
263
  this.power.dispose()
180
264
  this.ledger.dispose()
181
265
  this.listeners.clear()
182
266
  }
183
267
 
184
- private async launch(opened: OpenedRun, others: readonly OpenedRun[] = []): Promise<void> {
268
+ private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
185
269
  try {
186
270
  // A team run always mints a fresh Lead session: teammates are immutable
187
271
  // children of that session, so reusing an older one would collide on
@@ -190,13 +274,17 @@ export class TaskBoardHostService {
190
274
  const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
191
275
  // Both modes tell the launched agent what else this run opens; only a team
192
276
  // run names teammates, because only then does this session own them.
193
- const promptContext = others.length === 0 ? undefined : {
194
- peers: others.map(other => ({
195
- id: other.task.id,
196
- title: other.task.title,
197
- ...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id) } : {}),
198
- })),
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 }),
199
286
  ...(team ? { team: true } : {}),
287
+ ...(schedule === undefined ? {} : { schedule }),
200
288
  }
201
289
  const sessionId = await this.runner.launch(opened.task, {
202
290
  ...(reuseSessionId === undefined ? {} : { reuseSessionId }),
@@ -234,7 +322,7 @@ export class TaskBoardHostService {
234
322
  try {
235
323
  const member = await team.spawn({
236
324
  leadSessionId,
237
- 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),
238
326
  description: opened.task.title,
239
327
  prompt: promptText(opened.task),
240
328
  })
@@ -263,6 +351,10 @@ export class TaskBoardHostService {
263
351
  return
264
352
  }
265
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()
266
358
  // Read after the RPC so executions attached while it was in flight are
267
359
  // included in this pass, matching the former full-state snapshot timing.
268
360
  const runtime = this.ledger.runtimeView()
@@ -284,29 +376,124 @@ export class TaskBoardHostService {
284
376
  for (const execution of executions) {
285
377
  if (execution.sessionId === undefined) continue
286
378
  try {
287
- const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
288
- 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)
289
390
  this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
290
391
  } catch {
291
392
  // A transient inspection failure never settles a running execution.
292
393
  }
293
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
+ }
294
399
  }
295
400
 
296
- private async tickSchedule(first: boolean): Promise<void> {
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
+ )
426
+ }
427
+
428
+ /** Drop the armed schedule timer and forget its target. */
429
+ private clearScheduleTimer(): void {
430
+ this.scheduleTimer?.()
431
+ this.scheduleTimer = undefined
432
+ this.scheduleTarget = undefined
433
+ }
434
+
435
+ /**
436
+ * Boot / resume recovery: skip every occurrence that came due while the
437
+ * board was not running and roll each schedule to its next future target,
438
+ * then arm the timer for the nearest one. Rendering the occurrence is
439
+ * deliberately not attempted: the ACL of a card that fired hours ago is
440
+ * stale, and the board's own recovery contract is "missed triggers are
441
+ * skipped, never replayed".
442
+ */
443
+ private recoverSchedule(): void {
444
+ if (this.disposed) return
445
+ this.clearScheduleTimer()
446
+ const now = this.now()
447
+ this.ledger.setScheduler({ lastTickAt: now })
448
+ this.ledger.skipMissed(now)
449
+ this.armSchedule()
450
+ }
451
+
452
+ /**
453
+ * Arm the native timer at the nearest armed future trigger. One timer serves
454
+ * every schedule: the ledger's next target is the only instant the Host has
455
+ * to wake for. A target beyond the platform's timer ceiling re-arms in
456
+ * segments, and a target already past (the wall clock jumped, or the process
457
+ * was suspended) is handled immediately as a recovery.
458
+ */
459
+ private armSchedule(): void {
460
+ if (this.disposed || !this.active) return
461
+ this.clearScheduleTimer()
462
+ const target = this.ledger.nextArmedRunAt(this.now())
463
+ if (target === undefined) return
464
+ this.scheduleTarget = target
465
+ const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS))
466
+ this.scheduleTimer = this.timers.timeout(() => {
467
+ this.scheduleTimer = undefined
468
+ this.onScheduleFire(target)
469
+ }, delay)
470
+ }
471
+
472
+ /**
473
+ * One armed target became due. A fire landing well past its target is a
474
+ * resume (suspend, throttle, forward clock jump) rather than a normal
475
+ * occurrence, so it takes the recovery path instead of launching a run for a
476
+ * long-stale instant.
477
+ */
478
+ private onScheduleFire(target: number): void {
297
479
  if (this.disposed || !this.active) return
298
480
  const now = this.now()
299
- const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS)
300
- this.lastScheduleTick = now
481
+ this.scheduleTarget = undefined
301
482
  this.ledger.setScheduler({ lastTickAt: now })
302
- if (recovered) {
303
- this.ledger.skipMissed(now)
483
+ if (now - target > RECOVERY_TOLERANCE_MS) {
484
+ this.recoverSchedule()
304
485
  return
305
486
  }
306
487
  for (const schedule of this.ledger.dueSchedules(now)) {
307
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
308
- 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
+ )
309
493
  }
494
+ // The launched run (or the rolled-forward target) moved every due schedule,
495
+ // so the next nearest target has to be recomputed from the ledger.
496
+ this.armSchedule()
310
497
  }
311
498
 
312
499
  private armedSchedules(): number {
@@ -320,18 +507,18 @@ export class TaskBoardHostService {
320
507
  * others back. A team run's members are spawned inside the root's Lead
321
508
  * session instead, once that session exists.
322
509
  */
323
- private dispatchRuns(runs: readonly OpenedRun[]): void {
510
+ private dispatchRuns(runs: readonly OpenedRun[], schedule?: ScheduledRunContext): void {
324
511
  if (runs.length === 0) return
325
512
  const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
326
513
  const others = runs.filter(run => run !== root)
327
- this.scheduleLaunch(root, others)
514
+ this.scheduleLaunch(root, others, schedule)
328
515
  for (const run of others) {
329
- if (run.dispatch !== 'teammate') this.scheduleLaunch(run)
516
+ if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule)
330
517
  }
331
518
  }
332
519
 
333
- private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = []): void {
334
- 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 => {
335
522
  safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
336
523
  })
337
524
  }
@@ -344,12 +531,18 @@ export class TaskBoardHostService {
344
531
  }).finally(() => { this.pollInFlight = false })
345
532
  }
346
533
 
347
- private scheduleTick(first: boolean): void {
348
- if (this.tickInFlight || this.disposed) return
349
- this.tickInFlight = true
350
- void this.tickSchedule(first).catch(error => {
351
- safeConsoleError('[dsh-task-board] scheduler tick failed', error)
352
- }).finally(() => { this.tickInFlight = false })
534
+ /**
535
+ * Re-arm from the ledger's current targets. Callers that just changed a
536
+ * schedule (the host routes, the agent tools) invoke this after the write
537
+ * commits, so a new or edited trigger arms without waiting for the next fire.
538
+ */
539
+ refreshSchedule(): void {
540
+ if (this.disposed) return
541
+ if (!this.active) {
542
+ this.clearScheduleTimer()
543
+ return
544
+ }
545
+ this.armSchedule()
353
546
  }
354
547
 
355
548
  private syncPowerReasons(): void {
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
17
17
  import type {} from '@deepseek-ai/dsh-tools'
18
18
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
19
19
  import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
20
- import { TaskBoardHostService, type TaskBoardTeamDispatcher } from './host-service.ts'
20
+ import { TaskBoardHostService, type HostTimerFace, type TaskBoardTeamDispatcher } from './host-service.ts'
21
21
  import { parseTaskDraft, TaskParseError } from './host-ai.ts'
22
22
  import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts'
23
23
  import { DEFAULT_SUBTASK_DEPTH, SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from './core/subtask.ts'
@@ -179,6 +179,27 @@ export function resolveToolRegistry(ctx: Context): AgentToolRegistry | undefined
179
179
  }
180
180
  }
181
181
 
182
+ /**
183
+ * Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
184
+ * row). The board arms its schedule through it so unloading the row clears
185
+ * every pending timer with the owning fiber, instead of the row tracking raw
186
+ * process handles by hand. A composition that serves no timer service keeps
187
+ * working on the process globals.
188
+ * @param ctx - the plugin context.
189
+ * @returns the timer face, or undefined when this deployment serves none.
190
+ */
191
+ export function resolveHostTimers(ctx: Context): HostTimerFace | undefined {
192
+ try {
193
+ const timer = ctx.get('timer') as Partial<HostTimerFace> | undefined
194
+ if (timer === undefined) return undefined
195
+ return typeof timer.timeout === 'function' && typeof timer.interval === 'function'
196
+ ? { timeout: timer.timeout.bind(timer), interval: timer.interval.bind(timer) }
197
+ : undefined
198
+ } catch {
199
+ return undefined
200
+ }
201
+ }
202
+
182
203
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
183
204
  const DEFAULT_ANNOUNCE = false
184
205
 
@@ -301,6 +322,7 @@ function applyImpl(ctx: Context, config?: Config): void {
301
322
  workspaceRegistry: ctx.workspaceRegistry,
302
323
  sessionDefaultPermission: config?.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION,
303
324
  maxSubtaskDepth: maxSubtaskDepth(),
325
+ timers: resolveHostTimers(ctx),
304
326
  team: buildTeamDispatcher(ctx, config?.teamProvider ?? DEFAULT_TEAM_PROVIDER),
305
327
  commandDispatcher: {
306
328
  async execute(sessionId, line, signal) {
package/src/mount-once.ts CHANGED
@@ -5,44 +5,138 @@
5
5
  * the loader accepts a standalone install of the same package side by side;
6
6
  * without this guard the second instance would still re-register the same
7
7
  * webserver routes, tools, settings namespaces, and system-prompt sections
8
- * and fail the boot. mountOnce makes the second host apply a no-op for the
9
- * lifetime of the first instance (the browser half is already deduped by
10
- * package name in the client module host).
8
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
9
+ * no-op for as long as the first instance lives (the browser half is already
10
+ * deduped by package name in the client module host).
11
+ *
12
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
13
+ * long after the refused mount ran its course: the Host reloads a profile by
14
+ * creating the new loader entries before the old ones are torn down (a
15
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
16
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
17
+ * previous entry still owns the name. Dropping that refused mount lost the
18
+ * plugin for good - the previous entry then disposed its own mount, releasing
19
+ * the name with nobody left to take it, and the family row stayed listed as
20
+ * active while its host routes 404ed (the task-board panel showed
21
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
22
+ * restart recovered it).
23
+ *
24
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
25
+ * the holder releases the name - if the waiting fiber is still alive then. The
26
+ * single-instance guarantee is unchanged: exactly one mount is live per
27
+ * package name, and the replay re-enters the guard so a later mount still
28
+ * dedupes against it.
11
29
  *
12
30
  * The registry rides a global symbol so two module instances of the same
13
- * package (npm copy vs repository link) still share one verdict. cordis
14
- * `ctx.effect` runs its callback immediately and treats the callback's
31
+ * package (npm copy vs repository link) still share one verdict. That symbol
32
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
33
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
34
+ * dsh-community-plugins) are separate repositories carrying their own copy of
35
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
36
+ * must keep the shape every published copy reads (a `Set` of package names
37
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
38
+ * under their own additive key, and the registry reads back a `Set` even when
39
+ * some other build left a different value there. Changing `MOUNTED`'s shape
40
+ * in place broke that contract once: a satellite's legacy copy created a
41
+ * `Set`, the family's new copy read it as a `Map`, and every family row
42
+ * mounted after it failed with "claims.get is not a function".
43
+ *
44
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
15
45
  * return value as the fiber disposer, so the unmarker is returned, not run.
16
46
  */
17
47
 
48
+ /** Published cross-repository contract: package names currently mounted. */
18
49
  const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
19
50
 
51
+ /** Additive key this guard owns: refused mounts waiting for the name. */
52
+ const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
53
+
54
+ /** The slice of a cordis context this guard touches. */
55
+ interface MountContext {
56
+ effect?: (effect: () => unknown) => unknown
57
+ }
58
+
59
+ /** One mount that was refused while another fiber owned the package name. */
60
+ interface PendingMount {
61
+ /** Replay the refused mount; a no-op once its own fiber has been disposed. */
62
+ run(): void
63
+ }
64
+
20
65
  interface MountRegistry {
21
- [MOUNTED]?: Set<string>
66
+ [MOUNTED]?: unknown
67
+ [WAITERS]?: Map<string, PendingMount[]>
22
68
  }
23
69
 
70
+ /**
71
+ * The shared name registry, always a `Set` whatever another build stored here:
72
+ * a foreign value (an interim shape, a hand-written global) must not take every
73
+ * family plugin down with it.
74
+ * @returns the process-wide set of mounted package names.
75
+ */
24
76
  function mountedSet(): Set<string> {
25
77
  const registry = globalThis as MountRegistry
26
- return (registry[MOUNTED] ??= new Set())
78
+ const existing = registry[MOUNTED]
79
+ if (existing instanceof Set) return existing as Set<string>
80
+ const created = new Set<string>()
81
+ registry[MOUNTED] = created
82
+ return created
83
+ }
84
+
85
+ /** Queue per package name for mounts refused while a holder was alive. */
86
+ function mountWaiters(): Map<string, PendingMount[]> {
87
+ const registry = globalThis as MountRegistry
88
+ return (registry[WAITERS] ??= new Map())
27
89
  }
28
90
 
29
91
  /**
30
92
  * Wrap a cordis plugin apply so the package runs at most once per process.
31
- * The first mount registers normally and unmarks when its fiber disposes;
32
- * any later mount of the same package name is a no-op.
93
+ * The first mount registers normally and releases the name when its fiber
94
+ * disposes; a mount refused while that name is held waits for the release and
95
+ * then runs, unless its own fiber disposes first.
33
96
  * @param packageName - npm package identity shared by every install source.
34
97
  * @param fn - the original plugin apply.
35
98
  * @returns an apply of the same shape.
36
99
  */
37
100
  export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
- return ((...args: unknown[]) => {
101
+ const mount = (...args: unknown[]): unknown => {
39
102
  const mounted = mountedSet()
40
- if (mounted.has(packageName)) return
103
+ const ctx = args[0] as MountContext | undefined
104
+ if (mounted.has(packageName)) {
105
+ // Another fiber (the aggregate row, a standalone install, or a copy built
106
+ // from an older revision of this guard) owns the name. Queue instead of
107
+ // dropping: dropping lost the plugin for the rest of the process when the
108
+ // holder was disposed by a reload.
109
+ const waiters = mountWaiters()
110
+ const queue = waiters.get(packageName) ?? []
111
+ let alive = true
112
+ const pending: PendingMount = {
113
+ run: () => {
114
+ if (alive) mount(...args)
115
+ },
116
+ }
117
+ // A fiber that dies while it waits must not be replayed afterwards: its
118
+ // context is disposed and registering effects on it would throw.
119
+ ctx?.effect?.(() => () => {
120
+ alive = false
121
+ const index = queue.indexOf(pending)
122
+ if (index >= 0) queue.splice(index, 1)
123
+ })
124
+ queue.push(pending)
125
+ waiters.set(packageName, queue)
126
+ return
127
+ }
41
128
  mounted.add(packageName)
42
- const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
129
  ctx?.effect?.(() => () => {
44
130
  mounted.delete(packageName)
131
+ const waiters = mountWaiters()
132
+ const queue = waiters.get(packageName)
133
+ if (queue === undefined) return
134
+ waiters.delete(packageName)
135
+ // Defer the handover one microtask so the disposing mount's own effects
136
+ // (routes, ledger, timers) are torn down before the next one registers.
137
+ for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
45
138
  })
46
139
  return fn(...args)
47
- }) as T
140
+ }
141
+ return mount as T
48
142
  }