dsh-taskboard 0.1.2 → 0.2.1
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.md +35 -4
- package/lib/client.js +743 -142
- package/lib/host/execution.js +179 -35
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +30 -3
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +10 -1
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/store.js +31 -18
- package/lib/host/store.js.map +1 -1
- package/lib/host/tools.js +14 -4
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +19 -4
- package/lib/index.js.map +1 -1
- package/lib/shared/protocol.js +53 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +3 -0
- package/src/client/board/TaskBoard.tsx +95 -13
- package/src/client/board/TaskCard.tsx +6 -3
- package/src/client/board/TaskDetail.tsx +81 -7
- package/src/client/board/TaskFormModal.tsx +1 -1
- package/src/client/controller.ts +163 -4
- package/src/client/index.ts +10 -0
- package/src/client/session-jump.ts +93 -0
- package/src/client/sidebar-entry.ts +98 -1
- package/src/client/styles.ts +56 -2
- package/src/host/execution.ts +223 -29
- package/src/host/routes.ts +38 -11
- package/src/host/scheduler.ts +20 -4
- package/src/host/store.ts +34 -13
- package/src/host/tools.ts +30 -8
- package/src/index.ts +27 -3
- package/src/shared/protocol.ts +72 -3
- package/src/shared/version.ts +9 -0
package/src/host/execution.ts
CHANGED
|
@@ -10,10 +10,20 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @module dsh-taskboard/host/execution
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
effectivePrompt,
|
|
15
|
+
newCommentId,
|
|
16
|
+
newExecutionId,
|
|
17
|
+
normalizeBody,
|
|
18
|
+
type ExecutionRecord,
|
|
19
|
+
type TaskRecord,
|
|
20
|
+
} from '../shared/protocol.ts'
|
|
14
21
|
import { MessageId } from './sdk.ts'
|
|
15
22
|
import type { TaskStore } from './store.ts'
|
|
16
23
|
|
|
24
|
+
/** Default cap on concurrently running executions (env-overridable). */
|
|
25
|
+
export const DEFAULT_MAX_CONCURRENT = 3
|
|
26
|
+
|
|
17
27
|
/** Narrow agents face (the registry's create, structurally). */
|
|
18
28
|
export interface AgentsFace {
|
|
19
29
|
create(options: {
|
|
@@ -24,6 +34,7 @@ export interface AgentsFace {
|
|
|
24
34
|
agent: {
|
|
25
35
|
id: string
|
|
26
36
|
followup(message: unknown): void
|
|
37
|
+
inject(message: unknown): void
|
|
27
38
|
whenIdle(): Promise<void>
|
|
28
39
|
}
|
|
29
40
|
dispose(): Promise<void>
|
|
@@ -56,6 +67,8 @@ export interface ExecutionDeps {
|
|
|
56
67
|
mintMessageId?: () => string
|
|
57
68
|
/** Best-effort session rename (pins the session list title to the task title). */
|
|
58
69
|
renameSession?: (sessionId: string, title: string) => void
|
|
70
|
+
/** Max concurrently running executions across all tasks (default 3). */
|
|
71
|
+
maxConcurrent?: number
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
/** Outcome of a run request (immediate; the run settles asynchronously). */
|
|
@@ -63,6 +76,11 @@ export type RunRequestResult =
|
|
|
63
76
|
| { ok: true; executionId: string; sessionId: string }
|
|
64
77
|
| { ok: false; error: string }
|
|
65
78
|
|
|
79
|
+
/** Outcome of a cancel request. */
|
|
80
|
+
export type CancelRequestResult =
|
|
81
|
+
| { ok: true; executionId: string }
|
|
82
|
+
| { ok: false; error: string }
|
|
83
|
+
|
|
66
84
|
/** Whether a turn/end payload closed with an error reason. */
|
|
67
85
|
function isErrorTurnEnd(data: unknown): { message: string } | undefined {
|
|
68
86
|
if (typeof data !== 'object' || data === null) return undefined
|
|
@@ -78,12 +96,19 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
|
|
|
78
96
|
return { message }
|
|
79
97
|
}
|
|
80
98
|
|
|
99
|
+
/** One live execution tracked for settlement and cancellation. */
|
|
100
|
+
interface RunEntry {
|
|
101
|
+
sessionId: string
|
|
102
|
+
settle: () => void
|
|
103
|
+
dispose: () => Promise<void>
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
/**
|
|
82
107
|
* The execution service.
|
|
83
108
|
*/
|
|
84
109
|
export class ExecutionService {
|
|
85
|
-
/**
|
|
86
|
-
private readonly
|
|
110
|
+
/** Live executions by execution id (settles and cancels remove entries). */
|
|
111
|
+
private readonly runs = new Map<string, RunEntry>()
|
|
87
112
|
|
|
88
113
|
/** @param deps - store + agents + workspaces + events + clock. */
|
|
89
114
|
constructor(private readonly deps: ExecutionDeps) {
|
|
@@ -94,7 +119,7 @@ export class ExecutionService {
|
|
|
94
119
|
})
|
|
95
120
|
}
|
|
96
121
|
|
|
97
|
-
/** Record a turn failure against the running execution of that session. */
|
|
122
|
+
/** Record a turn failure against the running execution of that session and give the task back. */
|
|
98
123
|
private noteFailure(sessionId: string, message: string): void {
|
|
99
124
|
void this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
100
125
|
for (const task of ledger.tasks) {
|
|
@@ -103,6 +128,21 @@ export class ExecutionService {
|
|
|
103
128
|
execution.outcome = 'failed'
|
|
104
129
|
execution.error = message.slice(0, 500)
|
|
105
130
|
execution.endedAt = this.deps.now()
|
|
131
|
+
// The failed session will not finish the work: hand the task back
|
|
132
|
+
// instead of leaving it stuck in in_progress forever — and leave a
|
|
133
|
+
// system comment so the GUI shows why.
|
|
134
|
+
if (task.status === 'in_progress' && task.claimedBy === sessionId) {
|
|
135
|
+
task.status = 'todo'
|
|
136
|
+
task.updatedAt = this.deps.now()
|
|
137
|
+
delete task.claimedBy
|
|
138
|
+
delete task.claimedAt
|
|
139
|
+
task.comments.push({
|
|
140
|
+
id: newCommentId(),
|
|
141
|
+
body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
|
|
142
|
+
version: 1,
|
|
143
|
+
createdAt: this.deps.now(),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
106
146
|
return [task]
|
|
107
147
|
}
|
|
108
148
|
}
|
|
@@ -127,18 +167,24 @@ export class ExecutionService {
|
|
|
127
167
|
|
|
128
168
|
/**
|
|
129
169
|
* Run one task now (manual button or scheduler tick).
|
|
170
|
+
*
|
|
171
|
+
* The in-progress gate and the execution-open write happen inside ONE
|
|
172
|
+
* serial-queue mutation, so two overlapping run() calls (double click,
|
|
173
|
+
* overlapping scheduler ticks) can never both pass — exactly one session
|
|
174
|
+
* is opened per task.
|
|
130
175
|
* @param taskId - the task to run.
|
|
131
176
|
* @param trigger - what started it.
|
|
132
177
|
* @returns the immediate result; settlement lands in the ledger.
|
|
133
178
|
*/
|
|
134
179
|
async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
|
|
180
|
+
const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
|
|
181
|
+
if (this.runs.size >= max) {
|
|
182
|
+
return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }
|
|
183
|
+
}
|
|
135
184
|
const task = this.deps.store.get(taskId)
|
|
136
185
|
if (task === undefined || task.trashedAt !== undefined) {
|
|
137
186
|
return { ok: false, error: `no task ${taskId}` }
|
|
138
187
|
}
|
|
139
|
-
if (task.status === 'in_progress') {
|
|
140
|
-
return { ok: false, error: 'task is already in progress' }
|
|
141
|
-
}
|
|
142
188
|
const workspace = this.deps.workspaces.get(task.workspaceId)
|
|
143
189
|
if (workspace === undefined) {
|
|
144
190
|
return { ok: false, error: `unknown workspace ${task.workspaceId}` }
|
|
@@ -147,10 +193,19 @@ export class ExecutionService {
|
|
|
147
193
|
const executionId = newExecutionId()
|
|
148
194
|
const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
|
|
149
195
|
|
|
150
|
-
// 1. Open the execution record
|
|
196
|
+
// 1. Open the execution record, flip the card to in_progress, and record
|
|
197
|
+
// the executing session as the claim holder — atomically.
|
|
198
|
+
let gate: string | undefined
|
|
151
199
|
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
152
200
|
const target = ledger.tasks.find(t => t.id === taskId)
|
|
153
|
-
if (target === undefined
|
|
201
|
+
if (target === undefined || target.trashedAt !== undefined) {
|
|
202
|
+
gate = `no task ${taskId}`
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
if (target.status === 'in_progress') {
|
|
206
|
+
gate = 'task is already in progress'
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
154
209
|
target.executions.push({
|
|
155
210
|
id: executionId,
|
|
156
211
|
trigger,
|
|
@@ -160,8 +215,11 @@ export class ExecutionService {
|
|
|
160
215
|
target.status = 'in_progress'
|
|
161
216
|
target.updatedAt = this.deps.now()
|
|
162
217
|
target.updatedBy = { kind: 'user' }
|
|
218
|
+
target.claimedBy = sessionId
|
|
219
|
+
target.claimedAt = this.deps.now()
|
|
163
220
|
return [target]
|
|
164
221
|
})
|
|
222
|
+
if (gate !== undefined) return { ok: false, error: gate }
|
|
165
223
|
|
|
166
224
|
// 2. Create the fresh agent+session inside the task's project, carrying
|
|
167
225
|
// the pinned model — or the deployment default when unpinned (the
|
|
@@ -194,34 +252,65 @@ export class ExecutionService {
|
|
|
194
252
|
// 4. Record the session id (execution is really started now).
|
|
195
253
|
await this.patchExecution(executionId, { sessionId })
|
|
196
254
|
|
|
197
|
-
// 5. Submit the
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
255
|
+
// 5. Submit the opening pair and settle on quiescence (turn/end errors
|
|
256
|
+
// were already folded by the listener). Two messages, ONE turn:
|
|
257
|
+
// - inject() queues the plugin framing line (next-step, no wake); it
|
|
258
|
+
// renders as a plugin context row in the conversation.
|
|
259
|
+
// - followup() queues the card body as a normal user message
|
|
260
|
+
// (next-turn, wakes the driver). At claim time the loop drains ALL
|
|
261
|
+
// next-step messages plus the one next-turn message into a single
|
|
262
|
+
// turn — framing first, then the user bubble.
|
|
263
|
+
handle.agent.inject({
|
|
264
|
+
id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
|
|
265
|
+
role: 'user' as const,
|
|
266
|
+
content: [{ type: 'text' as const, text: this.pluginFraming(task) }],
|
|
267
|
+
source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },
|
|
268
|
+
})
|
|
269
|
+
handle.agent.followup({
|
|
202
270
|
id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
|
|
203
271
|
role: 'user' as const,
|
|
204
|
-
content: [{ type: 'text' as const, text: this.
|
|
272
|
+
content: [{ type: 'text' as const, text: this.userBody(task) }],
|
|
205
273
|
source: { kind: 'user' as const },
|
|
206
|
-
}
|
|
207
|
-
handle.agent.followup(message)
|
|
274
|
+
})
|
|
208
275
|
|
|
209
|
-
// 6. Settlement watcher
|
|
276
|
+
// 6. Settlement watcher: mark succeeded, release the executing session's
|
|
277
|
+
// hold, and — when the session did NOT follow the handoff protocol —
|
|
278
|
+
// auto-move the card to in_review with a system comment (otherwise a
|
|
279
|
+
// disobedient session would leave it hanging in in_progress forever).
|
|
210
280
|
const settle = (): void => {
|
|
211
|
-
this.
|
|
281
|
+
this.runs.delete(executionId)
|
|
212
282
|
void this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
213
283
|
for (const t of ledger.tasks) {
|
|
214
284
|
const execution = t.executions.find(e => e.id === executionId)
|
|
215
285
|
if (execution !== undefined && execution.outcome === 'running') {
|
|
286
|
+
const now = this.deps.now()
|
|
216
287
|
execution.outcome = 'succeeded'
|
|
217
|
-
execution.endedAt =
|
|
288
|
+
execution.endedAt = now
|
|
289
|
+
if (t.status === 'in_progress' && t.claimedBy === sessionId) {
|
|
290
|
+
delete t.claimedBy
|
|
291
|
+
delete t.claimedAt
|
|
292
|
+
}
|
|
293
|
+
if (t.status === 'in_progress') {
|
|
294
|
+
const commented = t.comments.some(c => c.threadId === sessionId)
|
|
295
|
+
t.comments.push({
|
|
296
|
+
id: newCommentId(),
|
|
297
|
+
body: normalizeBody(commented
|
|
298
|
+
? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
|
|
299
|
+
: '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
|
|
300
|
+
version: 1,
|
|
301
|
+
createdAt: now,
|
|
302
|
+
})
|
|
303
|
+
t.status = 'in_review'
|
|
304
|
+
t.updatedAt = now
|
|
305
|
+
t.updatedBy = { kind: 'user' }
|
|
306
|
+
}
|
|
218
307
|
return [t]
|
|
219
308
|
}
|
|
220
309
|
}
|
|
221
310
|
return undefined
|
|
222
311
|
})
|
|
223
312
|
}
|
|
224
|
-
this.
|
|
313
|
+
this.runs.set(executionId, { sessionId, settle, dispose: () => handle.dispose() })
|
|
225
314
|
void handle.agent.whenIdle().then(settle, () => {
|
|
226
315
|
this.noteFailure(sessionId, 'agent did not reach quiescence')
|
|
227
316
|
settle()
|
|
@@ -230,22 +319,127 @@ export class ExecutionService {
|
|
|
230
319
|
return { ok: true, executionId, sessionId }
|
|
231
320
|
}
|
|
232
321
|
|
|
233
|
-
/**
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
322
|
+
/** How many executions are currently running (for the concurrency cap). */
|
|
323
|
+
inFlight(): number {
|
|
324
|
+
return this.runs.size
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Cancel the running execution of a task (user action): stop the agent
|
|
329
|
+
* session, mark the execution cancelled, and hand the task back to todo.
|
|
330
|
+
* @param taskId - the task whose execution should be stopped.
|
|
331
|
+
* @returns the immediate result.
|
|
332
|
+
*/
|
|
333
|
+
async cancel(taskId: string): Promise<CancelRequestResult> {
|
|
334
|
+
const task = this.deps.store.get(taskId)
|
|
335
|
+
if (task === undefined) return { ok: false, error: `no task ${taskId}` }
|
|
336
|
+
const running = [...task.executions].reverse().find(e => e.outcome === 'running')
|
|
337
|
+
if (running === undefined) return { ok: false, error: 'no running execution' }
|
|
338
|
+
|
|
339
|
+
const entry = this.runs.get(running.id)
|
|
340
|
+
this.runs.delete(running.id)
|
|
341
|
+
// Stop the agent first (best effort): dispose stops the loop, unregisters
|
|
342
|
+
// the agent, and removes its session. A late whenIdle settlement no-ops —
|
|
343
|
+
// the record is no longer 'running'.
|
|
344
|
+
try {
|
|
345
|
+
await entry?.dispose()
|
|
346
|
+
} catch { /* already gone */ }
|
|
347
|
+
|
|
348
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
349
|
+
const target = ledger.tasks.find(t => t.id === taskId)
|
|
350
|
+
if (target === undefined) return undefined
|
|
351
|
+
const execution = target.executions.find(e => e.id === running.id)
|
|
352
|
+
if (execution === undefined || execution.outcome !== 'running') return undefined
|
|
353
|
+
execution.outcome = 'cancelled'
|
|
354
|
+
execution.endedAt = this.deps.now()
|
|
355
|
+
if (target.status === 'in_progress') {
|
|
356
|
+
target.status = 'todo'
|
|
357
|
+
target.updatedAt = this.deps.now()
|
|
358
|
+
delete target.claimedBy
|
|
359
|
+
delete target.claimedAt
|
|
360
|
+
}
|
|
361
|
+
return [target]
|
|
362
|
+
})
|
|
363
|
+
return { ok: true, executionId: running.id }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Startup reconciliation after a host restart: executions left `running`
|
|
368
|
+
* by the previous process can never settle here (their settlement watchers
|
|
369
|
+
* died with it), so mark them failed and hand their tasks back to todo.
|
|
370
|
+
*/
|
|
371
|
+
async reconcile(): Promise<void> {
|
|
372
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
373
|
+
const now = this.deps.now()
|
|
374
|
+
const touched: TaskRecord[] = []
|
|
375
|
+
for (const task of ledger.tasks) {
|
|
376
|
+
let dirty = false
|
|
377
|
+
for (const execution of task.executions) {
|
|
378
|
+
if (execution.outcome === 'running') {
|
|
379
|
+
execution.outcome = 'failed'
|
|
380
|
+
execution.error = 'interrupted by host restart'
|
|
381
|
+
execution.endedAt = now
|
|
382
|
+
dirty = true
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!dirty) continue
|
|
386
|
+
if (task.status === 'in_progress') {
|
|
387
|
+
task.status = 'todo'
|
|
388
|
+
task.updatedAt = now
|
|
389
|
+
delete task.claimedBy
|
|
390
|
+
delete task.claimedAt
|
|
391
|
+
}
|
|
392
|
+
touched.push(task)
|
|
393
|
+
}
|
|
394
|
+
return touched.length > 0 ? touched : undefined
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The plugin framing line (rendered as a plugin context row): task head,
|
|
400
|
+
* already-claimed state, and the handoff protocol — everything the session
|
|
401
|
+
* must know about the board. The task id appears exactly once (here); the
|
|
402
|
+
* protocol steps below refer to it as 本任务.
|
|
403
|
+
*/
|
|
404
|
+
private pluginFraming(task: TaskRecord): string {
|
|
405
|
+
return `【任务看板】${task.title}(ID: ${task.id})\n`
|
|
406
|
+
+ `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
|
|
407
|
+
+ `完成后按序交接:\n`
|
|
408
|
+
+ `1. taskboard_get 读取本任务,取得最新 version\n`
|
|
409
|
+
+ `2. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\n`
|
|
410
|
+
+ `3. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\n`
|
|
411
|
+
+ `若无法完成:留评论说明原因,将任务移回待办 todo。`
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The card body as a normal user bubble: the effective prompt (explicit
|
|
416
|
+
* prompt, else title+description) with template variables resolved from
|
|
417
|
+
* the task's own history at submit time (valuable for recurring patrols):
|
|
418
|
+
* `{{lastExecution}}` → the previous execution's trigger/outcome/error;
|
|
419
|
+
* `{{lastComments}}` → the last three comments (who + body).
|
|
420
|
+
*/
|
|
421
|
+
private userBody(task: TaskRecord): string {
|
|
422
|
+
const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')
|
|
423
|
+
const lastExecText = lastExec === undefined
|
|
424
|
+
? '(无)'
|
|
425
|
+
: `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`
|
|
426
|
+
const lastCommentsText = task.comments.slice(-3)
|
|
427
|
+
.map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)
|
|
428
|
+
.join('\n') || '(无)'
|
|
429
|
+
return effectivePrompt(task)
|
|
430
|
+
.replace(/\{\{lastExecution\}\}/g, lastExecText)
|
|
431
|
+
.replace(/\{\{lastComments\}\}/g, lastCommentsText)
|
|
240
432
|
}
|
|
241
433
|
|
|
242
|
-
/** Move a task back out of in_progress after a failed start. */
|
|
434
|
+
/** Move a task back out of in_progress (and release its hold) after a failed start. */
|
|
243
435
|
private async revertProgress(taskId: string): Promise<void> {
|
|
244
436
|
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
245
437
|
const target = ledger.tasks.find(t => t.id === taskId)
|
|
246
438
|
if (target !== undefined && target.status === 'in_progress') {
|
|
247
439
|
target.status = 'todo'
|
|
248
440
|
target.updatedAt = this.deps.now()
|
|
441
|
+
delete target.claimedBy
|
|
442
|
+
delete target.claimedAt
|
|
249
443
|
return [target]
|
|
250
444
|
}
|
|
251
445
|
return undefined
|
package/src/host/routes.ts
CHANGED
|
@@ -21,9 +21,12 @@ import {
|
|
|
21
21
|
newTaskId,
|
|
22
22
|
normalizeBody,
|
|
23
23
|
normalizeExecution,
|
|
24
|
+
normalizeModel,
|
|
24
25
|
normalizePrompt,
|
|
25
26
|
normalizeTitle,
|
|
26
27
|
summarize,
|
|
28
|
+
syncClaim,
|
|
29
|
+
type TaskModel,
|
|
27
30
|
type TaskRecord,
|
|
28
31
|
} from '../shared/protocol.ts'
|
|
29
32
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
@@ -43,6 +46,23 @@ export interface TaskboardRoutesOptions {
|
|
|
43
46
|
now: () => number
|
|
44
47
|
/** Manual-run hook (the execution service); absent → 501. */
|
|
45
48
|
run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
|
|
49
|
+
/** Cancel hook (the execution service); absent → 501. */
|
|
50
|
+
cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>
|
|
51
|
+
/**
|
|
52
|
+
* Registered model provider routes (from the host llm runtime), for
|
|
53
|
+
* advisory validation of pinned models; undefined = runtime unavailable.
|
|
54
|
+
*/
|
|
55
|
+
modelProviders?: () => string[] | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Validate a pinned model: structural check always, provider route when known. */
|
|
59
|
+
function checkModel(raw: unknown, modelProviders?: () => string[] | undefined): TaskModel {
|
|
60
|
+
const model = normalizeModel(raw)
|
|
61
|
+
const providers = modelProviders?.()
|
|
62
|
+
if (providers !== undefined && !providers.includes(model.provider)) {
|
|
63
|
+
throw new Error(`Error: invalid_input: model provider "${model.provider}" has no registered route (available: ${providers.join(', ')})`)
|
|
64
|
+
}
|
|
65
|
+
return model
|
|
46
66
|
}
|
|
47
67
|
|
|
48
68
|
/** JSON-envelope writer. */
|
|
@@ -117,14 +137,6 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
117
137
|
}
|
|
118
138
|
store.subscribe(broadcast)
|
|
119
139
|
|
|
120
|
-
const taskPath = (id: string, action?: string): RegExp | null => {
|
|
121
|
-
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
122
|
-
const pattern = action === undefined
|
|
123
|
-
? `^${ROUTE_PREFIX}/tasks/${escaped}$`
|
|
124
|
-
: `^${ROUTE_PREFIX}/tasks/${escaped}/${action}$`
|
|
125
|
-
return new RegExp(pattern)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
140
|
const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
129
141
|
try {
|
|
130
142
|
const url = new URL(req.url ?? '/', 'http://x')
|
|
@@ -181,7 +193,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
181
193
|
const urgency = asUrgency(str(body, 'urgency') ?? '')
|
|
182
194
|
const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
|
|
183
195
|
const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
|
|
184
|
-
const model = body.model
|
|
196
|
+
const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
|
|
185
197
|
const now = options.now()
|
|
186
198
|
const task: TaskRecord = {
|
|
187
199
|
id: newTaskId(),
|
|
@@ -245,7 +257,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
245
257
|
// The GUI (task owner surface) may edit model/execution; null clears the model.
|
|
246
258
|
if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
|
|
247
259
|
if (body.model === null) next.model = undefined
|
|
248
|
-
else if (body.model !== undefined) next.model = body.model
|
|
260
|
+
else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
|
|
249
261
|
next.version = task.version + 1
|
|
250
262
|
next.updatedAt = options.now()
|
|
251
263
|
next.updatedBy = { kind: 'user' }
|
|
@@ -270,6 +282,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
270
282
|
next.updatedAt = options.now()
|
|
271
283
|
next.updatedBy = { kind: 'user' }
|
|
272
284
|
if (task.status === 'todo' && to === 'in_progress') next.blocked = false
|
|
285
|
+
// A user move records no holder; leaving in_progress releases any hold.
|
|
286
|
+
syncClaim(next, to, options.now())
|
|
273
287
|
await store.mutate('task-moved', ledger => {
|
|
274
288
|
const i = ledger.tasks.findIndex(t => t.id === id)
|
|
275
289
|
ledger.tasks[i] = next
|
|
@@ -332,6 +346,20 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
332
346
|
}
|
|
333
347
|
return
|
|
334
348
|
}
|
|
349
|
+
if (action === 'cancel') {
|
|
350
|
+
if (options.cancel === undefined) {
|
|
351
|
+
const f = fail('invalid_input', 'execution service unavailable')
|
|
352
|
+
json(res, f.res, 501)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
const result = await options.cancel(id)
|
|
356
|
+
if (result.ok) json(res, { ok: true, value: { cancelled: true, executionId: result.executionId } }, 202)
|
|
357
|
+
else {
|
|
358
|
+
const f = fail('invalid_input', result.error)
|
|
359
|
+
json(res, f.res, f.status)
|
|
360
|
+
}
|
|
361
|
+
return
|
|
362
|
+
}
|
|
335
363
|
const f = fail('not_found', `unknown action ${action}`)
|
|
336
364
|
json(res, f.res, f.status)
|
|
337
365
|
} catch (error) {
|
|
@@ -341,7 +369,6 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
341
369
|
return
|
|
342
370
|
}
|
|
343
371
|
|
|
344
|
-
void taskPath
|
|
345
372
|
res.writeHead(404)
|
|
346
373
|
res.end()
|
|
347
374
|
} catch (error) {
|
package/src/host/scheduler.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module dsh-taskboard/host/scheduler
|
|
10
10
|
*/
|
|
11
11
|
import { nextCronTime, parseCron, type TaskLedger } from '../shared/protocol.ts'
|
|
12
|
-
import type
|
|
12
|
+
import { DEFAULT_MAX_CONCURRENT, type ExecutionService } from './execution.ts'
|
|
13
13
|
import type { TaskStore } from './store.ts'
|
|
14
14
|
|
|
15
15
|
/** Tick cadence. */
|
|
@@ -21,8 +21,10 @@ const SKIP_AFTER_MS = 5 * 60_000
|
|
|
21
21
|
/** Everything the scheduler needs. */
|
|
22
22
|
export interface SchedulerDeps {
|
|
23
23
|
store: TaskStore
|
|
24
|
-
execution: Pick<ExecutionService, 'run'>
|
|
24
|
+
execution: Pick<ExecutionService, 'run' | 'inFlight'>
|
|
25
25
|
now: () => number
|
|
26
|
+
/** Max concurrently running executions (default 3; must match the execution service). */
|
|
27
|
+
maxConcurrent?: number
|
|
26
28
|
/** Timer face (injectable for tests). */
|
|
27
29
|
timers?: {
|
|
28
30
|
setInterval(fn: () => void, ms: number): unknown
|
|
@@ -35,6 +37,7 @@ export interface SchedulerDeps {
|
|
|
35
37
|
*/
|
|
36
38
|
export class SchedulerService {
|
|
37
39
|
private handle: unknown
|
|
40
|
+
private catchup: ReturnType<typeof setTimeout> | undefined
|
|
38
41
|
|
|
39
42
|
/** @param deps - store + execution + clock. */
|
|
40
43
|
constructor(private readonly deps: SchedulerDeps) {}
|
|
@@ -46,12 +49,17 @@ export class SchedulerService {
|
|
|
46
49
|
clearInterval: (handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>),
|
|
47
50
|
}
|
|
48
51
|
this.handle = timers.setInterval(() => { void this.tick() }, TICK_MS)
|
|
49
|
-
// Catch up promptly on host restart: run one tick soon after start.
|
|
50
|
-
|
|
52
|
+
// Catch up promptly on host restart: run one tick soon after start. The
|
|
53
|
+
// handle is cleared on dispose so a torn-down scheduler never fires.
|
|
54
|
+
this.catchup = setTimeout(() => { void this.tick() }, 3_000)
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/** Stop ticking. */
|
|
54
58
|
dispose(): void {
|
|
59
|
+
if (this.catchup !== undefined) {
|
|
60
|
+
clearTimeout(this.catchup)
|
|
61
|
+
this.catchup = undefined
|
|
62
|
+
}
|
|
55
63
|
if (this.handle === undefined) return
|
|
56
64
|
const timers = this.deps.timers ?? { clearInterval: (h: unknown) => clearInterval(h as ReturnType<typeof setInterval>) }
|
|
57
65
|
timers.clearInterval(this.handle)
|
|
@@ -60,13 +68,21 @@ export class SchedulerService {
|
|
|
60
68
|
|
|
61
69
|
/** One scheduler pass (exported for tests). */
|
|
62
70
|
async tick(): Promise<void> {
|
|
71
|
+
// Load once before reading: snapshot() does not trigger a load, and the
|
|
72
|
+
// scheduler may be the first consumer after a host restart (otherwise it
|
|
73
|
+
// would tick over an empty ledger until something else loads it).
|
|
74
|
+
await this.deps.store.load()
|
|
63
75
|
const now = this.deps.now()
|
|
64
76
|
const ledger: TaskLedger = this.deps.store.snapshot()
|
|
77
|
+
const atCapacity = this.deps.execution.inFlight() >= (this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)
|
|
65
78
|
for (const task of ledger.tasks) {
|
|
66
79
|
if (task.execution.mode !== 'scheduled' || task.execution.cron === undefined) continue
|
|
67
80
|
if (task.execution.nextRunAt === undefined) continue
|
|
68
81
|
if (task.status === 'in_progress' || task.trashedAt !== undefined) continue
|
|
69
82
|
if (task.execution.nextRunAt > now) continue
|
|
83
|
+
// At the concurrency cap: leave nextRunAt in the past and retry next
|
|
84
|
+
// tick — advancing here would silently burn this window.
|
|
85
|
+
if (atCapacity) continue
|
|
70
86
|
const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS
|
|
71
87
|
|
|
72
88
|
// Advance the schedule FIRST (idempotent under re-ticks), then run
|
package/src/host/store.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname, join } from 'node:path'
|
|
|
11
11
|
import {
|
|
12
12
|
LEDGER_SCHEMA_VERSION,
|
|
13
13
|
emptyLedger,
|
|
14
|
+
pruneExecutions,
|
|
14
15
|
type TaskLedger,
|
|
15
16
|
type TaskRecord,
|
|
16
17
|
} from '../shared/protocol.ts'
|
|
@@ -55,7 +56,18 @@ export class TaskStore {
|
|
|
55
56
|
const raw = await readFile(this.file, 'utf8')
|
|
56
57
|
const parsed = JSON.parse(raw) as TaskLedger
|
|
57
58
|
if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {
|
|
58
|
-
|
|
59
|
+
const tasks = parsed.tasks as TaskRecord[]
|
|
60
|
+
// Migration from pre-claim-field ledgers: an agent-held in_progress
|
|
61
|
+
// task carried its holder in updatedBy — backfill the explicit claim
|
|
62
|
+
// fields so the hold survives user edits (updatedBy is audit-only).
|
|
63
|
+
for (const task of tasks) {
|
|
64
|
+
if (task.status === 'in_progress' && task.claimedBy === undefined
|
|
65
|
+
&& task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {
|
|
66
|
+
task.claimedBy = task.updatedBy.sessionId
|
|
67
|
+
task.claimedAt = task.updatedAt
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }
|
|
59
71
|
}
|
|
60
72
|
} catch (error) {
|
|
61
73
|
const code = (error as NodeJS.ErrnoException).code
|
|
@@ -70,14 +82,19 @@ export class TaskStore {
|
|
|
70
82
|
this.loaded = true
|
|
71
83
|
}
|
|
72
84
|
|
|
73
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* The current snapshot — a deep-frozen clone. Mutating the returned value
|
|
87
|
+
* throws (strict mode) instead of silently bypassing the revision/persist
|
|
88
|
+
* path; internal state is never handed out.
|
|
89
|
+
*/
|
|
74
90
|
snapshot(): TaskLedger {
|
|
75
|
-
return this.ledger
|
|
91
|
+
return deepFreeze(structuredClone(this.ledger))
|
|
76
92
|
}
|
|
77
93
|
|
|
78
|
-
/** Find a task by id. */
|
|
94
|
+
/** Find a task by id (frozen clone; internal state is never handed out). */
|
|
79
95
|
get(id: string): TaskRecord | undefined {
|
|
80
|
-
|
|
96
|
+
const task = this.ledger.tasks.find(t => t.id === id)
|
|
97
|
+
return task === undefined ? undefined : deepFreeze(structuredClone(task))
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
/** Subscribe to committed changes; returns the unsubscribe. */
|
|
@@ -103,6 +120,9 @@ export class TaskStore {
|
|
|
103
120
|
if (changed === undefined) {
|
|
104
121
|
return { ledger: this.ledger, changed: [] }
|
|
105
122
|
}
|
|
123
|
+
// Retention cap: every committed mutation re-checks the touched tasks,
|
|
124
|
+
// so execution history can never grow unbounded (SSE state payload).
|
|
125
|
+
for (const task of changed) pruneExecutions(task)
|
|
106
126
|
draft.revision += 1
|
|
107
127
|
const json = JSON.stringify(draft)
|
|
108
128
|
await persistAtomic(this.file, json)
|
|
@@ -118,16 +138,17 @@ export class TaskStore {
|
|
|
118
138
|
const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>
|
|
119
139
|
return result
|
|
120
140
|
}
|
|
141
|
+
}
|
|
121
142
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
})
|
|
143
|
+
/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */
|
|
144
|
+
function deepFreeze<T>(value: T): T {
|
|
145
|
+
if (value !== null && typeof value === 'object') {
|
|
146
|
+
if (!Object.isFrozen(value)) Object.freeze(value)
|
|
147
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
148
|
+
deepFreeze((value as Record<string, unknown>)[key])
|
|
149
|
+
}
|
|
130
150
|
}
|
|
151
|
+
return value
|
|
131
152
|
}
|
|
132
153
|
|
|
133
154
|
/** Atomic file persist: write temp, then rename over the target. */
|