@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.
- package/README.i18n.yaml +2 -2
- package/README.md +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
- package/src/host/run-guarded.ts +0 -76
package/src/host-service.ts
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
private
|
|
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.
|
|
174
|
+
if (this.disposed || this.pollTimer !== undefined) return
|
|
104
175
|
this.syncPowerReasons()
|
|
105
|
-
this.timers.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
this.lastScheduleTick = now
|
|
481
|
+
this.scheduleTarget = undefined
|
|
301
482
|
this.ledger.setScheduler({ lastTickAt: now })
|
|
302
|
-
if (
|
|
303
|
-
this.
|
|
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(
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
9
|
-
*
|
|
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.
|
|
14
|
-
*
|
|
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]?:
|
|
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
|
-
|
|
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
|
|
32
|
-
*
|
|
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
|
-
|
|
101
|
+
const mount = (...args: unknown[]): unknown => {
|
|
39
102
|
const mounted = mountedSet()
|
|
40
|
-
|
|
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
|
-
}
|
|
140
|
+
}
|
|
141
|
+
return mount as T
|
|
48
142
|
}
|