@linxin666/dsh-client-ui-task-board 0.4.0 → 0.4.2

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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +24 -0
  3. package/README.zh.md +24 -0
  4. package/lib/client.js +917 -81
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1774 -1420
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
  8. package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
  9. package/lib/types/client/board/NewTaskModal.d.ts +7 -1
  10. package/lib/types/client/board/TaskCard.d.ts +11 -1
  11. package/lib/types/client/locales.d.ts +33 -0
  12. package/lib/types/core/controller.d.ts +30 -1
  13. package/lib/types/core/subtask.d.ts +122 -0
  14. package/lib/types/core/tasks.d.ts +46 -3
  15. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  16. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  17. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  18. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  19. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  20. package/lib/types/host/agent-tools.d.ts +27 -0
  21. package/lib/types/host-ledger.d.ts +88 -4
  22. package/lib/types/host-runner.d.ts +39 -1
  23. package/lib/types/host-service.d.ts +47 -0
  24. package/lib/types/index.d.ts +83 -5
  25. package/lib/types/protocol.d.ts +14 -0
  26. package/package.json +23 -21
  27. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  28. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  29. package/src/client/board/NewTaskModal.tsx +40 -6
  30. package/src/client/board/TaskBoard.tsx +88 -8
  31. package/src/client/board/TaskCard.tsx +52 -2
  32. package/src/client/board/TaskDetail.tsx +124 -1
  33. package/src/client/board.module.css +96 -0
  34. package/src/client/host-api.ts +14 -4
  35. package/src/client/index.ts +11 -1
  36. package/src/client/locales.ts +70 -2
  37. package/src/core/controller.ts +69 -8
  38. package/src/core/store.ts +15 -0
  39. package/src/core/subtask.ts +286 -0
  40. package/src/core/tasks.ts +50 -2
  41. package/src/core/use-cases/task-archive.ts +46 -12
  42. package/src/core/use-cases/task-create.ts +42 -7
  43. package/src/core/use-cases/task-delete.ts +11 -1
  44. package/src/core/use-cases/task-parent.ts +64 -0
  45. package/src/core/use-cases/task-update.ts +3 -1
  46. package/src/host/agent-tools.ts +643 -0
  47. package/src/host-ledger.ts +323 -45
  48. package/src/host-runner.ts +141 -12
  49. package/src/host-service.ts +127 -10
  50. package/src/index.ts +181 -9
  51. package/src/protocol.ts +26 -2
@@ -5,11 +5,21 @@ import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
6
  import { isValidCron, nextRunAtMs } from './core/schedule.ts'
7
7
  import { isTaskRecord, parseLedger } from './core/store.ts'
8
- import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
8
+ import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
9
+ import {
10
+ DEFAULT_SUBTASK_DEPTH,
11
+ cascadeTargets,
12
+ combineCascadeOutcome,
13
+ normalizeSubtaskDepth,
14
+ openGroupExecution,
15
+ pendingCascadeChildren,
16
+ resolveExecutionTargets,
17
+ } from './core/subtask.ts'
9
18
  import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
10
19
  import { applyCreateTask } from './core/use-cases/task-create.ts'
11
20
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
12
21
  import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
22
+ import { applySetParent } from './core/use-cases/task-parent.ts'
13
23
  import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
14
24
  import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
15
25
  import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
@@ -40,9 +50,32 @@ export interface LedgerState {
40
50
  scheduler: TaskBoardSchedulerSnapshot
41
51
  }
42
52
 
53
+ /** Human-facing text for a refused binding, per surface. */
54
+ function bindingRefusalMessage(
55
+ refusal: { kind: 'root' | 'inherited' | 'subtask-pin'; title: string },
56
+ sessionDefault: TaskPermission,
57
+ surface: 'run' | 'schedule',
58
+ ): string {
59
+ if (refusal.kind === 'subtask-pin') {
60
+ return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear that card's permission or run the tree without Agent Team`
61
+ }
62
+ if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
63
+ if (refusal.kind === 'root') {
64
+ return `confirmation-required: the effective permission is above the session default (${sessionDefault}); confirm the card's permission binding first`
65
+ }
66
+ return `confirmation-required: subtask "${refusal.title}" inherits an above-default permission; confirm that card's permission binding first`
67
+ }
68
+
43
69
  export interface OpenedRun {
44
70
  task: TaskRecord
45
71
  execution: ExecutionRecord
72
+ /**
73
+ * How the Host obtains this run's session. Absent or `session` launches a
74
+ * fresh (or reused) execution session; `teammate` means the run belongs to a
75
+ * team-mode cascade, where the Host asks the root's Lead session to spawn a
76
+ * teammate instead of launching a session of its own.
77
+ */
78
+ dispatch?: 'session' | 'teammate'
46
79
  }
47
80
 
48
81
  /** Minimal value copy used by the Host session monitor. */
@@ -298,6 +331,39 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
298
331
  return { ...newer, executions: retainRecentExecutions(executions) }
299
332
  }
300
333
 
334
+ /**
335
+ * Repair parent links after a load or an import: a link whose parent row is
336
+ * missing, cyclic, or names an archived parent while the child is on board is
337
+ * dropped (the task becomes a root) instead of dropping the task. A chain
338
+ * deeper than the current `maxSubtaskDepth` is deliberately KEPT: the limit
339
+ * gates new links, it never deletes a stored one, and every cascade walk is
340
+ * bounded by the limit anyway. The Host is the only writer of these links, so
341
+ * this is a last-resort guard against a hand-edited or imported document.
342
+ */
343
+ export function repairParentLinks(tasks: readonly TaskRecord[]): TaskRecord[] {
344
+ const byId = new Map(tasks.map(task => [task.id, task]))
345
+ return tasks.map(task => {
346
+ if (task.parentId === undefined) return task
347
+ const seen = new Set<string>([task.id])
348
+ let current: TaskRecord | undefined = byId.get(task.parentId)
349
+ let depth = 0
350
+ while (current !== undefined) {
351
+ // The first hop is the parent itself: an archived parent holding an
352
+ // on-board child is outside the archive cascade invariant, and only an
353
+ // import can produce it.
354
+ if (seen.has(current.id) || (depth === 0 && current.archivedAt !== undefined && task.archivedAt === undefined)) {
355
+ return { ...task, parentId: undefined }
356
+ }
357
+ seen.add(current.id)
358
+ depth += 1
359
+ current = current.parentId === undefined ? undefined : byId.get(current.parentId)
360
+ }
361
+ // A walk that never started (depth 0) means the parent row did not survive
362
+ // the parse: the link is dangling and the task goes back to the root.
363
+ return depth === 0 ? { ...task, parentId: undefined } : task
364
+ })
365
+ }
366
+
301
367
  function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
302
368
  const rawById = new Map<string, Record<string, unknown>>()
303
369
  for (const value of values) {
@@ -305,7 +371,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
305
371
  const raw = value as Record<string, unknown>
306
372
  if (typeof raw.id === 'string') rawById.set(raw.id, raw)
307
373
  }
308
- return parseLedger(JSON.stringify(values)).map(task => {
374
+ return repairParentLinks(parseLedger(JSON.stringify(values))).map(task => {
309
375
  const rawSchedule = rawById.get(task.id)?.schedule
310
376
  if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
311
377
  const schedule = rawSchedule as Record<string, unknown>
@@ -337,9 +403,16 @@ export class HostTaskLedger {
337
403
 
338
404
  /** Session-default permission the confirmation gate compares against. */
339
405
  readonly sessionDefaultPermission: TaskPermission
406
+ /**
407
+ * Deployment subtask depth limit (1..3): the lineage gate every write obeys.
408
+ * The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
409
+ * instead of remounting the row.
410
+ */
411
+ private depthLimit: number
340
412
 
341
- constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission } = {}) {
413
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
342
414
  this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
415
+ this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
343
416
  mkdirSync(dir, { recursive: true })
344
417
  this.file = join(dir, 'ledger-v2.json')
345
418
  this.lockFile = join(dir, 'ledger-v2.lock')
@@ -362,6 +435,23 @@ export class HostTaskLedger {
362
435
  }
363
436
  }
364
437
 
438
+ /** Current subtask depth limit (1..3). */
439
+ get maxSubtaskDepth(): number {
440
+ return this.depthLimit
441
+ }
442
+
443
+ /**
444
+ * Apply a live settings edit of the subtask depth limit. A no-op when the
445
+ * normalized limit is unchanged, so a coarse volatile invalidation that
446
+ * changed nothing emits nothing.
447
+ */
448
+ setMaxSubtaskDepth(depth: number): void {
449
+ const next = normalizeSubtaskDepth(depth)
450
+ if (next === this.depthLimit) return
451
+ this.depthLimit = next
452
+ this.notify()
453
+ }
454
+
365
455
  /** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
366
456
  private cleanStaleTemporaryFiles(dir: string): void {
367
457
  try {
@@ -403,6 +493,9 @@ export class HostTaskLedger {
403
493
  if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
404
494
  for (const execution of task.executions) {
405
495
  if (execution.endedAt !== undefined) continue
496
+ // A deferred cascade parent already knows its own outcome; the monitor
497
+ // has nothing left to inspect, and its children's settles finalize it.
498
+ if (execution.ownResult !== undefined) continue
406
499
  openExecutions.push({
407
500
  taskId: task.id,
408
501
  executionId: execution.id,
@@ -457,7 +550,7 @@ export class HostTaskLedger {
457
550
  requestId: string,
458
551
  action: TaskBoardAction,
459
552
  initiator?: string,
460
- ): { state: LedgerState; run?: OpenedRun } {
553
+ ): { state: LedgerState; runs?: OpenedRun[] } {
461
554
  const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
462
555
  const cached = this.requestCache.get(requestId)
463
556
  if (cached !== undefined) {
@@ -479,27 +572,32 @@ export class HostTaskLedger {
479
572
  }
480
573
  }
481
574
 
482
- openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun | undefined {
575
+ /**
576
+ * Open the cascade one due schedule triggers: an empty array means nothing
577
+ * ran (already running, or a participant whose elevated permission is still
578
+ * unconfirmed), and the rule rolls to its next occurrence either way.
579
+ */
580
+ openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun[] {
483
581
  const task = this.document.tasks.find(item => item.id === taskId)
484
- if (task === undefined || task.archivedAt !== undefined) return undefined
485
- if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
486
- // An unconfirmed above-default permission must never run unattended:
487
- // cron refuses the card and rolls to the next occurrence, exactly
488
- // like the already-running refusal.
582
+ if (task === undefined || task.archivedAt !== undefined) return []
583
+ const rollForward = (): void => {
489
584
  this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
490
585
  this.commit()
491
- return undefined
586
+ }
587
+ const refusal = this.bindingRefusal(task)
588
+ if (refusal !== undefined) {
589
+ // An unconfirmed above-default permission must never run unattended: the
590
+ // whole tree is refused and the schedule rolls to the next occurrence,
591
+ // exactly like the already-running refusal.
592
+ this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
593
+ rollForward()
594
+ return []
492
595
  }
493
596
  if (task.status === 'running' || hasOpenExecution(task)) {
494
- this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
495
- this.commit()
496
- return undefined
597
+ rollForward()
598
+ return []
497
599
  }
498
- const opened = startExecution(task, triggeredAt, crypto.randomUUID())
499
- this.document.tasks = this.document.tasks.map(item => item.id === taskId ? opened.task : item)
500
- this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)]
501
- this.commit()
502
- return opened
600
+ return this.startCascade(task, triggeredAt, undefined, false, nextRunAt)
503
601
  }
504
602
 
505
603
  skipMissed(now: number): void {
@@ -535,6 +633,38 @@ export class HostTaskLedger {
535
633
  this.commit(false)
536
634
  }
537
635
 
636
+ /**
637
+ * Settle one execution. A cascade participant first records its OWN outcome
638
+ * and only finalizes once every child execution in its run group has
639
+ * settled, so a parent card leaves 'running' with the whole tree's verdict
640
+ * rather than its own turn alone.
641
+ */
642
+ settle(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
643
+ const now = this.now()
644
+ const task = this.document.tasks.find(item => item.id === taskId)
645
+ const execution = task?.executions.find(entry => entry.id === executionId)
646
+ if (task === undefined || execution === undefined || execution.endedAt !== undefined) return
647
+ const groupId = execution.runGroupId
648
+ if (groupId === undefined) {
649
+ this.document.tasks = this.document.tasks.map(item => item.id === taskId
650
+ ? settleExecution(item, executionId, outcome, now, error)
651
+ : item)
652
+ this.commit()
653
+ return
654
+ }
655
+ let changed = false
656
+ if (execution.ownResult === undefined) {
657
+ this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
658
+ ...item,
659
+ updatedAt: now,
660
+ executions: item.executions.map(entry => entry.id === executionId ? { ...entry, ownResult: outcome, ownError: error } : entry),
661
+ })
662
+ changed = true
663
+ }
664
+ if (this.settleCascade(taskId, groupId, now)) changed = true
665
+ if (changed) this.commit()
666
+ }
667
+
538
668
  attachSession(taskId: string, executionId: string, sessionId: string): void {
539
669
  const now = this.now()
540
670
  this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
@@ -545,16 +675,9 @@ export class HostTaskLedger {
545
675
  this.commit()
546
676
  }
547
677
 
548
- settle(taskId: string, executionId: string, outcome: 'succeeded' | 'failed' | 'cancelled', error?: string): void {
549
- this.document.tasks = this.document.tasks.map(task => task.id === taskId
550
- ? settleExecution(task, executionId, outcome, this.now(), error)
551
- : task)
552
- this.commit()
553
- }
554
-
555
- private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; run?: OpenedRun } {
678
+ private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
556
679
  const now = this.now()
557
- let run: OpenedRun | undefined
680
+ let runs: OpenedRun[] | undefined
558
681
  switch (action.kind) {
559
682
  case 'import': {
560
683
  const sources = new Set(this.document.scheduler.importedSources ?? [])
@@ -565,7 +688,9 @@ export class HostTaskLedger {
565
688
  const incoming = parseHostTasks(action.tasks)
566
689
  const merged = new Map(this.document.tasks.map(task => [task.id, task]))
567
690
  for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task)
568
- this.document.tasks = [...merged.values()]
691
+ // An imported child can name a parent this ledger has never seen (a
692
+ // partial export): the dangling link is dropped, the task survives.
693
+ this.document.tasks = repairParentLinks([...merged.values()])
569
694
  this.document.scheduler.importedSources = [...sources, action.sourceId]
570
695
  this.document.scheduler.error = invalidScheduleIds.length === 0
571
696
  ? undefined
@@ -582,8 +707,8 @@ export class HostTaskLedger {
582
707
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
583
708
  ? action.input
584
709
  : { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
585
- const result = applyCreateTask(this.document.tasks, input, now, action.id)
586
- if (result.task === undefined) throw new Error('invalid task')
710
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
711
+ if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
587
712
  this.document.tasks = [...result.tasks]
588
713
  break
589
714
  }
@@ -607,14 +732,24 @@ export class HostTaskLedger {
607
732
  this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, patch, now)]
608
733
  break
609
734
  }
610
- case 'delete':
611
- {
612
- const task = this.document.tasks.find(task => task.id === action.taskId)
613
- if (task === undefined) throw new Error('task not found')
614
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
735
+ case 'delete': {
736
+ const task = this.document.tasks.find(task => task.id === action.taskId)
737
+ if (task === undefined) throw new Error('task not found')
738
+ if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
739
+ // Subtasks keep their link: deleting the parent would leave dangling
740
+ // children, so the user detaches or deletes them explicitly first.
741
+ if (this.document.tasks.some(item => item.parentId === action.taskId)) {
742
+ throw new Error('task has subtasks; detach or delete them first')
615
743
  }
616
744
  this.document.tasks = [...applyDeleteTask(this.document.tasks, undefined, action.taskId).tasks]
617
745
  break
746
+ }
747
+ case 'set-parent': {
748
+ const result = applySetParent(this.document.tasks, action.taskId, action.parentId, now, this.maxSubtaskDepth)
749
+ if (!result.applied) throw new Error(result.error ?? 'parent link refused')
750
+ this.document.tasks = [...result.tasks]
751
+ break
752
+ }
618
753
  case 'move': {
619
754
  const task = this.document.tasks.find(item => item.id === action.taskId)
620
755
  if (task === undefined) throw new Error('task not found')
@@ -625,13 +760,15 @@ export class HostTaskLedger {
625
760
  break
626
761
  }
627
762
  case 'archive': {
628
- const result = applyArchiveTask(this.document.tasks, action.taskId, now)
763
+ if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be archived')
764
+ const result = applyArchiveTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
629
765
  if (!result.archived) throw new Error('task cannot be archived')
630
766
  this.document.tasks = [...result.tasks]
631
767
  break
632
768
  }
633
769
  case 'restore': {
634
- const result = applyRestoreTask(this.document.tasks, action.taskId, now)
770
+ if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
771
+ const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
635
772
  if (!result.archived) throw new Error('task is not archived')
636
773
  this.document.tasks = [...result.tasks]
637
774
  break
@@ -658,17 +795,148 @@ export class HostTaskLedger {
658
795
  const task = this.document.tasks.find(item => item.id === action.taskId)
659
796
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
660
797
  if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
661
- if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
662
- throw new Error(`confirmation-required: the effective permission is above the session default (${this.sessionDefaultPermission}); confirm the card's permission binding first`)
663
- }
664
- const base = action.kind === 'rerun' ? withStatus(task, 'todo', now) : task
665
- run = startExecution(base, now, crypto.randomUUID(), initiator)
666
- this.document.tasks = this.document.tasks.map(item => item.id === task.id ? run!.task : item)
798
+ // The confirmation gate judges the RESOLVED binding: a subtask that
799
+ // inherits an elevated permission from its parent is exactly as
800
+ // unconfirmed as the parent would be without its own stamp.
801
+ const refusal = this.bindingRefusal(task)
802
+ if (refusal !== undefined) throw new Error(bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'run'))
803
+ runs = this.startCascade(task, now, initiator, action.kind === 'rerun')
667
804
  break
668
805
  }
669
806
  }
807
+ // startCascade committed the opened participants itself; committing again
808
+ // here would bump the revision twice for one action.
809
+ if (runs === undefined || runs.length === 0) this.commit()
810
+ return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
811
+ }
812
+
813
+ /**
814
+ * The binding that makes a run illegal, if any.
815
+ *
816
+ * A plain cascade launches one session per participant, so every participant
817
+ * carries its own resolved binding and each one gates the run. A team run
818
+ * launches only the Lead session: the Lead's binding gates it, while a
819
+ * subtask's OWN above-default pin cannot be applied to a teammate and is
820
+ * refused instead of being silently dropped (an inherited binding is the
821
+ * Lead's own and stays allowed once the Lead is confirmed).
822
+ * @param root - the task being run.
823
+ * @returns the first refusal, or undefined when the run may start.
824
+ */
825
+ private bindingRefusal(root: TaskRecord): { kind: 'root' | 'inherited' | 'subtask-pin'; title: string } | undefined {
826
+ const participants = this.cascadeParticipants(root.id)
827
+ if (root.teamRun === true) {
828
+ const lead = participants.find(participant => participant.id === root.id)
829
+ if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
830
+ return { kind: 'root', title: lead.title }
831
+ }
832
+ // The raw record carries the subtask's OWN binding: the resolved
833
+ // participant above already folded the inherited one into its permission.
834
+ const pinned = participants.find(participant => {
835
+ if (participant.id === root.id) return false
836
+ const raw = this.document.tasks.find(task => task.id === participant.id)
837
+ return raw !== undefined && requiresPermissionConfirmation(raw, this.sessionDefaultPermission)
838
+ })
839
+ return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
840
+ }
841
+ const unconfirmed = participants.find(participant => requiresPermissionConfirmation(participant, this.sessionDefaultPermission))
842
+ if (unconfirmed === undefined) return undefined
843
+ return { kind: unconfirmed.id === root.id ? 'root' : 'inherited', title: unconfirmed.title }
844
+ }
845
+
846
+ /**
847
+ * The tasks a cascade from `rootId` would actually open executions for, with
848
+ * their effective execution targets resolved. Archived members never run, and
849
+ * a member that already has an open execution is skipped (a task cannot run
850
+ * twice), so the result is the participant set the launch will use.
851
+ */
852
+ private cascadeParticipants(rootId: string): TaskRecord[] {
853
+ return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
854
+ .map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
855
+ .filter(participant => participant.archivedAt === undefined
856
+ && participant.status !== 'running'
857
+ && !hasOpenExecution(participant))
858
+ }
859
+
860
+ /** Whether a task or any of its subtasks still has an open execution. */
861
+ private subtreeHasOpenExecution(id: string): boolean {
862
+ return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
863
+ .some(task => task.status === 'running' || hasOpenExecution(task))
864
+ }
865
+
866
+ /**
867
+ * Open one execution per cascade participant (the requested task and its
868
+ * on-board descendants, within the depth limit) under a single run group, and
869
+ * commit the ledger once. The returned runs are ordered root-first, so the
870
+ * Host launches the parent before its subtasks.
871
+ * @param root - the task the user ran.
872
+ * @param now - clock instant (ms epoch).
873
+ * @param initiator - the DSH session that asked for the run (audit only).
874
+ * @param rerun - true to reset the root to 'todo' before starting it.
875
+ * @param nextRunAt - when set, the root's schedule also rolls forward.
876
+ */
877
+ private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, nextRunAt?: number): OpenedRun[] {
878
+ const before = this.document.tasks
879
+ const groupId = crypto.randomUUID()
880
+ // A team-mode root runs as the Team Lead: every other member of the same
881
+ // cascade is handed to that Lead as a teammate (the tree is flattened into
882
+ // one Team, because only the Lead may spawn).
883
+ const team = root.teamRun === true
884
+ const started = new Map<string, TaskRecord>()
885
+ const runs: OpenedRun[] = []
886
+ for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
887
+ if (task.archivedAt !== undefined) continue
888
+ if (task.status === 'running' || hasOpenExecution(task)) continue
889
+ const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
890
+ const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
891
+ started.set(task.id, opened.task)
892
+ runs.push({
893
+ task: resolveExecutionTargets(opened.task, before, this.maxSubtaskDepth),
894
+ execution: opened.execution,
895
+ ...(team && task.id !== root.id ? { dispatch: 'teammate' as const } : {}),
896
+ })
897
+ }
898
+ if (runs.length === 0) return []
899
+ let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
900
+ if (nextRunAt !== undefined) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now)
901
+ this.document.tasks = [...tasks]
670
902
  this.commit()
671
- return { state: this.state(), ...(run === undefined ? {} : { run }) }
903
+ return runs
904
+ }
905
+
906
+ /**
907
+ * Finalize every cascade parent that is ready, walking from `taskId` up the
908
+ * lineage: a member settles once its own outcome is recorded and none of its
909
+ * group children is still open, and its verdict folds those children in
910
+ * (failure dominates, then cancellation).
911
+ */
912
+ private settleCascade(taskId: string, groupId: string, now: number): boolean {
913
+ let changed = false
914
+ const visited = new Set<string>()
915
+ let current: string | undefined = taskId
916
+ while (current !== undefined && !visited.has(current)) {
917
+ visited.add(current)
918
+ const task: TaskRecord | undefined = this.document.tasks.find(item => item.id === current)
919
+ if (task === undefined) break
920
+ const execution = openGroupExecution(task, groupId)
921
+ if (execution === undefined || execution.ownResult === undefined) break
922
+ if (pendingCascadeChildren(this.document.tasks, current, groupId).length > 0) break
923
+ const entries: Array<{ result: ExecutionOutcome; error?: string }> = [
924
+ { result: execution.ownResult, ...(execution.ownError === undefined ? {} : { error: execution.ownError }) },
925
+ ]
926
+ for (const child of this.document.tasks) {
927
+ if (child.parentId !== current) continue
928
+ const childExecution = child.executions.find(entry => entry.runGroupId === groupId && entry.result !== undefined)
929
+ if (childExecution?.result === undefined) continue
930
+ entries.push({ result: childExecution.result, ...(childExecution.error === undefined ? {} : { error: childExecution.error }) })
931
+ }
932
+ const combined = combineCascadeOutcome(entries)
933
+ this.document.tasks = this.document.tasks.map(item => item.id === current
934
+ ? settleExecution(item, execution.id, combined.result, now, combined.error)
935
+ : item)
936
+ changed = true
937
+ current = task.parentId
938
+ }
939
+ return changed
672
940
  }
673
941
 
674
942
  private repairSchedules(skipPast: boolean, persist = true): void {
@@ -694,13 +962,23 @@ export class HostTaskLedger {
694
962
  private reconcileInterruptedStarts(persist = true): void {
695
963
  const now = this.now()
696
964
  let changed = false
965
+ const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
697
966
  this.document.tasks = this.document.tasks.map(task => {
698
967
  if (task.status !== 'running') return task
699
968
  const execution = task.executions.at(-1)
700
969
  if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
701
970
  changed = true
971
+ interrupted.push({ taskId: task.id, runGroupId: execution.runGroupId })
702
972
  return settleExecution(task, execution.id, 'cancelled', now, 'host restarted before the execution session was recorded')
703
973
  })
974
+ // A cancelled participant may have been the last pending child of a
975
+ // deferred cascade parent; walking up from its parent is what lets that
976
+ // parent finalize instead of staying in the running column forever.
977
+ for (const entry of interrupted) {
978
+ if (entry.runGroupId === undefined) continue
979
+ const parentId = this.document.tasks.find(task => task.id === entry.taskId)?.parentId
980
+ if (parentId !== undefined && this.settleCascade(parentId, entry.runGroupId, now)) changed = true
981
+ }
704
982
  if (changed && persist) this.commit()
705
983
  }
706
984