dsh-taskboard 0.1.2 → 0.2.0
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 +30 -3
- package/lib/client.js +743 -142
- package/lib/host/execution.js +156 -28
- 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 +190 -16
- 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: {
|
|
@@ -56,6 +66,8 @@ export interface ExecutionDeps {
|
|
|
56
66
|
mintMessageId?: () => string
|
|
57
67
|
/** Best-effort session rename (pins the session list title to the task title). */
|
|
58
68
|
renameSession?: (sessionId: string, title: string) => void
|
|
69
|
+
/** Max concurrently running executions across all tasks (default 3). */
|
|
70
|
+
maxConcurrent?: number
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
/** Outcome of a run request (immediate; the run settles asynchronously). */
|
|
@@ -63,6 +75,11 @@ export type RunRequestResult =
|
|
|
63
75
|
| { ok: true; executionId: string; sessionId: string }
|
|
64
76
|
| { ok: false; error: string }
|
|
65
77
|
|
|
78
|
+
/** Outcome of a cancel request. */
|
|
79
|
+
export type CancelRequestResult =
|
|
80
|
+
| { ok: true; executionId: string }
|
|
81
|
+
| { ok: false; error: string }
|
|
82
|
+
|
|
66
83
|
/** Whether a turn/end payload closed with an error reason. */
|
|
67
84
|
function isErrorTurnEnd(data: unknown): { message: string } | undefined {
|
|
68
85
|
if (typeof data !== 'object' || data === null) return undefined
|
|
@@ -78,12 +95,19 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
|
|
|
78
95
|
return { message }
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
/** One live execution tracked for settlement and cancellation. */
|
|
99
|
+
interface RunEntry {
|
|
100
|
+
sessionId: string
|
|
101
|
+
settle: () => void
|
|
102
|
+
dispose: () => Promise<void>
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
/**
|
|
82
106
|
* The execution service.
|
|
83
107
|
*/
|
|
84
108
|
export class ExecutionService {
|
|
85
|
-
/**
|
|
86
|
-
private readonly
|
|
109
|
+
/** Live executions by execution id (settles and cancels remove entries). */
|
|
110
|
+
private readonly runs = new Map<string, RunEntry>()
|
|
87
111
|
|
|
88
112
|
/** @param deps - store + agents + workspaces + events + clock. */
|
|
89
113
|
constructor(private readonly deps: ExecutionDeps) {
|
|
@@ -94,7 +118,7 @@ export class ExecutionService {
|
|
|
94
118
|
})
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
/** Record a turn failure against the running execution of that session. */
|
|
121
|
+
/** Record a turn failure against the running execution of that session and give the task back. */
|
|
98
122
|
private noteFailure(sessionId: string, message: string): void {
|
|
99
123
|
void this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
100
124
|
for (const task of ledger.tasks) {
|
|
@@ -103,6 +127,21 @@ export class ExecutionService {
|
|
|
103
127
|
execution.outcome = 'failed'
|
|
104
128
|
execution.error = message.slice(0, 500)
|
|
105
129
|
execution.endedAt = this.deps.now()
|
|
130
|
+
// The failed session will not finish the work: hand the task back
|
|
131
|
+
// instead of leaving it stuck in in_progress forever — and leave a
|
|
132
|
+
// system comment so the GUI shows why.
|
|
133
|
+
if (task.status === 'in_progress' && task.claimedBy === sessionId) {
|
|
134
|
+
task.status = 'todo'
|
|
135
|
+
task.updatedAt = this.deps.now()
|
|
136
|
+
delete task.claimedBy
|
|
137
|
+
delete task.claimedAt
|
|
138
|
+
task.comments.push({
|
|
139
|
+
id: newCommentId(),
|
|
140
|
+
body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
|
|
141
|
+
version: 1,
|
|
142
|
+
createdAt: this.deps.now(),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
106
145
|
return [task]
|
|
107
146
|
}
|
|
108
147
|
}
|
|
@@ -127,18 +166,24 @@ export class ExecutionService {
|
|
|
127
166
|
|
|
128
167
|
/**
|
|
129
168
|
* Run one task now (manual button or scheduler tick).
|
|
169
|
+
*
|
|
170
|
+
* The in-progress gate and the execution-open write happen inside ONE
|
|
171
|
+
* serial-queue mutation, so two overlapping run() calls (double click,
|
|
172
|
+
* overlapping scheduler ticks) can never both pass — exactly one session
|
|
173
|
+
* is opened per task.
|
|
130
174
|
* @param taskId - the task to run.
|
|
131
175
|
* @param trigger - what started it.
|
|
132
176
|
* @returns the immediate result; settlement lands in the ledger.
|
|
133
177
|
*/
|
|
134
178
|
async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
|
|
179
|
+
const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
|
|
180
|
+
if (this.runs.size >= max) {
|
|
181
|
+
return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }
|
|
182
|
+
}
|
|
135
183
|
const task = this.deps.store.get(taskId)
|
|
136
184
|
if (task === undefined || task.trashedAt !== undefined) {
|
|
137
185
|
return { ok: false, error: `no task ${taskId}` }
|
|
138
186
|
}
|
|
139
|
-
if (task.status === 'in_progress') {
|
|
140
|
-
return { ok: false, error: 'task is already in progress' }
|
|
141
|
-
}
|
|
142
187
|
const workspace = this.deps.workspaces.get(task.workspaceId)
|
|
143
188
|
if (workspace === undefined) {
|
|
144
189
|
return { ok: false, error: `unknown workspace ${task.workspaceId}` }
|
|
@@ -147,10 +192,19 @@ export class ExecutionService {
|
|
|
147
192
|
const executionId = newExecutionId()
|
|
148
193
|
const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
|
|
149
194
|
|
|
150
|
-
// 1. Open the execution record
|
|
195
|
+
// 1. Open the execution record, flip the card to in_progress, and record
|
|
196
|
+
// the executing session as the claim holder — atomically.
|
|
197
|
+
let gate: string | undefined
|
|
151
198
|
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
152
199
|
const target = ledger.tasks.find(t => t.id === taskId)
|
|
153
|
-
if (target === undefined
|
|
200
|
+
if (target === undefined || target.trashedAt !== undefined) {
|
|
201
|
+
gate = `no task ${taskId}`
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
if (target.status === 'in_progress') {
|
|
205
|
+
gate = 'task is already in progress'
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
154
208
|
target.executions.push({
|
|
155
209
|
id: executionId,
|
|
156
210
|
trigger,
|
|
@@ -160,8 +214,11 @@ export class ExecutionService {
|
|
|
160
214
|
target.status = 'in_progress'
|
|
161
215
|
target.updatedAt = this.deps.now()
|
|
162
216
|
target.updatedBy = { kind: 'user' }
|
|
217
|
+
target.claimedBy = sessionId
|
|
218
|
+
target.claimedAt = this.deps.now()
|
|
163
219
|
return [target]
|
|
164
220
|
})
|
|
221
|
+
if (gate !== undefined) return { ok: false, error: gate }
|
|
165
222
|
|
|
166
223
|
// 2. Create the fresh agent+session inside the task's project, carrying
|
|
167
224
|
// the pinned model — or the deployment default when unpinned (the
|
|
@@ -206,22 +263,44 @@ export class ExecutionService {
|
|
|
206
263
|
}
|
|
207
264
|
handle.agent.followup(message)
|
|
208
265
|
|
|
209
|
-
// 6. Settlement watcher
|
|
266
|
+
// 6. Settlement watcher: mark succeeded, release the executing session's
|
|
267
|
+
// hold, and — when the session did NOT follow the handoff protocol —
|
|
268
|
+
// auto-move the card to in_review with a system comment (otherwise a
|
|
269
|
+
// disobedient session would leave it hanging in in_progress forever).
|
|
210
270
|
const settle = (): void => {
|
|
211
|
-
this.
|
|
271
|
+
this.runs.delete(executionId)
|
|
212
272
|
void this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
213
273
|
for (const t of ledger.tasks) {
|
|
214
274
|
const execution = t.executions.find(e => e.id === executionId)
|
|
215
275
|
if (execution !== undefined && execution.outcome === 'running') {
|
|
276
|
+
const now = this.deps.now()
|
|
216
277
|
execution.outcome = 'succeeded'
|
|
217
|
-
execution.endedAt =
|
|
278
|
+
execution.endedAt = now
|
|
279
|
+
if (t.status === 'in_progress' && t.claimedBy === sessionId) {
|
|
280
|
+
delete t.claimedBy
|
|
281
|
+
delete t.claimedAt
|
|
282
|
+
}
|
|
283
|
+
if (t.status === 'in_progress') {
|
|
284
|
+
const commented = t.comments.some(c => c.threadId === sessionId)
|
|
285
|
+
t.comments.push({
|
|
286
|
+
id: newCommentId(),
|
|
287
|
+
body: normalizeBody(commented
|
|
288
|
+
? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
|
|
289
|
+
: '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
|
|
290
|
+
version: 1,
|
|
291
|
+
createdAt: now,
|
|
292
|
+
})
|
|
293
|
+
t.status = 'in_review'
|
|
294
|
+
t.updatedAt = now
|
|
295
|
+
t.updatedBy = { kind: 'user' }
|
|
296
|
+
}
|
|
218
297
|
return [t]
|
|
219
298
|
}
|
|
220
299
|
}
|
|
221
300
|
return undefined
|
|
222
301
|
})
|
|
223
302
|
}
|
|
224
|
-
this.
|
|
303
|
+
this.runs.set(executionId, { sessionId, settle, dispose: () => handle.dispose() })
|
|
225
304
|
void handle.agent.whenIdle().then(settle, () => {
|
|
226
305
|
this.noteFailure(sessionId, 'agent did not reach quiescence')
|
|
227
306
|
settle()
|
|
@@ -230,22 +309,117 @@ export class ExecutionService {
|
|
|
230
309
|
return { ok: true, executionId, sessionId }
|
|
231
310
|
}
|
|
232
311
|
|
|
233
|
-
/**
|
|
312
|
+
/** How many executions are currently running (for the concurrency cap). */
|
|
313
|
+
inFlight(): number {
|
|
314
|
+
return this.runs.size
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Cancel the running execution of a task (user action): stop the agent
|
|
319
|
+
* session, mark the execution cancelled, and hand the task back to todo.
|
|
320
|
+
* @param taskId - the task whose execution should be stopped.
|
|
321
|
+
* @returns the immediate result.
|
|
322
|
+
*/
|
|
323
|
+
async cancel(taskId: string): Promise<CancelRequestResult> {
|
|
324
|
+
const task = this.deps.store.get(taskId)
|
|
325
|
+
if (task === undefined) return { ok: false, error: `no task ${taskId}` }
|
|
326
|
+
const running = [...task.executions].reverse().find(e => e.outcome === 'running')
|
|
327
|
+
if (running === undefined) return { ok: false, error: 'no running execution' }
|
|
328
|
+
|
|
329
|
+
const entry = this.runs.get(running.id)
|
|
330
|
+
this.runs.delete(running.id)
|
|
331
|
+
// Stop the agent first (best effort): dispose stops the loop, unregisters
|
|
332
|
+
// the agent, and removes its session. A late whenIdle settlement no-ops —
|
|
333
|
+
// the record is no longer 'running'.
|
|
334
|
+
try {
|
|
335
|
+
await entry?.dispose()
|
|
336
|
+
} catch { /* already gone */ }
|
|
337
|
+
|
|
338
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
339
|
+
const target = ledger.tasks.find(t => t.id === taskId)
|
|
340
|
+
if (target === undefined) return undefined
|
|
341
|
+
const execution = target.executions.find(e => e.id === running.id)
|
|
342
|
+
if (execution === undefined || execution.outcome !== 'running') return undefined
|
|
343
|
+
execution.outcome = 'cancelled'
|
|
344
|
+
execution.endedAt = this.deps.now()
|
|
345
|
+
if (target.status === 'in_progress') {
|
|
346
|
+
target.status = 'todo'
|
|
347
|
+
target.updatedAt = this.deps.now()
|
|
348
|
+
delete target.claimedBy
|
|
349
|
+
delete target.claimedAt
|
|
350
|
+
}
|
|
351
|
+
return [target]
|
|
352
|
+
})
|
|
353
|
+
return { ok: true, executionId: running.id }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Startup reconciliation after a host restart: executions left `running`
|
|
358
|
+
* by the previous process can never settle here (their settlement watchers
|
|
359
|
+
* died with it), so mark them failed and hand their tasks back to todo.
|
|
360
|
+
*/
|
|
361
|
+
async reconcile(): Promise<void> {
|
|
362
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
363
|
+
const now = this.deps.now()
|
|
364
|
+
const touched: TaskRecord[] = []
|
|
365
|
+
for (const task of ledger.tasks) {
|
|
366
|
+
let dirty = false
|
|
367
|
+
for (const execution of task.executions) {
|
|
368
|
+
if (execution.outcome === 'running') {
|
|
369
|
+
execution.outcome = 'failed'
|
|
370
|
+
execution.error = 'interrupted by host restart'
|
|
371
|
+
execution.endedAt = now
|
|
372
|
+
dirty = true
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!dirty) continue
|
|
376
|
+
if (task.status === 'in_progress') {
|
|
377
|
+
task.status = 'todo'
|
|
378
|
+
task.updatedAt = now
|
|
379
|
+
delete task.claimedBy
|
|
380
|
+
delete task.claimedAt
|
|
381
|
+
}
|
|
382
|
+
touched.push(task)
|
|
383
|
+
}
|
|
384
|
+
return touched.length > 0 ? touched : undefined
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The prompt text one execution submits (task context + instructions).
|
|
390
|
+
* The effective prompt supports two template variables, rendered from the
|
|
391
|
+
* task's own history at submit time (valuable for recurring patrols):
|
|
392
|
+
* `{{lastExecution}}` → the previous execution's trigger/outcome/error;
|
|
393
|
+
* `{{lastComments}}` → the last three comments (who + body).
|
|
394
|
+
*/
|
|
234
395
|
private executionPrompt(task: TaskRecord): string {
|
|
235
396
|
const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'
|
|
236
397
|
const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`
|
|
237
398
|
+ `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`
|
|
238
399
|
+ `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`
|
|
239
|
-
|
|
400
|
+
const base = effectivePrompt(task)
|
|
401
|
+
const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')
|
|
402
|
+
const lastExecText = lastExec === undefined
|
|
403
|
+
? '(无)'
|
|
404
|
+
: `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`
|
|
405
|
+
const lastCommentsText = task.comments.slice(-3)
|
|
406
|
+
.map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)
|
|
407
|
+
.join('\n') || '(无)'
|
|
408
|
+
const body = base
|
|
409
|
+
.replace(/\{\{lastExecution\}\}/g, lastExecText)
|
|
410
|
+
.replace(/\{\{lastComments\}\}/g, lastCommentsText)
|
|
411
|
+
return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${body}\n\n${tail}`
|
|
240
412
|
}
|
|
241
413
|
|
|
242
|
-
/** Move a task back out of in_progress after a failed start. */
|
|
414
|
+
/** Move a task back out of in_progress (and release its hold) after a failed start. */
|
|
243
415
|
private async revertProgress(taskId: string): Promise<void> {
|
|
244
416
|
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
245
417
|
const target = ledger.tasks.find(t => t.id === taskId)
|
|
246
418
|
if (target !== undefined && target.status === 'in_progress') {
|
|
247
419
|
target.status = 'todo'
|
|
248
420
|
target.updatedAt = this.deps.now()
|
|
421
|
+
delete target.claimedBy
|
|
422
|
+
delete target.claimedAt
|
|
249
423
|
return [target]
|
|
250
424
|
}
|
|
251
425
|
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. */
|