dsh-taskboard 0.1.1 → 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 +47 -3
- package/lib/client.js +886 -139
- package/lib/host/execution.js +160 -33
- 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 +27 -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/AlertModal.tsx +36 -0
- package/src/client/board/TaskBoard.tsx +126 -44
- package/src/client/board/TaskCard.tsx +16 -3
- package/src/client/board/TaskDetail.tsx +93 -14
- package/src/client/board/TaskFormModal.tsx +47 -1
- package/src/client/controller.ts +171 -8
- 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 +83 -6
- package/src/host/execution.ts +202 -18
- 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 +37 -3
- package/src/shared/protocol.ts +72 -3
- package/src/shared/version.ts +9 -0
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. */
|
package/src/host/tools.ts
CHANGED
|
@@ -26,13 +26,16 @@ import {
|
|
|
26
26
|
canTransition,
|
|
27
27
|
effectivePrompt,
|
|
28
28
|
isClaim,
|
|
29
|
+
isClaimedBy,
|
|
29
30
|
newCommentId,
|
|
30
31
|
newTaskId,
|
|
31
32
|
normalizeBody,
|
|
32
33
|
normalizeExecution,
|
|
34
|
+
normalizeModel,
|
|
33
35
|
normalizePrompt,
|
|
34
36
|
normalizeTitle,
|
|
35
37
|
summarize,
|
|
38
|
+
syncClaim,
|
|
36
39
|
type Actor,
|
|
37
40
|
type TaskModel,
|
|
38
41
|
type TaskRecord,
|
|
@@ -72,6 +75,8 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
|
|
|
72
75
|
`状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
|
|
73
76
|
`执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
|
|
74
77
|
]
|
|
78
|
+
const holder = isClaimedBy(t)
|
|
79
|
+
if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
|
|
75
80
|
if (t.execution.nextRunAt !== undefined) lines.push(`下次触发: ${new Date(t.execution.nextRunAt).toISOString()}`)
|
|
76
81
|
if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}`)
|
|
77
82
|
lines.push(`描述: ${t.description.length > 0 ? t.description : '(无)'}`)
|
|
@@ -151,6 +156,22 @@ export interface ToolDeps {
|
|
|
151
156
|
workspaces: WorkspaceFace
|
|
152
157
|
/** Current epoch ms (injectable for tests). */
|
|
153
158
|
now: () => number
|
|
159
|
+
/**
|
|
160
|
+
* Registered model provider routes (from the host llm runtime), for
|
|
161
|
+
* advisory validation of pinned models; undefined = runtime unavailable,
|
|
162
|
+
* in which case only the structural check applies.
|
|
163
|
+
*/
|
|
164
|
+
modelProviders?: () => string[] | undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Validate a pinned model: structural check always, provider route when known. */
|
|
168
|
+
function checkModel(deps: ToolDeps, raw: unknown): TaskModel {
|
|
169
|
+
const model = normalizeModel(raw)
|
|
170
|
+
const providers = deps.modelProviders?.()
|
|
171
|
+
if (providers !== undefined && !providers.includes(model.provider)) {
|
|
172
|
+
throw new ToolError(ERR.invalidInput, `model provider "${model.provider}" has no registered route (available: ${providers.join(', ')})`)
|
|
173
|
+
}
|
|
174
|
+
return model
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
/** Resolve the calling agent's actor and session id. */
|
|
@@ -358,9 +379,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
358
379
|
throw new ToolError(ERR.invalidTransition, 'a new task cannot start as done/archived')
|
|
359
380
|
}
|
|
360
381
|
const execution = normalizeExecution(args.execution ?? {}, deps.now())
|
|
361
|
-
|
|
362
|
-
throw new ToolError(ERR.invalidInput, 'model must be { provider: string, model: string }')
|
|
363
|
-
}
|
|
382
|
+
const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
|
|
364
383
|
const now = deps.now()
|
|
365
384
|
const task: TaskRecord = {
|
|
366
385
|
id: newTaskId(),
|
|
@@ -372,7 +391,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
372
391
|
status,
|
|
373
392
|
blocked: false,
|
|
374
393
|
execution,
|
|
375
|
-
model
|
|
394
|
+
model,
|
|
376
395
|
version: 1,
|
|
377
396
|
createdAt: now,
|
|
378
397
|
updatedAt: now,
|
|
@@ -483,10 +502,11 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
483
502
|
if (!canTransition(task.status, to)) {
|
|
484
503
|
throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
|
|
485
504
|
}
|
|
486
|
-
// Exclusive hold: while a task is in_progress under
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
505
|
+
// Exclusive hold: while a task is in_progress under a session
|
|
506
|
+
// (explicit claimedBy — an agent claim or a live execution), no other
|
|
507
|
+
// session may move it (that would be a takeover).
|
|
508
|
+
if (task.status === 'in_progress' && task.claimedBy !== undefined && task.claimedBy !== actor.sessionId) {
|
|
509
|
+
throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`)
|
|
490
510
|
}
|
|
491
511
|
// Claim boundary: the calling session must belong to the task's project.
|
|
492
512
|
if (isClaim(task.status, to)) {
|
|
@@ -501,6 +521,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
501
521
|
next.updatedAt = deps.now()
|
|
502
522
|
next.updatedBy = actor
|
|
503
523
|
if (isClaim(task.status, to)) next.blocked = false
|
|
524
|
+
// Record the holder on a claim; every move out of in_progress releases it.
|
|
525
|
+
syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : undefined)
|
|
504
526
|
await store.mutate('task-moved', ledger => {
|
|
505
527
|
const i = ledger.tasks.findIndex(t => t.id === args.id)
|
|
506
528
|
ledger.tasks[i] = next
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-tools'
|
|
|
20
20
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
21
21
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
22
22
|
import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'
|
|
23
|
-
import { ExecutionService, type EventsFace } from './host/execution.ts'
|
|
23
|
+
import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
|
|
24
24
|
import { registerTaskboardRoutes } from './host/routes.ts'
|
|
25
25
|
import { SchedulerService } from './host/scheduler.ts'
|
|
26
26
|
import { dshHomePath } from './host/sdk.ts'
|
|
@@ -43,6 +43,8 @@ export const inject = ['tools', 'systemPrompt']
|
|
|
43
43
|
export function apply(ctx: Context): void {
|
|
44
44
|
const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })
|
|
45
45
|
const now = () => Date.now()
|
|
46
|
+
// Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).
|
|
47
|
+
const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)
|
|
46
48
|
|
|
47
49
|
// Agent workflow protocol (claim discipline, retry rules, done-gate).
|
|
48
50
|
const disposeSection = ctx.systemPrompt.section({
|
|
@@ -56,10 +58,24 @@ export function apply(ctx: Context): void {
|
|
|
56
58
|
// workspace registry (claim boundary + project execution need it).
|
|
57
59
|
ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {
|
|
58
60
|
const disposers: Array<() => void> = []
|
|
61
|
+
|
|
62
|
+
// Registered model provider routes (from the host llm runtime), read
|
|
63
|
+
// lazily at call time so late availability still applies; undefined when
|
|
64
|
+
// the runtime is absent → only structural model validation runs.
|
|
65
|
+
const modelProviders = (): string[] | undefined => {
|
|
66
|
+
try {
|
|
67
|
+
const llm = wsCtx.get('llm') as { listProviders?: () => Array<{ id: string }> } | undefined
|
|
68
|
+
return llm === undefined || typeof llm.listProviders !== 'function'
|
|
69
|
+
? undefined
|
|
70
|
+
: llm.listProviders().map(p => p.id)
|
|
71
|
+
} catch { return undefined }
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
disposers.push(...registerTaskboardTools(wsCtx, {
|
|
60
75
|
store,
|
|
61
76
|
workspaces: workspaceFace(wsCtx.workspaceRegistry),
|
|
62
77
|
now,
|
|
78
|
+
modelProviders,
|
|
63
79
|
}))
|
|
64
80
|
|
|
65
81
|
// Settlement listener over the session event bus.
|
|
@@ -84,6 +100,16 @@ export function apply(ctx: Context): void {
|
|
|
84
100
|
},
|
|
85
101
|
events,
|
|
86
102
|
now,
|
|
103
|
+
renameSession: (sessionId, title) => {
|
|
104
|
+
// Best-effort: pin the execution session's title to the task title
|
|
105
|
+
// through the log-backed session-title service (user-sourced rename).
|
|
106
|
+
try {
|
|
107
|
+
const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined
|
|
108
|
+
const sessionTitle = agentCtx.get('sessionTitle') as { rename(session: unknown, title: string): unknown } | undefined
|
|
109
|
+
const session = sessions?.get(sessionId)
|
|
110
|
+
if (session !== undefined && sessionTitle !== undefined) sessionTitle.rename(session, title)
|
|
111
|
+
} catch { /* cosmetic */ }
|
|
112
|
+
},
|
|
87
113
|
defaultModel: () => {
|
|
88
114
|
try {
|
|
89
115
|
const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined
|
|
@@ -91,6 +117,7 @@ export function apply(ctx: Context): void {
|
|
|
91
117
|
return read === undefined ? undefined : read.call(selection)
|
|
92
118
|
} catch { return undefined }
|
|
93
119
|
},
|
|
120
|
+
maxConcurrent,
|
|
94
121
|
})
|
|
95
122
|
|
|
96
123
|
// /dsh-taskboard routes (the run action reaches the execution service).
|
|
@@ -101,13 +128,20 @@ export function apply(ctx: Context): void {
|
|
|
101
128
|
workspaces: workspaceFace(wsCtx.workspaceRegistry),
|
|
102
129
|
now,
|
|
103
130
|
run: (taskId: string) => execution.run(taskId, 'manual'),
|
|
131
|
+
cancel: (taskId: string) => execution.cancel(taskId),
|
|
132
|
+
modelProviders,
|
|
104
133
|
})
|
|
105
134
|
return () => disposeRoutes?.()
|
|
106
135
|
})
|
|
107
136
|
|
|
137
|
+
// Startup reconciliation: executions left 'running' by a previous host
|
|
138
|
+
// process are marked failed and their tasks handed back to todo (their
|
|
139
|
+
// settlement watchers died with that process).
|
|
140
|
+
void execution.reconcile()
|
|
141
|
+
|
|
108
142
|
// Host-side cron scheduler: due scheduled tasks execute even with no
|
|
109
|
-
// browser open.
|
|
110
|
-
const scheduler = new SchedulerService({ store, execution, now })
|
|
143
|
+
// browser open. Shares the execution concurrency cap.
|
|
144
|
+
const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })
|
|
111
145
|
scheduler.start()
|
|
112
146
|
disposers.push(() => scheduler.dispose())
|
|
113
147
|
|
package/src/shared/protocol.ts
CHANGED
|
@@ -271,6 +271,15 @@ export type TaskRecord = {
|
|
|
271
271
|
blocked: boolean
|
|
272
272
|
execution: ExecutionConfig
|
|
273
273
|
model?: TaskModel
|
|
274
|
+
/**
|
|
275
|
+
* The session currently holding the in-progress claim (explicit claim or a
|
|
276
|
+
* live execution). Present only while `status === 'in_progress'`: any move
|
|
277
|
+
* out of in_progress releases it. `updatedBy` is audit-only — user edits no
|
|
278
|
+
* longer erase the holder.
|
|
279
|
+
*/
|
|
280
|
+
claimedBy?: string
|
|
281
|
+
/** When the current holder claimed the task (epoch ms). */
|
|
282
|
+
claimedAt?: number
|
|
274
283
|
version: number
|
|
275
284
|
createdAt: number
|
|
276
285
|
updatedAt: number
|
|
@@ -278,10 +287,28 @@ export type TaskRecord = {
|
|
|
278
287
|
updatedBy: Actor
|
|
279
288
|
comments: CommentRecord[]
|
|
280
289
|
executions: ExecutionRecord[]
|
|
290
|
+
/** How many older execution records were pruned by the retention cap. */
|
|
291
|
+
executionsPruned?: number
|
|
281
292
|
/** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */
|
|
282
293
|
trashedAt?: number
|
|
283
294
|
}
|
|
284
295
|
|
|
296
|
+
/** Retention cap: how many execution records each task keeps (oldest pruned). */
|
|
297
|
+
export const MAX_EXECUTIONS = 20
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Enforce the execution-record retention cap on one task (in place): keep the
|
|
301
|
+
* newest {@link MAX_EXECUTIONS} records, count the dropped ones in
|
|
302
|
+
* `executionsPruned`. Running records are always the newest, never dropped.
|
|
303
|
+
* @param task - the task to prune.
|
|
304
|
+
*/
|
|
305
|
+
export function pruneExecutions(task: TaskRecord): void {
|
|
306
|
+
if (task.executions.length <= MAX_EXECUTIONS) return
|
|
307
|
+
const dropped = task.executions.length - MAX_EXECUTIONS
|
|
308
|
+
task.executions = task.executions.slice(-MAX_EXECUTIONS)
|
|
309
|
+
task.executionsPruned = (task.executionsPruned ?? 0) + dropped
|
|
310
|
+
}
|
|
311
|
+
|
|
285
312
|
/** The whole durable ledger. */
|
|
286
313
|
export type TaskLedger = {
|
|
287
314
|
schemaVersion: number
|
|
@@ -424,9 +451,51 @@ export function effectivePrompt(task: TaskRecord): string {
|
|
|
424
451
|
* @param task - the task.
|
|
425
452
|
*/
|
|
426
453
|
export function isClaimedBy(task: TaskRecord): string | undefined {
|
|
427
|
-
return task.status === 'in_progress' && task.
|
|
428
|
-
|
|
429
|
-
|
|
454
|
+
return task.status === 'in_progress' && task.claimedBy !== undefined ? task.claimedBy : undefined
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Maintain the explicit claim fields around a status change: entering
|
|
459
|
+
* in_progress under a session records the holder (an execution-start or an
|
|
460
|
+
* agent claim); every move out of in_progress releases the claim (handoff,
|
|
461
|
+
* give-back, cancel). A user-driven move into in_progress records no holder —
|
|
462
|
+
* no session works on it yet.
|
|
463
|
+
* @param task - the task being written (mutated in place).
|
|
464
|
+
* @param to - the target status.
|
|
465
|
+
* @param now - current epoch ms.
|
|
466
|
+
* @param holder - the session id claiming the task, when applicable.
|
|
467
|
+
*/
|
|
468
|
+
export function syncClaim(task: TaskRecord, to: TaskStatus, now: number, holder?: string): void {
|
|
469
|
+
if (to !== 'in_progress') {
|
|
470
|
+
delete task.claimedBy
|
|
471
|
+
delete task.claimedAt
|
|
472
|
+
} else if (holder !== undefined) {
|
|
473
|
+
task.claimedBy = holder
|
|
474
|
+
task.claimedAt = now
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Validate and normalize a pinned model: `{ provider, model }`, both
|
|
480
|
+
* non-empty trimmed strings.
|
|
481
|
+
* @param raw - the raw input.
|
|
482
|
+
* @returns the normalized model.
|
|
483
|
+
* @throws when the shape or the fields are invalid.
|
|
484
|
+
*/
|
|
485
|
+
export function normalizeModel(raw: unknown): TaskModel {
|
|
486
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
487
|
+
throw new Error('model must be { provider: string, model: string }')
|
|
488
|
+
}
|
|
489
|
+
const { provider, model } = raw as { provider?: unknown; model?: unknown }
|
|
490
|
+
if (typeof provider !== 'string' || typeof model !== 'string') {
|
|
491
|
+
throw new Error('model must be { provider: string, model: string }')
|
|
492
|
+
}
|
|
493
|
+
const p = provider.trim()
|
|
494
|
+
const m = model.trim()
|
|
495
|
+
if (p.length === 0 || m.length === 0) {
|
|
496
|
+
throw new Error('model.provider and model.model must be non-empty strings')
|
|
497
|
+
}
|
|
498
|
+
return { provider: p, model: m }
|
|
430
499
|
}
|
|
431
500
|
|
|
432
501
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin package version shown in the board UI. Kept in sync with
|
|
3
|
+
* package.json by a regression test (tests lock drift).
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-taskboard/shared/version
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The package version (must equal package.json "version"). */
|
|
9
|
+
export const PLUGIN_VERSION = '0.2.0'
|