@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.3

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.
@@ -0,0 +1,142 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
3
+ /**
4
+ * Family settings form bound to the profile entry id a Host actually serves.
5
+ *
6
+ * Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
7
+ * a family card knows only its own settings namespace. The family binder
8
+ * (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
9
+ * the other and is the preferred transport; this module is the fallback for a
10
+ * page that serves no binder — the group plugin is not installed, or its client
11
+ * half has not applied yet (the aggregate mounts its children in order, and a
12
+ * child still waiting on its own injects publishes nothing).
13
+ *
14
+ * The shared describe mirror is the only local evidence of which row id this
15
+ * profile serves, but it answers asynchronously: at plugin activation it usually
16
+ * holds nothing yet. A one-shot guess therefore cannot be right for every
17
+ * deployment, and the form is bound once per session:
18
+ *
19
+ * - binding the bare namespace made every save answer `No configurable plugin
20
+ * entry "<namespace>"` on an aggregate install (the namespace is not an entry
21
+ * id there), which the card reported as "the deployment did not accept these
22
+ * values";
23
+ * - binding the aggregate row id would break a standalone install, whose row id
24
+ * is the namespace or the package's own `ui-*` row.
25
+ *
26
+ * So the fallback starts on the most likely row and REBINDS the moment the
27
+ * mirror names one of this package's rows. Until the mirror answers, the form
28
+ * reports the binding it holds (usually `loading`), which the card renders as an
29
+ * unavailable form rather than a doomed one.
30
+ */
31
+ import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
32
+
33
+ /** Options of {@link createServedEntryForm}. */
34
+ export interface ServedEntryFormOptions<T> {
35
+ /** The shared configuration forms service (`ctx.configForms`). */
36
+ forms: ConfigForms
37
+ /**
38
+ * Profile entry ids this package's rows carry, most likely first: the
39
+ * aggregate's generated row, the package's own standalone row, then the bare
40
+ * namespace. The first is the fallback while the mirror has no answer; the
41
+ * last is the answer when the mirror serves none of them.
42
+ */
43
+ entryIds: readonly string[]
44
+ }
45
+
46
+ /** The snapshot a form reports before the Host has answered with this entry. */
47
+ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
48
+ return {
49
+ status: 'loading',
50
+ value: undefined,
51
+ base: undefined,
52
+ user: undefined,
53
+ revision: undefined,
54
+ writable: false,
55
+ mode: 'host',
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Create a settings form bound to the profile entry id this Host serves, and
61
+ * rebound whenever the shared describe mirror names a different one of this
62
+ * package's rows.
63
+ * @param options - the shared forms service and this package's candidate row ids.
64
+ * @returns a form delegating to the currently resolved entry's own form.
65
+ */
66
+ export function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T> {
67
+ const { forms, entryIds } = options
68
+ const fallbackId = entryIds[0]
69
+ const namespaceId = entryIds[entryIds.length - 1]
70
+ const listeners = new Set<() => void>()
71
+ let boundId: string | undefined
72
+ let bound: ConfigForm<T> | undefined
73
+ let offBound: (() => void) | undefined
74
+ let snapshot: ConfigFormSnapshot<T> = pendingSnapshot<T>()
75
+
76
+ /** Republish: the delegated snapshot when one is bound, the pending one otherwise. */
77
+ const publish = (): void => {
78
+ if (bound !== undefined) snapshot = bound.getSnapshot()
79
+ for (const listener of [...listeners]) listener()
80
+ }
81
+
82
+ /**
83
+ * The entry id the mirror currently justifies. An unanswered or empty mirror
84
+ * is not evidence of absence, so it keeps the first candidate; a mirror that
85
+ * answers without any of this package's rows leaves the namespace itself.
86
+ * @returns the entry id to bind.
87
+ */
88
+ const resolve = (): string => {
89
+ let served: readonly string[] | undefined
90
+ try {
91
+ served = forms.describe().getSnapshot().view?.namespaces.map(row => row.ns)
92
+ } catch {
93
+ // A mirror that refuses the read is unanswered too, not absent.
94
+ served = undefined
95
+ }
96
+ if (served === undefined || served.length === 0) return fallbackId
97
+ return entryIds.find(id => served.includes(id)) ?? namespaceId
98
+ }
99
+
100
+ /** Bind (or rebind) the resolved entry's form; a no-op while it is unchanged. */
101
+ const bind = (): void => {
102
+ const target = resolve()
103
+ if (target === boundId) return
104
+ offBound?.()
105
+ offBound = undefined
106
+ let form: ConfigForm<T>
107
+ try {
108
+ form = forms.get<T>(target)
109
+ } catch {
110
+ // A service that refuses the id stays unbound; the next mirror answer
111
+ // retries rather than pinning a wrong entry for the session.
112
+ boundId = undefined
113
+ return
114
+ }
115
+ boundId = target
116
+ bound = form
117
+ offBound = form.subscribe(() => { publish() })
118
+ publish()
119
+ }
120
+
121
+ // The mirror subscription lives for the page: the card owns its form for the
122
+ // session, and the ConfigForm contract carries no disposer to hang it on. The
123
+ // callback is cheap (a resolve plus a no-op while the id is unchanged). A
124
+ // mirror without a subscription seat keeps the initial binding.
125
+ try {
126
+ forms.describe().subscribe(() => { bind() })
127
+ } catch {
128
+ // A describe face that refuses the subscription: the initial binding stands.
129
+ }
130
+ bind()
131
+
132
+ return {
133
+ getSnapshot: () => snapshot,
134
+ subscribe: (listener) => {
135
+ listeners.add(listener)
136
+ return () => { listeners.delete(listener) }
137
+ },
138
+ set: (field, value) => bound?.set(field, value) ?? Promise.resolve(false),
139
+ unset: (field) => bound?.unset(field) ?? Promise.resolve(false),
140
+ mutate: (ops, expectedRevision) => bound?.mutate(ops, expectedRevision) ?? Promise.resolve(false),
141
+ }
142
+ }
@@ -62,6 +62,21 @@ function currentOf(sessions: SessionsControllerFace | undefined): string | undef
62
62
  return sessions?.current()
63
63
  }
64
64
 
65
+ /** Stable id shared by the board's sidebar panel row and its main-slot page. */
66
+ export const TASK_BOARD_PANEL_ID = 'task-board'
67
+
68
+ /**
69
+ * The layout's panel-navigation face. The board no longer owns the center
70
+ * column at the DOM level: selecting the panel is the layout's business, and
71
+ * this is the one call that asks for it (null returns to the conversation).
72
+ * Optional, so a composition without the layout service keeps the board's
73
+ * pure state transitions testable.
74
+ */
75
+ export interface PanelNavigationFace {
76
+ /** Select the board panel, or null to hand the column back to the conversation. */
77
+ select(panelId: string | null): void
78
+ }
79
+
65
80
  /** Controller dependencies (all swappable in tests). */
66
81
  export interface ControllerDeps {
67
82
  store: TaskStore
@@ -72,6 +87,8 @@ export interface ControllerDeps {
72
87
  uuid?: () => string
73
88
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
74
89
  transport?: TaskBoardTransport
90
+ /** Layout panel selection; absent keeps the board state-only (tests, shell-less hosts). */
91
+ panel?: PanelNavigationFace
75
92
  }
76
93
 
77
94
  /**
@@ -253,15 +270,31 @@ export class BoardController {
253
270
 
254
271
  // --- view state -------------------------------------------------------------
255
272
 
273
+ /**
274
+ * Show the board. The layout owns which panel the center column renders, so
275
+ * the state flip and the panel selection travel together here; a composition
276
+ * with no layout face (tests, a shell-less host) still flips the state.
277
+ *
278
+ * The selection is requested AFTER the snapshot flips so a subscriber
279
+ * rendering against `boardOpen` never observes "open" while the shell still
280
+ * shows the conversation.
281
+ */
256
282
  openBoard(): void {
257
283
  if (this.boardOpen) return
258
284
  this.boardOpen = true
259
285
  this.notify()
286
+ this.selectPanel(TASK_BOARD_PANEL_ID)
260
287
  }
261
288
 
289
+ /**
290
+ * Return the center column to the conversation. The layout's own selection is
291
+ * the source of truth for what the column renders, so this asks for the
292
+ * conversation explicitly rather than only clearing local state.
293
+ */
262
294
  closeBoard(): void {
263
295
  this.boardOpen = false
264
296
  this.notify()
297
+ this.selectPanel(null)
265
298
  }
266
299
 
267
300
  toggleBoard(): void {
@@ -269,6 +302,30 @@ export class BoardController {
269
302
  else this.openBoard()
270
303
  }
271
304
 
305
+ /**
306
+ * Reflect a panel selection that came from OUTSIDE this controller (the user
307
+ * clicked another sidebar row, or the layout dropped the panel id). Keeps
308
+ * `boardOpen` aligned with what the column actually shows without asking the
309
+ * layout to select anything back.
310
+ * @param panelId - the layout's current panel id, or null for the conversation.
311
+ */
312
+ syncPanelSelection(panelId: string | null): void {
313
+ const open = panelId === TASK_BOARD_PANEL_ID
314
+ if (open === this.boardOpen) return
315
+ this.boardOpen = open
316
+ this.notify()
317
+ }
318
+
319
+ /** Ask the layout to select a panel; a shell that serves no layout face is a no-op. */
320
+ private selectPanel(panelId: string | null): void {
321
+ try {
322
+ this.deps.panel?.select(panelId)
323
+ } catch {
324
+ // The layout service throws by contract before its root entry mounts;
325
+ // the state flip above already stands, and the next selection retries.
326
+ }
327
+ }
328
+
272
329
  /**
273
330
  * Switch between the kanban columns and the archive view. Leaving the
274
331
  * archive view with an archived task still selected closes the selection —
@@ -547,8 +604,8 @@ export class BoardController {
547
604
  * (background navigation, the Host runner creating and selecting a fresh
548
605
  * execution session, settlement, other plugins), so closing on `current`
549
606
  * changes would evict the board without the user asking. The board closes
550
- * only on explicit user navigation: a sidebar session/workspace row click
551
- * (board-mount onClickSidebarRow) or the board's own actions
607
+ * only on explicit user navigation: selecting another panel (the layout owns
608
+ * selection, and `syncPanelSelection` follows it) or the board's own actions
552
609
  * (openSession / close). Keeping the hook preserves the subscription
553
610
  * contract for future listeners.
554
611
  */
@@ -516,6 +516,26 @@ export class HostTaskLedger {
516
516
  return count
517
517
  }
518
518
 
519
+ /**
520
+ * The earliest armed `nextRunAt` strictly after the supplied Host time, or
521
+ * undefined when no schedule is armed in the future. This is the instant the
522
+ * Host arms its native timer at, so the board wakes exactly when work is due
523
+ * instead of polling a fixed heartbeat.
524
+ * @param now - current Host time in ms epoch.
525
+ * @returns the nearest future trigger, or undefined when none is armed.
526
+ */
527
+ nextArmedRunAt(now: number): number | undefined {
528
+ let nearest: number | undefined
529
+ for (const task of this.document.tasks) {
530
+ if (task.archivedAt !== undefined) continue
531
+ const schedule = task.schedule
532
+ if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined) continue
533
+ if (schedule.nextRunAt <= now) continue
534
+ if (nearest === undefined || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt
535
+ }
536
+ return nearest
537
+ }
538
+
519
539
  /** Return value-only references for schedules due at the supplied Host time. */
520
540
  dueSchedules(now: number): DueScheduleReference[] {
521
541
  const due: DueScheduleReference[] = []
@@ -73,15 +73,51 @@ function resolveAccess(access: TaskBoardRouteAccess): ResolvedRouteAccess {
73
73
  return { trustedProxyHosts, ...(access.proxyToken === undefined ? {} : { proxyToken: access.proxyToken }) }
74
74
  }
75
75
 
76
+ /**
77
+ * Harness browser-auth cookie prefix (dsh-client-connection): the
78
+ * authority-bound signed cookie the Host mints in exchange for its launch
79
+ * token. dsh-web's remote channel redeems that token before re-issuing paired
80
+ * traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
81
+ * official DSH Desktop shell keeps its own copy from the same exchange and
82
+ * attaches it to every request it forwards for the dsh-app://app/ page. The
83
+ * two constants describe one fact and move together.
84
+ */
85
+ const BROWSER_AUTH_COOKIE_PREFIX = 'dsh-auth-'
86
+
87
+ /**
88
+ * Whether a Cookie header carries the Host's browser-auth credential. Only an
89
+ * application on this machine can hold it: the desktop shell never lets it
90
+ * reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
91
+ * from attaching it.
92
+ * @param header - the raw Cookie header value.
93
+ */
94
+ function carriesBrowserAuthCookie(header: string | undefined): boolean {
95
+ if (header === undefined) return false
96
+ return header.split(';').some(segment => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX))
97
+ }
98
+
76
99
  /**
77
100
  * Browser-signal tripwire, NOT an authority check: a bare curl sends neither
78
101
  * header and is refused, but a curl with a forged Origin passes this too.
79
102
  * The real boundary is the loopback socket + Host + origin-equality checks
80
103
  * in isTrustedTaskBoardRequest below; do not rely on this marker alone.
104
+ *
105
+ * A first-party client that presents NEITHER header must still pass. The DSH
106
+ * Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
107
+ * page's Host requests itself, deleting origin and sec-fetch-site on the
108
+ * way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
109
+ * arrives marker-less and every route behind this guard answered 403 - which
110
+ * the panel renders as its board.hostError.unauthorized message. The shell
111
+ * does attach the Host's browser-auth cookie, redeemed from the Host's launch
112
+ * URL at startup and deliberately withheld from the page. That credential,
113
+ * not a header the shell strips, is the browser signal of an application on
114
+ * this machine; a marker-less, credential-less request stays refused.
81
115
  */
82
116
  function browserSameOriginMarker(req: IncomingMessage): boolean {
83
117
  const site = req.headers['sec-fetch-site']
84
- return site === 'same-origin' || typeof req.headers.origin === 'string'
118
+ if (site === 'same-origin') return true
119
+ if (typeof req.headers.origin === 'string') return true
120
+ return carriesBrowserAuthCookie(req.headers.cookie)
85
121
  }
86
122
 
87
123
  function sameAuthority(req: IncomingMessage, host: URL): boolean {
@@ -38,20 +38,62 @@ 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
+ /**
55
+ * Actions that can move an armed trigger. Only these re-arm the native timer;
56
+ * an unrelated card edit leaves the pending fire untouched.
57
+ */
58
+ const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['set-schedule', 'delete', 'archive'])
59
+
60
+ /**
61
+ * The native timer face the Host arms through. The cordis `timer` service
62
+ * (dsh-base's own `cordis-plugin-timer` row) provides it: its handles are
63
+ * registered on the owning fiber, so unloading the board clears every armed
64
+ * timer without this service tracking handles by hand. A composition that
65
+ * serves no timer service falls back to the process globals.
66
+ */
67
+ export interface HostTimerFace {
68
+ timeout(callback: () => void, delay: number): () => void
69
+ interval(callback: () => void, delay: number): () => void
70
+ }
71
+
72
+ /** Process-global fallback used when the deployment serves no cordis timer service. */
73
+ const PROCESS_TIMERS: HostTimerFace = {
74
+ timeout(callback: () => void, delay: number): () => void {
75
+ const handle = setTimeout(callback, delay)
76
+ return () => { clearTimeout(handle) }
77
+ },
78
+ interval(callback: () => void, delay: number): () => void {
79
+ const handle = setInterval(callback, delay)
80
+ return () => { clearInterval(handle) }
81
+ },
82
+ }
44
83
 
45
84
  export class TaskBoardHostService {
46
85
  readonly ledger: HostTaskLedger
47
86
  readonly runner: HostExecutionRunner
48
87
  readonly power: PowerInhibitor
49
88
  private readonly listeners = new Set<() => void>()
50
- private timers: Array<ReturnType<typeof setInterval>> = []
51
- private lastScheduleTick: number | undefined
89
+ /** The one recurring timer: the session-roster poll. */
90
+ private pollTimer: (() => void) | undefined
91
+ /** The armed schedule timer, if a trigger is pending. */
92
+ private scheduleTimer: (() => void) | undefined
93
+ /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
94
+ private scheduleTarget: number | undefined
52
95
  private disposed = false
53
96
  private pollInFlight = false
54
- private tickInFlight = false
55
97
  private active = true
56
98
  /**
57
99
  * Ids the last roster poll saw as present and idle; undefined while the
@@ -62,6 +104,7 @@ export class TaskBoardHostService {
62
104
  private idleSessionIds: ReadonlySet<string> | undefined
63
105
  private preventIdleSleep = false
64
106
  private readonly team: TaskBoardTeamDispatcher | undefined
107
+ private readonly timers: HostTimerFace
65
108
  private lastPowerJson = ''
66
109
  private readonly now: () => number
67
110
 
@@ -74,6 +117,7 @@ export class TaskBoardHostService {
74
117
  sessionDefaultPermission?: TaskPermission
75
118
  maxSubtaskDepth?: number
76
119
  team?: TaskBoardTeamDispatcher
120
+ timers?: HostTimerFace
77
121
  } = {}) {
78
122
  this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
79
123
  sessionDefaultPermission: options.sessionDefaultPermission,
@@ -81,6 +125,7 @@ export class TaskBoardHostService {
81
125
  })
82
126
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
83
127
  this.team = options.team
128
+ this.timers = options.timers ?? PROCESS_TIMERS
84
129
  this.power = options.power ?? new PowerInhibitor()
85
130
  this.now = options.now ?? Date.now
86
131
  installStreamErrorGuards()
@@ -100,12 +145,15 @@ export class TaskBoardHostService {
100
145
  }
101
146
 
102
147
  start(): void {
103
- if (this.disposed || this.timers.length > 0) return
148
+ if (this.disposed || this.pollTimer !== undefined) return
104
149
  this.syncPowerReasons()
105
- this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS))
106
- this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
150
+ this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, SESSION_POLL_MS)
107
151
  this.schedulePoll()
108
- this.scheduleTick(true)
152
+ // Boot is a recovery point: an occurrence armed while the Host was down is
153
+ // not replayed, and each schedule rolls to its next future target. A
154
+ // schedule the Board should have served while running is then armed
155
+ // normally by the timer below.
156
+ this.recoverSchedule()
109
157
  }
110
158
 
111
159
  setConfiguration(active: boolean, preventIdleSleep: boolean): void {
@@ -123,7 +171,11 @@ export class TaskBoardHostService {
123
171
  this.power.setEnabled(active && preventIdleSleep)
124
172
  if (resumed) {
125
173
  this.schedulePoll()
126
- this.scheduleTick(true)
174
+ this.recoverSchedule()
175
+ } else if (!active) {
176
+ // A disabled board holds no timer: its schedules must not fire while the
177
+ // master switch is off.
178
+ this.clearScheduleTimer()
127
179
  }
128
180
  this.emit()
129
181
  }
@@ -164,6 +216,10 @@ export class TaskBoardHostService {
164
216
  }
165
217
  const result = this.ledger.applyRequest(requestId, action, initiator)
166
218
  if (result.runs !== undefined) this.dispatchRuns(result.runs)
219
+ // A committed schedule write (create / update / toggle / delete) moves the
220
+ // nearest trigger; re-arm on every action so a newly enabled schedule fires
221
+ // at its own instant without waiting for the previous target to elapse.
222
+ if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
167
223
  return {
168
224
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
169
225
  revision: result.state.revision,
@@ -175,7 +231,9 @@ export class TaskBoardHostService {
175
231
 
176
232
  dispose(): void {
177
233
  this.disposed = true
178
- for (const timer of this.timers.splice(0)) clearInterval(timer)
234
+ this.clearScheduleTimer()
235
+ this.pollTimer?.()
236
+ this.pollTimer = undefined
179
237
  this.power.dispose()
180
238
  this.ledger.dispose()
181
239
  this.listeners.clear()
@@ -293,20 +351,72 @@ export class TaskBoardHostService {
293
351
  }
294
352
  }
295
353
 
296
- private async tickSchedule(first: boolean): Promise<void> {
354
+ /** Drop the armed schedule timer and forget its target. */
355
+ private clearScheduleTimer(): void {
356
+ this.scheduleTimer?.()
357
+ this.scheduleTimer = undefined
358
+ this.scheduleTarget = undefined
359
+ }
360
+
361
+ /**
362
+ * Boot / resume recovery: skip every occurrence that came due while the
363
+ * board was not running and roll each schedule to its next future target,
364
+ * then arm the timer for the nearest one. Rendering the occurrence is
365
+ * deliberately not attempted: the ACL of a card that fired hours ago is
366
+ * stale, and the board's own recovery contract is "missed triggers are
367
+ * skipped, never replayed".
368
+ */
369
+ private recoverSchedule(): void {
370
+ if (this.disposed) return
371
+ this.clearScheduleTimer()
372
+ const now = this.now()
373
+ this.ledger.setScheduler({ lastTickAt: now })
374
+ this.ledger.skipMissed(now)
375
+ this.armSchedule()
376
+ }
377
+
378
+ /**
379
+ * Arm the native timer at the nearest armed future trigger. One timer serves
380
+ * every schedule: the ledger's next target is the only instant the Host has
381
+ * to wake for. A target beyond the platform's timer ceiling re-arms in
382
+ * segments, and a target already past (the wall clock jumped, or the process
383
+ * was suspended) is handled immediately as a recovery.
384
+ */
385
+ private armSchedule(): void {
386
+ if (this.disposed || !this.active) return
387
+ this.clearScheduleTimer()
388
+ const target = this.ledger.nextArmedRunAt(this.now())
389
+ if (target === undefined) return
390
+ this.scheduleTarget = target
391
+ const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS))
392
+ this.scheduleTimer = this.timers.timeout(() => {
393
+ this.scheduleTimer = undefined
394
+ this.onScheduleFire(target)
395
+ }, delay)
396
+ }
397
+
398
+ /**
399
+ * One armed target became due. A fire landing well past its target is a
400
+ * resume (suspend, throttle, forward clock jump) rather than a normal
401
+ * occurrence, so it takes the recovery path instead of launching a run for a
402
+ * long-stale instant.
403
+ */
404
+ private onScheduleFire(target: number): void {
297
405
  if (this.disposed || !this.active) return
298
406
  const now = this.now()
299
- const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS)
300
- this.lastScheduleTick = now
407
+ this.scheduleTarget = undefined
301
408
  this.ledger.setScheduler({ lastTickAt: now })
302
- if (recovered) {
303
- this.ledger.skipMissed(now)
409
+ if (now - target > RECOVERY_TOLERANCE_MS) {
410
+ this.recoverSchedule()
304
411
  return
305
412
  }
306
413
  for (const schedule of this.ledger.dueSchedules(now)) {
307
414
  const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
308
415
  this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now))
309
416
  }
417
+ // The launched run (or the rolled-forward target) moved every due schedule,
418
+ // so the next nearest target has to be recomputed from the ledger.
419
+ this.armSchedule()
310
420
  }
311
421
 
312
422
  private armedSchedules(): number {
@@ -344,12 +454,18 @@ export class TaskBoardHostService {
344
454
  }).finally(() => { this.pollInFlight = false })
345
455
  }
346
456
 
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 })
457
+ /**
458
+ * Re-arm from the ledger's current targets. Callers that just changed a
459
+ * schedule (the host routes, the agent tools) invoke this after the write
460
+ * commits, so a new or edited trigger arms without waiting for the next fire.
461
+ */
462
+ refreshSchedule(): void {
463
+ if (this.disposed) return
464
+ if (!this.active) {
465
+ this.clearScheduleTimer()
466
+ return
467
+ }
468
+ this.armSchedule()
353
469
  }
354
470
 
355
471
  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) {