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/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.
@@ -101,6 +117,7 @@ export function apply(ctx: Context): void {
101
117
  return read === undefined ? undefined : read.call(selection)
102
118
  } catch { return undefined }
103
119
  },
120
+ maxConcurrent,
104
121
  })
105
122
 
106
123
  // /dsh-taskboard routes (the run action reaches the execution service).
@@ -111,13 +128,20 @@ export function apply(ctx: Context): void {
111
128
  workspaces: workspaceFace(wsCtx.workspaceRegistry),
112
129
  now,
113
130
  run: (taskId: string) => execution.run(taskId, 'manual'),
131
+ cancel: (taskId: string) => execution.cancel(taskId),
132
+ modelProviders,
114
133
  })
115
134
  return () => disposeRoutes?.()
116
135
  })
117
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
+
118
142
  // Host-side cron scheduler: due scheduled tasks execute even with no
119
- // browser open.
120
- const scheduler = new SchedulerService({ store, execution, now })
143
+ // browser open. Shares the execution concurrency cap.
144
+ const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })
121
145
  scheduler.start()
122
146
  disposers.push(() => scheduler.dispose())
123
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'