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.
@@ -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 as { provider: string; model: string } | undefined
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 as { provider: string; model: string }
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) {
@@ -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 { ExecutionService } from './execution.ts'
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
- setTimeout(() => { void this.tick() }, 3_000)
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
- this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks: parsed.tasks }
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
- /** The current immutable snapshot. */
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
- return this.ledger.tasks.find(t => t.id === id)
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
- /** Persist the current ledger now (used after external reconciliation). */
123
- async flush(kind: LedgerChange['kind'], changed: readonly TaskRecord[]): Promise<void> {
124
- await this.mutate(kind, (ledger) => {
125
- // replace tasks wholesale from the live snapshot objects
126
- const byId = new Map(this.ledger.tasks.map(t => [t.id, t]))
127
- ledger.tasks = ledger.tasks.map(t => byId.get(t.id) ?? t)
128
- return [...changed]
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
- if (args.model !== undefined && (typeof args.model.provider !== 'string' || typeof args.model.model !== 'string')) {
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: args.model as TaskModel | undefined,
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 an agent, no other
487
- // session may move it at all (that would be a takeover).
488
- if (task.status === 'in_progress' && task.updatedBy.kind === 'agent' && task.updatedBy.sessionId !== actor.sessionId) {
489
- throw new ToolError(ERR.forbidden, `task is held by session ${task.updatedBy.sessionId}; never take over another session's claim`)
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
 
@@ -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.updatedBy.kind === 'agent'
428
- ? task.updatedBy.sessionId
429
- : undefined
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'