dsh-taskboard 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +24 -1
  2. package/lib/client.js +434 -193
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +210 -112
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +128 -97
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +48 -5
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +9 -8
  25. package/src/client/api.ts +26 -8
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/SettingsModal.tsx +84 -0
  28. package/src/client/board/TaskBoard.tsx +47 -40
  29. package/src/client/board/TaskCard.tsx +3 -5
  30. package/src/client/board/TaskDetail.tsx +30 -21
  31. package/src/client/board/TaskFormModal.tsx +39 -31
  32. package/src/client/board/format.ts +26 -0
  33. package/src/client/board/labels.ts +44 -0
  34. package/src/client/controller.ts +86 -34
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +5 -1
  37. package/src/client/styles.ts +4 -0
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +263 -128
  41. package/src/host/scheduler.ts +62 -36
  42. package/src/host/sdk.ts +12 -1
  43. package/src/host/store.ts +53 -7
  44. package/src/host/templates.ts +12 -3
  45. package/src/host/tools.ts +187 -126
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +11 -2
  48. package/src/shared/protocol.ts +83 -6
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
package/src/host/tools.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  asUrgency,
30
30
  canTransition,
31
31
  checklistFromTexts,
32
+ defaultIsolationOf,
32
33
  effectivePrompt,
33
34
  isClaim,
34
35
  isClaimedBy,
@@ -45,6 +46,7 @@ import {
45
46
  syncClaim,
46
47
  type Actor,
47
48
  type ChecklistItem,
49
+ type TaskLedger,
48
50
  type TaskModel,
49
51
  type TaskRecord,
50
52
  } from '../shared/protocol.ts'
@@ -123,7 +125,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
123
125
  } else {
124
126
  lines.push('执行记录: 无')
125
127
  }
126
- const updatedBy = t.updatedBy.kind === 'agent' ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : 'user'
128
+ const updatedBy = t.updatedBy.kind === 'agent' ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : t.updatedBy.kind === 'system' ? 'system' : 'user'
127
129
  lines.push(`更新: ${new Date(t.updatedAt).toISOString()} 由 ${updatedBy}`)
128
130
  return lines.join('\n')
129
131
  }
@@ -139,8 +141,10 @@ export const ERR = {
139
141
  invalidInput: 'invalid_input',
140
142
  } as const
141
143
 
142
- /** Tool failure: an Error whose message starts with a stable code. */
143
- class ToolError extends Error {
144
+ /** Tool failure: an Error whose message starts with a stable code. The code
145
+ * is also carried structurally so the routes layer can map failures without
146
+ * re-parsing messages (review P2). */
147
+ export class ToolError extends Error {
144
148
  constructor(readonly code: string, detail: string) {
145
149
  super(`Error: ${code}: ${detail}`)
146
150
  }
@@ -222,6 +226,20 @@ function versionGuard(task: TaskRecord, ifVersion: number | undefined): void {
222
226
  }
223
227
  }
224
228
 
229
+ /**
230
+ * Find a live (non-trashed) task INSIDE a mutator (R1: every guard must run
231
+ * on the fresh draft the serial queue hands us, never on a pre-read clone —
232
+ * a pre-read can pass its version check and then blind-overwrite a task that
233
+ * changed while the caller awaited). Throws not_found for missing/trashed.
234
+ */
235
+ function liveTaskAt(ledger: TaskLedger, id: string): { index: number; task: TaskRecord } {
236
+ const index = ledger.tasks.findIndex(t => t.id === id)
237
+ if (index < 0) throw new ToolError(ERR.notFound, `no task ${id}`)
238
+ const task = ledger.tasks[index]!
239
+ if (task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${id}`)
240
+ return { index, task }
241
+ }
242
+
225
243
  /** Re-throw with a stable code; non-ToolErrors become invalid_input. */
226
244
  function fail(error: unknown): never {
227
245
  if (error instanceof ToolError) throw error
@@ -248,7 +266,7 @@ export interface ToolContextFace {
248
266
  }
249
267
 
250
268
  /**
251
- * Register all eight tools.
269
+ * Register all ten tools.
252
270
  * @param ctx - a context exposing `tools.register`.
253
271
  * @param deps - store + workspaces + clock.
254
272
  * @returns dispose functions, one per tool.
@@ -373,7 +391,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
373
391
  },
374
392
  isolation: {
375
393
  type: 'string',
376
- description: 'Code isolation for executions: "worktree" (default — each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction).',
394
+ description: 'Code isolation for executions: "worktree" (each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction). Omitted → the board default (看板设置 → 默认执行隔离; factory default "none").',
377
395
  },
378
396
  presetId: {
379
397
  type: 'string',
@@ -414,14 +432,20 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
414
432
  }
415
433
  const urgency = asUrgency(args.urgency)
416
434
  const status = args.status === undefined ? 'todo' as const : asStatus(args.status)
417
- if (status === 'done' || status === 'archived') {
418
- throw new ToolError(ERR.invalidTransition, 'a new task cannot start as done/archived')
435
+ if (status !== 'backlog' && status !== 'todo') {
436
+ throw new ToolError(ERR.invalidTransition, 'a new task must start as backlog or todo (in_progress requires claiming the task)')
419
437
  }
420
438
  const execution = normalizeExecution(args.execution ?? {}, deps.now())
421
439
  const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
422
- const isolation = args.isolation === undefined ? undefined : asIsolation(args.isolation)
440
+ // 0.5.0: an omitted isolation is MATERIALIZED from the board setting
441
+ // (看板设置) at creation, so later setting changes never rewrite
442
+ // existing tasks.
443
+ const isolation = args.isolation === undefined ? defaultIsolationOf(store.snapshot().settings) : asIsolation(args.isolation)
423
444
  const presetId = args.presetId?.trim() || undefined
424
- const checklist = args.checklist !== undefined ? checklistFromTexts(args.checklist) : undefined
445
+ // T9: match the GUI create route trim and drop blank lines instead
446
+ // of failing the whole call over one empty string.
447
+ const checklistTexts = args.checklist?.map(c => c.trim()).filter(c => c.length > 0)
448
+ const checklist = checklistTexts !== undefined && checklistTexts.length > 0 ? checklistFromTexts(checklistTexts) : undefined
425
449
  const now = deps.now()
426
450
  const task: TaskRecord = {
427
451
  id: newTaskId(),
@@ -434,7 +458,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
434
458
  blocked: false,
435
459
  execution,
436
460
  model,
437
- ...(isolation !== undefined ? { isolation } : {}),
461
+ isolation,
438
462
  ...(presetId !== undefined ? { presetId } : {}),
439
463
  ...(checklist !== undefined ? { checklist } : {}),
440
464
  version: 1,
@@ -488,25 +512,27 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
488
512
  }, exec: unknown) {
489
513
  try {
490
514
  const { actor } = caller(exec as ToolRunContext)
491
- const task = store.get(args.id)
492
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
493
- versionGuard(task, args.ifVersion)
494
- if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
495
- const next: TaskRecord = structuredClone(task)
496
- if (args.title !== undefined) next.title = normalizeTitle(args.title)
497
- if (args.description !== undefined) next.description = args.description.trim()
498
- if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
499
- if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
500
- if (args.blocked !== undefined) next.blocked = args.blocked
501
- next.version = task.version + 1
502
- next.updatedAt = deps.now()
503
- next.updatedBy = actor
515
+ // R1: lookup + version guard + write run inside the serial-queue
516
+ // mutation, on the fresh draft a pre-read clone could pass its
517
+ // version check and then blind-overwrite a concurrent writer.
518
+ let next: TaskRecord | undefined
504
519
  await store.mutate('task-updated', ledger => {
505
- const i = ledger.tasks.findIndex(t => t.id === args.id)
506
- ledger.tasks[i] = next
520
+ const { index, task } = liveTaskAt(ledger, args.id)
521
+ versionGuard(task, args.ifVersion)
522
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
523
+ next = structuredClone(task)
524
+ if (args.title !== undefined) next.title = normalizeTitle(args.title)
525
+ if (args.description !== undefined) next.description = args.description.trim()
526
+ if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
527
+ if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
528
+ if (args.blocked !== undefined) next.blocked = args.blocked
529
+ next.version = task.version + 1
530
+ next.updatedAt = deps.now()
531
+ next.updatedBy = actor
532
+ ledger.tasks[index] = next
507
533
  return [next]
508
534
  })
509
- return json({ task: summarize(next) })
535
+ return json({ task: summarize(next!) })
510
536
  } catch (error) { fail(error) }
511
537
  },
512
538
  })) as () => void)
@@ -536,44 +562,47 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
536
562
  try {
537
563
  const { actor } = caller(exec as ToolRunContext)
538
564
  const to = asStatus(args.status)
539
- const task = store.get(args.id)
540
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
541
- versionGuard(task, args.ifVersion)
565
+ // Claim boundary (policy gate): resolving the caller's workspace is
566
+ // async, so it happens BEFORE the mutation the comparison runs INSIDE
567
+ // against the FRESH task (stronger than the old stale-clone compare).
568
+ const callerWsId = to === 'in_progress'
569
+ ? await callerWorkspace(deps, exec as ToolRunContext)
570
+ : undefined
571
+ // R1: every state guard + the write itself run inside the mutation.
572
+ let next: TaskRecord | undefined
573
+ await store.mutate('task-moved', ledger => {
574
+ const { index, task } = liveTaskAt(ledger, args.id)
575
+ versionGuard(task, args.ifVersion)
542
576
 
543
- // Code-level gate: agents never complete a task.
544
- if (to === 'done') {
545
- throw new ToolError(ERR.forbidden, 'moving a task to done requires explicit user confirmation (GUI); agents cannot do it')
546
- }
547
- if (!canTransition(task.status, to)) {
548
- throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
549
- }
550
- // Exclusive hold: while a task is in_progress under a session
551
- // (explicit claimedBy — an agent claim or a live execution), no other
552
- // session may move it (that would be a takeover).
553
- if (task.status === 'in_progress' && task.claimedBy !== undefined && task.claimedBy !== actor.sessionId) {
554
- throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`)
555
- }
556
- // Claim boundary: the calling session must belong to the task's project.
557
- if (isClaim(task.status, to)) {
558
- const wsId = await callerWorkspace(deps, exec as ToolRunContext)
559
- if (wsId !== task.workspaceId) {
577
+ // Code-level gate: agents never complete a task.
578
+ if (to === 'done') {
579
+ throw new ToolError(ERR.forbidden, 'moving a task to done requires explicit user confirmation (GUI); agents cannot do it')
580
+ }
581
+ if (!canTransition(task.status, to)) {
582
+ throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
583
+ }
584
+ // Exclusive hold: while a task is in_progress under a session
585
+ // (explicit claimedBy — an agent claim or a live execution), no other
586
+ // session may move it (that would be a takeover).
587
+ if (task.status === 'in_progress' && task.claimedBy !== undefined && task.claimedBy !== actor.sessionId) {
588
+ throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`)
589
+ }
590
+ // Claim boundary: the calling session must belong to the task's project.
591
+ if (isClaim(task.status, to) && callerWsId !== task.workspaceId) {
560
592
  throw new ToolError(ERR.workspaceMismatch, 'only a session inside this task\'s project may claim it')
561
593
  }
562
- }
563
- const next: TaskRecord = structuredClone(task)
564
- next.status = to
565
- next.version = task.version + 1
566
- next.updatedAt = deps.now()
567
- next.updatedBy = actor
568
- if (isClaim(task.status, to)) next.blocked = false
569
- // Record the holder on a claim; every move out of in_progress releases it.
570
- syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : undefined)
571
- await store.mutate('task-moved', ledger => {
572
- const i = ledger.tasks.findIndex(t => t.id === args.id)
573
- ledger.tasks[i] = next
594
+ next = structuredClone(task)
595
+ next.status = to
596
+ next.version = task.version + 1
597
+ next.updatedAt = deps.now()
598
+ next.updatedBy = actor
599
+ if (isClaim(task.status, to)) next.blocked = false
600
+ // Record the holder on a claim; every move out of in_progress releases it.
601
+ syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : undefined)
602
+ ledger.tasks[index] = next
574
603
  return [next]
575
604
  })
576
- return json({ task: summarize(next) })
605
+ return json({ task: summarize(next!) })
577
606
  } catch (error) { fail(error) }
578
607
  },
579
608
  })) as () => void)
@@ -606,8 +635,6 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
606
635
  async execute(args: { id: string; body: string }, exec: unknown) {
607
636
  try {
608
637
  const { sessionId } = caller(exec as ToolRunContext)
609
- const task = store.get(args.id)
610
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
611
638
  const comment = {
612
639
  id: newCommentId(),
613
640
  body: normalizeBody(args.body),
@@ -615,16 +642,21 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
615
642
  createdAt: deps.now(),
616
643
  threadId: sessionId,
617
644
  }
618
- const next: TaskRecord = structuredClone(task)
619
- next.comments.push(comment)
620
- next.version = task.version + 1
621
- next.updatedAt = deps.now()
645
+ // R1: find + append inside the mutation (no ifVersion by design —
646
+ // comments are append-only — but the write must not clobber a task
647
+ // that changed while we were queued).
648
+ let next: TaskRecord | undefined
622
649
  await store.mutate('comment-added', ledger => {
623
- const i = ledger.tasks.findIndex(t => t.id === args.id)
624
- ledger.tasks[i] = next
650
+ const { index, task } = liveTaskAt(ledger, args.id)
651
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
652
+ next = structuredClone(task)
653
+ next.comments.push(comment)
654
+ next.version = task.version + 1
655
+ next.updatedAt = deps.now()
656
+ ledger.tasks[index] = next
625
657
  return [next]
626
658
  })
627
- return json({ comment, task: { id: next.id, version: next.version, status: next.status } })
659
+ return json({ comment, task: { id: next!.id, version: next!.version, status: next!.status } })
628
660
  } catch (error) { fail(error) }
629
661
  },
630
662
  })) as () => void)
@@ -678,15 +710,23 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
678
710
  async execute(args: { id: string; ifVersion: number }, exec: unknown) {
679
711
  try {
680
712
  caller(exec as ToolRunContext)
681
- const task = store.get(args.id)
682
- if (task === undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
683
- versionGuard(task, args.ifVersion)
684
- const next: TaskRecord = structuredClone(task)
685
- next.trashedAt = deps.now()
686
- next.version = task.version + 1
713
+ // R1: guards inside the mutation. S5: a running execution keeps
714
+ // writing to the task (report, settlement) refuse the soft-delete
715
+ // until it is cancelled or settled. T8: clear claim residue.
716
+ let next: TaskRecord | undefined
687
717
  await store.mutate('task-deleted', ledger => {
688
- const i = ledger.tasks.findIndex(t => t.id === args.id)
689
- ledger.tasks[i] = next
718
+ const { index, task } = liveTaskAt(ledger, args.id)
719
+ versionGuard(task, args.ifVersion)
720
+ if (task.executions.some(e => e.outcome === 'running')) {
721
+ throw new ToolError(ERR.invalidInput, '任务有正在运行的执行(先在 GUI 取消或等它结束再删除)')
722
+ }
723
+ next = structuredClone(task)
724
+ next.trashedAt = deps.now()
725
+ next.version = task.version + 1
726
+ delete next.claimedBy
727
+ delete next.claimedAt
728
+ next.blocked = false
729
+ ledger.tasks[index] = next
690
730
  return [next]
691
731
  })
692
732
  return { trashed: true }
@@ -735,57 +775,57 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
735
775
  }, exec: unknown) {
736
776
  try {
737
777
  const { actor } = caller(exec as ToolRunContext)
738
- const task = store.get(args.id)
739
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
740
- versionGuard(task, args.ifVersion)
741
- if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
742
- const next: TaskRecord = structuredClone(task)
743
- const checklist: ChecklistItem[] = next.checklist === undefined ? [] : [...next.checklist]
744
-
745
- if (args.action === 'add') {
746
- const texts = args.items ?? []
747
- if (texts.length === 0 || texts.length > 10) {
748
- throw new ToolError(ERR.invalidInput, 'items must carry 1..10 texts per add call')
749
- }
750
- if (checklist.length + texts.length > MAX_CHECKLIST_ITEMS) {
751
- throw new ToolError(ERR.invalidInput, `checklist may hold at most ${MAX_CHECKLIST_ITEMS} items (currently ${checklist.length})`)
778
+ // R1: guards + the checklist edit itself run inside the mutation.
779
+ let next: TaskRecord | undefined
780
+ await store.mutate('task-updated', ledger => {
781
+ const { index, task } = liveTaskAt(ledger, args.id)
782
+ versionGuard(task, args.ifVersion)
783
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
784
+ next = structuredClone(task)
785
+ const checklist: ChecklistItem[] = next.checklist === undefined ? [] : [...next.checklist]
786
+
787
+ if (args.action === 'add') {
788
+ const texts = args.items ?? []
789
+ if (texts.length === 0 || texts.length > 10) {
790
+ throw new ToolError(ERR.invalidInput, 'items must carry 1..10 texts per add call')
791
+ }
792
+ if (checklist.length + texts.length > MAX_CHECKLIST_ITEMS) {
793
+ throw new ToolError(ERR.invalidInput, `checklist may hold at most ${MAX_CHECKLIST_ITEMS} items (currently ${checklist.length})`)
794
+ }
795
+ checklist.push(...checklistFromTexts(texts))
796
+ } else if (args.action === 'check') {
797
+ if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
798
+ const item = checklist.find(i => i.id === args.itemId)
799
+ if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
800
+ const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
801
+ item.checked = true
802
+ item.checkedBy = actor.sessionId
803
+ item.checkedAt = deps.now()
804
+ if (note !== undefined) item.note = note
805
+ } else if (args.action === 'uncheck') {
806
+ if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
807
+ const item = checklist.find(i => i.id === args.itemId)
808
+ if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
809
+ item.checked = false
810
+ delete item.checkedBy
811
+ delete item.checkedAt
812
+ delete item.note
813
+ } else {
814
+ throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
752
815
  }
753
- checklist.push(...checklistFromTexts(texts))
754
- } else if (args.action === 'check') {
755
- if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
756
- const item = checklist.find(i => i.id === args.itemId)
757
- if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
758
- const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
759
- item.checked = true
760
- item.checkedBy = actor.sessionId
761
- item.checkedAt = deps.now()
762
- if (note !== undefined) item.note = note
763
- } else if (args.action === 'uncheck') {
764
- if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
765
- const item = checklist.find(i => i.id === args.itemId)
766
- if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
767
- item.checked = false
768
- delete item.checkedBy
769
- delete item.checkedAt
770
- delete item.note
771
- } else {
772
- throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
773
- }
774
816
 
775
- if (checklist.length > 0) next.checklist = checklist
776
- else delete next.checklist
777
- next.version = task.version + 1
778
- next.updatedAt = deps.now()
779
- next.updatedBy = actor
780
- await store.mutate('task-updated', ledger => {
781
- const i = ledger.tasks.findIndex(t => t.id === args.id)
782
- ledger.tasks[i] = next
817
+ if (checklist.length > 0) next.checklist = checklist
818
+ else delete next.checklist
819
+ next.version = task.version + 1
820
+ next.updatedAt = deps.now()
821
+ next.updatedBy = actor
822
+ ledger.tasks[index] = next
783
823
  return [next]
784
824
  })
785
- const progress = next.checklist !== undefined
786
- ? { done: next.checklist.filter(i => i.checked).length, total: next.checklist.length }
825
+ const progress = next!.checklist !== undefined
826
+ ? { done: next!.checklist.filter(i => i.checked).length, total: next!.checklist.length }
787
827
  : { done: 0, total: 0 }
788
- return json({ task: { id: next.id, version: next.version }, checklist: next.checklist ?? [], ...progress })
828
+ return json({ task: { id: next!.id, version: next!.version }, checklist: next!.checklist ?? [], ...progress })
789
829
  } catch (error) { fail(error) }
790
830
  },
791
831
  })) as () => void)
@@ -796,7 +836,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
796
836
  description:
797
837
  'Submit the structured execution report for the task you are currently executing (summary / changed '
798
838
  + 'files / how you verified / artifacts / remaining risk). Submit BEFORE moving the task to in_review; '
799
- + 'a later submission overwrites the previous report. Commits and diffs are host-collected do not repeat them.',
839
+ + 'a later submission overwrites the previous report. If your run already settled, you may back-submit '
840
+ + 'onto your latest succeeded execution while you still hold the task. Commits and diffs are host-collected.',
800
841
  parameters: {
801
842
  summary: { type: 'string', required: true, description: 'What was done (1..2000 chars).' },
802
843
  changedFiles: {
@@ -838,8 +879,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
838
879
  try {
839
880
  const { sessionId } = caller(exec as ToolRunContext)
840
881
  const report = normalizeExecutionReport(args)
841
- // Locate the RUNNING execution this session owns — reports attach to
842
- // the live run, so the agent never needs to know execution ids.
882
+ // Path 1 (unchanged): attach to the RUNNING execution this session
883
+ // owns — reports ride the live run, so the agent never needs ids.
843
884
  let taskId: string | undefined
844
885
  let executionId: string | undefined
845
886
  await store.mutate('execution-recorded', ledger => {
@@ -854,8 +895,28 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
854
895
  }
855
896
  return undefined
856
897
  })
898
+ // Path 2 (review follow-up, P2): back-submit onto a session-owned
899
+ // SETTLED execution — the main conversation claims tasks directly and
900
+ // has no live run. Allowed when the session holds the task or owns
901
+ // its latest successful execution; never touches anyone else's runs.
902
+ if (taskId === undefined || executionId === undefined) {
903
+ await store.mutate('execution-recorded', ledger => {
904
+ for (const task of ledger.tasks) {
905
+ if (task.trashedAt !== undefined || task.status === 'archived') continue
906
+ const last = task.executions[task.executions.length - 1]
907
+ const owned = last !== undefined && last.sessionId === sessionId && last.outcome === 'succeeded'
908
+ const holds = task.claimedBy === sessionId && last !== undefined && last.sessionId === sessionId
909
+ if (!owned && !holds) continue
910
+ last!.report = report
911
+ taskId = task.id
912
+ executionId = last!.id
913
+ return [task]
914
+ }
915
+ return undefined
916
+ })
917
+ }
857
918
  if (taskId === undefined || executionId === undefined) {
858
- throw new ToolError(ERR.forbidden, 'no running execution belongs to this session the report can only be submitted while the taskboard execution session is still running')
919
+ throw new ToolError(ERR.forbidden, 'no running execution and no settled execution of yours to report on reports attach to your running execution, or back-submit onto your latest succeeded one while you hold the task')
859
920
  }
860
921
  return json({ taskId, executionId, report })
861
922
  } catch (error) { fail(error) }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Host loader entry for dsh-taskboard.
3
3
  *
4
- * Wiring: the ledger store (one JSON file under the DSH home), the eight
4
+ * Wiring: the ledger store (one JSON file under the DSH home), the ten
5
5
  * `taskboard_*` agent tools, the agent workflow-protocol system-prompt
6
6
  * section, the /taskboard JSON+SSE routes (when a webServer is served),
7
7
  * the host execution service (fresh in-project sessions, pinned models), and
@@ -48,6 +48,12 @@ export const inject = ['tools', 'systemPrompt']
48
48
  export function apply(ctx: Context): void {
49
49
  const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })
50
50
  const templates = new TemplateStore(dshHomePath(TEMPLATES_FILE))
51
+ // Eager first load: the tools and most routes read snapshot()/get() without
52
+ // triggering the lazy load, so a fresh boot used to serve an EMPTY board to
53
+ // taskboard_list/get until the scheduler catchup tick or the first
54
+ // GET /state happened to load the file (review P0). load() never throws —
55
+ // a corrupt ledger is quarantined instead.
56
+ void store.load()
51
57
  const now = () => Date.now()
52
58
  // Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).
53
59
  const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)
@@ -173,6 +179,9 @@ export function apply(ctx: Context): void {
173
179
  const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })
174
180
  scheduler.start()
175
181
  disposers.push(() => scheduler.dispose())
182
+ // Detach the settlement listener with the plugin — a hot reload must
183
+ // not leave stale services reacting to turn/end errors (review P1).
184
+ disposers.push(() => execution.dispose())
176
185
 
177
186
  return () => {
178
187
  disposeRoutes?.()
package/src/shared/api.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module dsh-taskboard/shared/api
7
7
  */
8
- import type { TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'
8
+ import type { BoardSettings, TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'
9
9
 
10
10
  export type { TaskRecord }
11
11
 
@@ -151,6 +151,15 @@ export type TaskTemplate = {
151
151
  /** Templates listing response. */
152
152
  export type TemplatesResponse = { templates: TaskTemplate[] }
153
153
 
154
+ /** Board-settings response (0.5.0; absent fields follow factory defaults). */
155
+ export type SettingsResponse = BoardSettings
156
+
157
+ /** Update-board-settings request body (0.5.0; whole-object replace semantics). */
158
+ export type UpdateSettingsBody = {
159
+ /** Default code isolation for NEW tasks ('worktree' | 'none'). */
160
+ defaultIsolation?: string
161
+ }
162
+
154
163
  /** Import dry-run response (0.4.0): every task classified, nothing written. */
155
164
  export type ImportPreviewResponse = {
156
165
  plan: {
@@ -186,6 +195,6 @@ export type SummaryResponse = { tasks: TaskSummary[] }
186
195
  /** Change frame pushed on every committed ledger mutation. */
187
196
  export type ChangeEvent = {
188
197
  revision: number
189
- kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'
198
+ kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'
190
199
  tasks: TaskSummary[]
191
200
  }