@linxin666/dsh-client-ui-task-board 0.4.6 → 0.4.7

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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +14 -6
  3. package/README.zh.md +14 -6
  4. package/lib/client.js +1502 -820
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +674 -75
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +49 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/locales.d.ts +16 -0
  15. package/lib/types/core/card-defaults.d.ts +78 -0
  16. package/lib/types/core/controller.d.ts +32 -7
  17. package/lib/types/core/launch-width.d.ts +31 -0
  18. package/lib/types/core/reasoning-effort.d.ts +57 -0
  19. package/lib/types/core/subtask.d.ts +30 -8
  20. package/lib/types/core/tasks.d.ts +19 -0
  21. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/launch-admission.d.ts +72 -0
  24. package/lib/types/host-ledger.d.ts +9 -4
  25. package/lib/types/host-runner.d.ts +79 -4
  26. package/lib/types/host-service.d.ts +85 -2
  27. package/lib/types/index.d.ts +87 -0
  28. package/lib/types/protocol.d.ts +13 -0
  29. package/package.json +28 -28
  30. package/src/client/CardDefaultPresetField.tsx +101 -0
  31. package/src/client/TaskBoardSettingsCard.tsx +214 -16
  32. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  33. package/src/client/board/NewTaskModal.tsx +86 -40
  34. package/src/client/board/ReasoningEffortField.tsx +50 -0
  35. package/src/client/board/TaskDetail.tsx +42 -6
  36. package/src/client/board/model-options.ts +178 -0
  37. package/src/client/board/preset-label.ts +32 -0
  38. package/src/client/board/preset-roster.ts +58 -0
  39. package/src/client/board/team-run-promotion.ts +39 -0
  40. package/src/client/index.ts +98 -94
  41. package/src/client/locales.ts +34 -2
  42. package/src/client/settings-entry-form.ts +0 -1
  43. package/src/core/card-defaults.ts +132 -0
  44. package/src/core/controller.ts +44 -9
  45. package/src/core/launch-width.ts +38 -0
  46. package/src/core/reasoning-effort.ts +92 -0
  47. package/src/core/store.ts +5 -0
  48. package/src/core/subtask.ts +48 -13
  49. package/src/core/tasks.ts +20 -0
  50. package/src/core/use-cases/task-create.ts +27 -16
  51. package/src/core/use-cases/task-update.ts +6 -1
  52. package/src/host/agent-tools.ts +21 -8
  53. package/src/host/launch-admission.ts +140 -0
  54. package/src/host-ledger.ts +18 -6
  55. package/src/host-runner.ts +184 -18
  56. package/src/host-service.ts +247 -23
  57. package/src/index.ts +149 -2
  58. package/src/protocol.ts +28 -4
@@ -1,15 +1,18 @@
1
1
  import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
2
+ import { DEFAULT_RUN_LAUNCH_WIDTH, normalizeRunLaunchWidth } from './core/launch-width.ts'
2
3
  import { DEFAULT_SESSION_POLL_SECONDS, normalizeSessionPollSeconds, sessionPollMs } from './core/poll-cadence.ts'
3
4
  import { reusableSessionId } from './core/session-reuse.ts'
5
+ import { LaunchAdmissionQueue, type QueuedLaunch } from './host/launch-admission.ts'
4
6
  import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
5
- import { HostExecutionRunner, SessionLaunchError, promptText, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
7
+ import { HostExecutionRunner, SessionLaunchError, promptText, type GatewayPeer, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
6
8
  import { teammateName } from './core/subtask.ts'
7
9
  import { workspaceOwningSession } from './core/workspace-target.ts'
8
10
  import { PowerInhibitor } from './power-inhibitor.ts'
9
11
  import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
12
+ import type { CardDefaults } from './core/card-defaults.ts'
10
13
  import { TaskBoardExtensionRegistry } from './host/extension-registry.ts'
11
14
  import type { TaskBoardExtension } from './core/extension.ts'
12
- import type { ExecutionOutcome, ScheduleMode, TaskRecord, TaskStatus } from './core/tasks.ts'
15
+ import { isTaskPermission, normalizeTargetId, type ExecutionOutcome, type ScheduleMode, type TaskRecord, type TaskStatus } from './core/tasks.ts'
13
16
  import { passedAttempt, resolveContract, verificationNeverInvoked, verificationOnlyInvalid, verificationRequired, withAcceptanceDetailCleared, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
14
17
  import type { TaskPermission } from './core/handover.ts'
15
18
 
@@ -65,6 +68,35 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647
65
68
  */
66
69
  const UNREADABLE_SETTLE_POLLS = 24
67
70
 
71
+ /**
72
+ * Why a member of a run never started: the Host stopped while its launch was
73
+ * still waiting for an admission slot. Recorded rather than left open, so the
74
+ * card leaves the running column instead of waiting for the next Host start's
75
+ * interrupted-start recovery.
76
+ */
77
+ const ABANDONED_LAUNCH_REASON = 'host stopped before this execution started'
78
+
79
+ /**
80
+ * The acceptance contract one run group freezes, read once for the whole group.
81
+ *
82
+ * The board freezes an execution's acceptance contract BEFORE its session is
83
+ * prompted. Admission delays the launch, so reading these per execution would
84
+ * let a settings edit made while later members wait reach some members of one
85
+ * run and not others. Reading them once per run group — at the group's first
86
+ * admission, which is its root's — keeps the whole group on one contract and
87
+ * still freezes it before any member's session is prompted.
88
+ */
89
+ interface FrozenRunContract {
90
+ settings: VerificationSettings
91
+ /** The GLOBAL native-/goal switch, resolved exactly once for the run group. */
92
+ globalGoalEnabled: boolean
93
+ /** The host model catalog the group's contracts resolve their route from. */
94
+ catalog: ModelCatalogView | undefined
95
+ }
96
+
97
+ /** Reads (or returns the already-read) contract of one run group. */
98
+ type RunContractFreezer = () => Promise<FrozenRunContract>
99
+
68
100
  /**
69
101
  * Why an enforced goal execution settled failed while its acceptance DID run:
70
102
  * attempts were recorded and none of them passed. The judge answered about the
@@ -105,10 +137,18 @@ export interface ScheduledRunContext {
105
137
  }
106
138
 
107
139
  /**
108
- * Actions that can move an armed trigger. Only these re-arm the native timer;
109
- * an unrelated card edit leaves the pending fire untouched.
140
+ * Actions that can change the instant the schedule timer is armed at. Only
141
+ * these re-arm the native timer; an unrelated card edit leaves the pending fire
142
+ * untouched.
143
+ *
144
+ * The set is the write surface of a rule itself rather than a guess at the UI
145
+ * flows that reach it: `create` and `import` land a card that already carries
146
+ * an enabled rule (the new-task dialog and the one-time browser import),
147
+ * `set-schedule` edits one, and `delete`/`archive` drop one. `update` is
148
+ * deliberately absent — the update patch cannot carry a schedule — and neither
149
+ * can the run, move, lineage, permission, verification or label actions.
110
150
  */
111
- const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['set-schedule', 'delete', 'archive'])
151
+ const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['create', 'import', 'set-schedule', 'delete', 'archive'])
112
152
 
113
153
  /**
114
154
  * The native timer face the Host arms through. The cordis `timer` service
@@ -134,6 +174,29 @@ const PROCESS_TIMERS: HostTimerFace = {
134
174
  },
135
175
  }
136
176
 
177
+ /**
178
+ * The new-card defaults a snapshot publishes, or undefined when this
179
+ * deployment configures none. A target the board configures nothing for is
180
+ * omitted instead of published blank, so the browser can tell "no default
181
+ * model" from "a blank one" (only a hand-edited settings document can express
182
+ * the latter).
183
+ */
184
+ function cardDefaultsOf(defaults: CardDefaults): TaskBoardSnapshot['cardDefaults'] {
185
+ const mode = normalizeTargetId(defaults.mode)
186
+ const permission = normalizeTargetId(defaults.permission)
187
+ const model = normalizeTargetId(defaults.model)
188
+ const reasoningEffort = normalizeTargetId(defaults.reasoningEffort)
189
+ const published: NonNullable<TaskBoardSnapshot['cardDefaults']> = {
190
+ ...(mode === undefined ? {} : { mode }),
191
+ // The snapshot publishes a permission the board's own vocabulary knows; an
192
+ // out-of-domain value is ignored here exactly as the create path ignores it.
193
+ ...(isTaskPermission(permission) ? { permission } : {}),
194
+ ...(model === undefined ? {} : { model }),
195
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
196
+ }
197
+ return Object.keys(published).length === 0 ? undefined : published
198
+ }
199
+
137
200
  export class TaskBoardHostService {
138
201
  readonly ledger: HostTaskLedger
139
202
  readonly runner: HostExecutionRunner
@@ -154,6 +217,12 @@ export class TaskBoardHostService {
154
217
  private disposed = false
155
218
  private pollInFlight = false
156
219
  private active = true
220
+ /**
221
+ * Admission control for the session-creating half of a run: one board action
222
+ * opens a whole subtree, and this is what keeps the Host from asking DSH for
223
+ * every member's session at once (see {@link LaunchAdmissionQueue}).
224
+ */
225
+ private readonly launches: LaunchAdmissionQueue
157
226
  /**
158
227
  * Consecutive unreadable-history polls per open execution (see
159
228
  * {@link noteUnreadableInspection}). Cleared as soon as an inspection
@@ -167,6 +236,14 @@ export class TaskBoardHostService {
167
236
  private readonly timers: HostTimerFace
168
237
  /** Live reader of the global native-/goal switch; frozen per execution. */
169
238
  private readonly goalRunEnabled: () => boolean
239
+ /** Live reader of the deployment's new-card defaults (published and applied per create). */
240
+ private readonly cardDefaults: () => CardDefaults
241
+ /**
242
+ * Live reader of the run-launch admission width: how many launches may be in
243
+ * flight at once. Read at every admission, so a settings edit bounds the next
244
+ * launch without remounting the row. Absent means the default.
245
+ */
246
+ private readonly runLaunchWidth: () => number
170
247
  private lastPowerJson = ''
171
248
  private readonly now: () => number
172
249
  /** The deployment's workspaces, consulted to resolve a card that pins none. */
@@ -184,12 +261,26 @@ export class TaskBoardHostService {
184
261
  now?: () => number
185
262
  commandDispatcher?: SessionCommandDispatcher
186
263
  workspaceRegistry?: TaskBoardWorkspaceRegistry
264
+ /**
265
+ * The owner peer every gateway request the runner makes speaks for.
266
+ * Production passes the peer bound to the plugin fiber (see
267
+ * {@link createOwnerPeer}); absent is fine for tests and bare mounts,
268
+ * where the runner binds one to a fresh root context.
269
+ */
270
+ ownerPeer?: GatewayPeer
187
271
  /**
188
272
  * Permission baseline of the confirmation gate: a fixed value, or a live
189
273
  * resolver the board re-reads so a Host Settings change needs no remount.
190
274
  */
191
275
  sessionDefaultPermission?: TaskPermission | (() => TaskPermission)
192
276
  maxSubtaskDepth?: number
277
+ /**
278
+ * Live reader of the deployment's new-card defaults (volatile config reads).
279
+ * The board publishes them for the create dialog and re-reads them for every
280
+ * create that opts in, so a settings edit reaches the next created card
281
+ * without remounting the row. Absent means the deployment configures none.
282
+ */
283
+ cardDefaults?: () => CardDefaults
193
284
  team?: TaskBoardTeamDispatcher
194
285
  timers?: HostTimerFace
195
286
  /**
@@ -208,18 +299,29 @@ export class TaskBoardHostService {
208
299
  verificationSettings?: () => VerificationSettings
209
300
  /** The host model catalog the acceptance contract resolves its route from. */
210
301
  verificationCatalog?: () => Promise<ModelCatalogView | undefined>
302
+ /**
303
+ * Live admission width for run launches: the number of launches (one
304
+ * session creation and its prompt each) that may be in flight at once. Read
305
+ * per admission, so editing the setting bounds the next launch of a run that
306
+ * is still queueing. Absent means the default width.
307
+ */
308
+ runLaunchWidth?: () => number
211
309
  } = {}) {
212
310
  this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
213
311
  sessionDefaultPermission: options.sessionDefaultPermission,
214
312
  maxSubtaskDepth: options.maxSubtaskDepth,
313
+ cardDefaults: options.cardDefaults,
215
314
  })
216
- this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
315
+ this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry, undefined, options.ownerPeer)
217
316
  this.workspaceRegistry = options.workspaceRegistry
218
317
  this.team = options.team
219
318
  this.timers = options.timers ?? PROCESS_TIMERS
220
319
  this.power = options.power ?? new PowerInhibitor()
221
320
  this.now = options.now ?? Date.now
222
321
  this.goalRunEnabled = options.goalRunEnabled ?? (() => false)
322
+ this.cardDefaults = options.cardDefaults ?? (() => ({}))
323
+ this.runLaunchWidth = options.runLaunchWidth ?? (() => DEFAULT_RUN_LAUNCH_WIDTH)
324
+ this.launches = new LaunchAdmissionQueue(() => normalizeRunLaunchWidth(this.runLaunchWidth()))
223
325
  this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
224
326
  this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
225
327
  this.extensions = new TaskBoardExtensionRegistry({
@@ -310,6 +412,7 @@ export class TaskBoardHostService {
310
412
  snapshot(): TaskBoardSnapshot {
311
413
  const state = this.ledger.state()
312
414
  const published = this.extensions.published()
415
+ const cardDefaults = cardDefaultsOf(this.cardDefaults())
313
416
  return {
314
417
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
315
418
  revision: state.revision,
@@ -323,6 +426,10 @@ export class TaskBoardHostService {
323
426
  // service freezes its own read at launch: this mirror is for the FORM
324
427
  // (enable/disable + explanation), never for a run already in flight.
325
428
  goalRunEnabled: this.goalRunEnabled(),
429
+ // The new-card defaults are published live for the same reason the switch
430
+ // is: the create dialog prefills from them, and each creating session
431
+ // re-reads them for the create it submits.
432
+ ...(cardDefaults === undefined ? {} : { cardDefaults }),
326
433
  ...(Object.keys(published).length === 0 ? {} : { extensions: published }),
327
434
  }
328
435
  }
@@ -427,9 +534,11 @@ export class TaskBoardHostService {
427
534
  }
428
535
  const result = this.ledger.applyRequest(requestId, this.withInheritedWorkspace(action, initiator), initiator)
429
536
  if (result.runs !== undefined) this.dispatchRuns(result.runs)
430
- // A committed schedule write (create / update / toggle / delete) moves the
431
- // nearest trigger; re-arm on every action so a newly enabled schedule fires
432
- // at its own instant without waiting for the previous target to elapse.
537
+ // A committed write that can move a rule moves the nearest trigger;
538
+ // re-arm from the ledger so a created or edited schedule fires at its own
539
+ // instant instead of waiting out whatever target was armed before it. The
540
+ // re-arm is derived from the ledger, so a write that moved nothing leaves
541
+ // the pending fire exactly as it was.
433
542
  if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
434
543
  this.emitStatusChanges(before, initiator)
435
544
  if (action.kind === 'delete') this.extensions.emitTaskDeleted({ taskId: action.taskId })
@@ -474,6 +583,14 @@ export class TaskBoardHostService {
474
583
 
475
584
  dispose(): void {
476
585
  this.disposed = true
586
+ // Launches still waiting for a slot can never start now: settle them so
587
+ // their cards leave the running column here, instead of waiting for the
588
+ // next Host start's interrupted-start recovery. Launches already in flight
589
+ // are left to finish — their session may already exist.
590
+ const abandoned = this.launches.drain()
591
+ if (abandoned.length > 0) {
592
+ safeConsoleError(`[dsh-task-board] ${abandoned.length} run member(s) were still waiting for a launch slot when the Host stopped; each is settled as cancelled`)
593
+ }
477
594
  this.extensions.dispose()
478
595
  this.clearScheduleTimer()
479
596
  this.clearPollBackoff()
@@ -484,7 +601,7 @@ export class TaskBoardHostService {
484
601
  this.listeners.clear()
485
602
  }
486
603
 
487
- private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
604
+ private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext, freezeContract?: RunContractFreezer): Promise<void> {
488
605
  /** The session this launch attached to the execution, once it exists. */
489
606
  let attached: string | undefined
490
607
  try {
@@ -512,11 +629,16 @@ export class TaskBoardHostService {
512
629
  // retrofit the rule that will judge it. A card that opted out resolves the
513
630
  // same contract from an OFF switch: the board-wide switch decides the
514
631
  // default, the card decides this execution, and both are read here.
515
- const settings = this.verificationSettings()
516
- // The GLOBAL native-/goal switch is read ONCE here and carried by value
517
- // for the whole execution: a settings edit after this point can only
632
+ //
633
+ // The read is the run GROUP's when the queue handed one down: admission
634
+ // delays a member's launch, so a per-execution read would let a settings
635
+ // edit made while later members wait reach some members of one run and not
636
+ // others. One group, one contract — and still frozen before the group's
637
+ // first session is prompted.
638
+ const { settings, globalGoalEnabled, catalog } = await (freezeContract ?? (() => this.freezeRunContract()))()
639
+ // The GLOBAL native-/goal switch is read ONCE per run group and carried by
640
+ // value for the whole execution: a settings edit after this point can only
518
641
  // affect executions that start later.
519
- const globalGoalEnabled = this.goalRunEnabled() === true
520
642
  // The card must ASK for the goal run: the option is off by default, so an
521
643
  // absent value is a plain turn exactly like an explicit false.
522
644
  const goalEnabled = globalGoalEnabled && opened.task.goalRun === true
@@ -525,7 +647,7 @@ export class TaskBoardHostService {
525
647
  const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
526
648
  const contract = resolveContract(
527
649
  { ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
528
- await this.verificationCatalog(),
650
+ catalog,
529
651
  )
530
652
  // An execution that will not become a goal run can never fire the
531
653
  // completion gate, so it is marked with the reason that actually applied:
@@ -549,6 +671,8 @@ export class TaskBoardHostService {
549
671
  // The frozen global switch travels with the launch, so the runner's
550
672
  // arming decision is exactly the one made above.
551
673
  goalEnabled,
674
+ // The same catalog snapshot the acceptance contract was frozen from.
675
+ catalog,
552
676
  // Bind the session to the execution before the prompt is queued, so the
553
677
  // completion gate can never observe the agent without its binding.
554
678
  onSession: (id) => {
@@ -573,16 +697,60 @@ export class TaskBoardHostService {
573
697
  // failed launch can never silently spend the user's run budget. A manual
574
698
  // run, and a launch that DID reach a session, are never refunded.
575
699
  if (schedule !== undefined && attached === undefined && launchedSession === undefined) {
576
- this.ledger.refundScheduledOccurrence(opened.task.id, this.now())
700
+ this.refundUnstartedSchedule(opened.task.id)
577
701
  }
578
702
  this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
579
703
  }
580
704
  }
581
705
 
706
+ /**
707
+ * Read the acceptance contract one run group freezes, in the order the board
708
+ * has always read it: the acceptance settings, then the global native-/goal
709
+ * switch, then the model catalog that both resolves the contract's route and
710
+ * decides the reasoning effort the session may be given.
711
+ */
712
+ private async freezeRunContract(): Promise<FrozenRunContract> {
713
+ const settings = this.verificationSettings()
714
+ const globalGoalEnabled = this.goalRunEnabled() === true
715
+ const catalog = await this.verificationCatalog()
716
+ return { settings, globalGoalEnabled, catalog }
717
+ }
718
+
719
+ /**
720
+ * A {@link RunContractFreezer} that reads once and hands the SAME value to
721
+ * every later caller of one run group. A read that fails is not cached: the
722
+ * next member reads again, exactly as a per-execution read would have.
723
+ */
724
+ private contractFreezerForRunGroup(): RunContractFreezer {
725
+ let pending: Promise<FrozenRunContract> | undefined
726
+ return () => {
727
+ pending ??= this.freezeRunContract().catch((error: unknown) => {
728
+ pending = undefined
729
+ throw error
730
+ })
731
+ return pending
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Give back the occurrence a launch consumed, for a launch that never reached
737
+ * a session: a failed launch, or one the Host abandoned while it was still
738
+ * waiting for an admission slot. A manual run, and a launch that DID reach a
739
+ * session, are never refunded.
740
+ */
741
+ private refundUnstartedSchedule(taskId: string): void {
742
+ this.ledger.refundScheduledOccurrence(taskId, this.now())
743
+ // The refund runs after the fire already armed the next target, and a
744
+ // refunded occurrence can restore one the fire had just cleared (a rule
745
+ // that spent its last run). Re-arm so the restored occurrence is served
746
+ // at its own instant rather than lost until some unrelated write.
747
+ this.refreshSchedule()
748
+ }
749
+
582
750
  private scheduleTeammate(opened: OpenedRun, leadSessionId: string): void {
583
- void this.spawnTeammate(opened, leadSessionId).catch(error => {
751
+ this.enqueueLaunch(opened, () => this.spawnTeammate(opened, leadSessionId).catch(error => {
584
752
  safeConsoleError('[dsh-task-board] teammate spawn settlement failed', error)
585
- })
753
+ }))
586
754
  }
587
755
 
588
756
  /**
@@ -966,14 +1134,22 @@ export class TaskBoardHostService {
966
1134
  * member then launches on its own so one refused participant cannot hold the
967
1135
  * others back. A team run's members are spawned inside the root's Lead
968
1136
  * session instead, once that session exists.
1137
+ *
1138
+ * Every launch of one run set goes through the admission queue, which admits
1139
+ * at most `runLaunchWidth` of them at a time — the whole subtree still opens,
1140
+ * members just enter the running column in batches. Order inside the group is
1141
+ * the order they are enqueued here, so the root's session always exists before
1142
+ * any member's, and one member's launch settling (or failing) is what admits
1143
+ * the next. All members share the one contract this group freezes.
969
1144
  */
970
1145
  private dispatchRuns(runs: readonly OpenedRun[], schedule?: ScheduledRunContext): void {
971
1146
  if (runs.length === 0) return
972
1147
  const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
973
1148
  const others = runs.filter(run => run !== root)
974
- this.scheduleLaunch(root, others, schedule)
1149
+ const freezeContract = this.contractFreezerForRunGroup()
1150
+ this.scheduleLaunch(root, others, schedule, freezeContract)
975
1151
  for (const run of others) {
976
- if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule)
1152
+ if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule, freezeContract)
977
1153
  }
978
1154
  }
979
1155
 
@@ -987,10 +1163,58 @@ export class TaskBoardHostService {
987
1163
  this.ledger.setVerification(taskId, executionId, { ...verification, applicability })
988
1164
  }
989
1165
 
990
- private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
991
- void this.launch(opened, others, schedule).catch(error => {
1166
+ private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext, freezeContract?: RunContractFreezer): void {
1167
+ this.enqueueLaunch(opened, () => this.launch(opened, others, schedule, freezeContract).catch(error => {
992
1168
  safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
993
- })
1169
+ }), schedule)
1170
+ }
1171
+
1172
+ /**
1173
+ * Hand one launch to the admission queue, or abandon it on the spot when the
1174
+ * Host is already stopping: a queue entry created after {@link dispose} would
1175
+ * otherwise never be drained, and its card would sit in the running column
1176
+ * until the next Host start recovered it.
1177
+ */
1178
+ private enqueueLaunch(opened: OpenedRun, start: () => Promise<void>, schedule?: ScheduledRunContext): void {
1179
+ const launch: QueuedLaunch = {
1180
+ groupId: opened.execution.runGroupId ?? opened.task.id,
1181
+ start: async () => {
1182
+ // Waiting for a slot is time the execution can be settled in: a manual
1183
+ // settle, or a team run folded onto its Lead's verdict, can close a
1184
+ // member's execution before it ever started. Starting it then would
1185
+ // create a session no execution is watching, so a launch whose
1186
+ // execution is already closed is dropped instead.
1187
+ if (!this.isExecutionOpen(opened)) return
1188
+ await start()
1189
+ },
1190
+ abandon: () => this.abandonQueuedRun(opened, schedule),
1191
+ }
1192
+ if (this.disposed) {
1193
+ launch.abandon()
1194
+ return
1195
+ }
1196
+ this.launches.enqueue(launch)
1197
+ }
1198
+
1199
+ /** Whether one opened execution is still open in the ledger. */
1200
+ private isExecutionOpen(opened: OpenedRun): boolean {
1201
+ const execution = this.ledger.getTask(opened.task.id)?.executions.find(entry => entry.id === opened.execution.id)
1202
+ return execution !== undefined && execution.endedAt === undefined
1203
+ }
1204
+
1205
+ /**
1206
+ * Take one launch that never started out of the running column.
1207
+ *
1208
+ * Settled as CANCELLED, not failed: nothing about the run's work went wrong —
1209
+ * the Host stopped before the launch got a slot, which is exactly what the
1210
+ * next start's interrupted-start recovery records for an execution that never
1211
+ * reached a session. A scheduled occurrence that never reached a session is
1212
+ * refunded on the same terms as any other pre-session failure, so a run the
1213
+ * Host never started cannot silently spend the user's run budget.
1214
+ */
1215
+ private abandonQueuedRun(opened: OpenedRun, schedule?: ScheduledRunContext): void {
1216
+ if (schedule !== undefined) this.refundUnstartedSchedule(opened.task.id)
1217
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'cancelled', ABANDONED_LAUNCH_REASON)
994
1218
  }
995
1219
 
996
1220
  private schedulePoll(): void {
package/src/index.ts CHANGED
@@ -18,10 +18,12 @@ import type {} from '@deepseek-ai/dsh-tools'
18
18
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
19
19
  import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
20
20
  import { TaskBoardHostService, type HostTimerFace, type TaskBoardTeamDispatcher } from './host-service.ts'
21
+ import { createOwnerPeer } from './host-runner.ts'
21
22
  import { parseTaskDraft, TaskParseError } from './host-ai.ts'
22
23
  import { TASK_PERMISSIONS, isTaskPermission, type TaskPermission } from './core/tasks.ts'
23
24
  import { DEFAULT_SUBTASK_DEPTH, SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from './core/subtask.ts'
24
25
  import { DEFAULT_SESSION_POLL_SECONDS, SESSION_POLL_MAX_SECONDS, SESSION_POLL_MIN_SECONDS } from './core/poll-cadence.ts'
26
+ import { DEFAULT_RUN_LAUNCH_WIDTH, RUN_LAUNCH_WIDTH_MAX, RUN_LAUNCH_WIDTH_MIN } from './core/launch-width.ts'
25
27
  import {
26
28
  DEFAULT_VERIFICATION_BUDGET_SECONDS,
27
29
  DEFAULT_VERIFICATION_CALL_TIMEOUT_SECONDS,
@@ -33,6 +35,7 @@ import {
33
35
  verificationCallTimeoutMs,
34
36
  } from './core/verification-budget.ts'
35
37
  import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts'
38
+ import type { CardDefaults } from './core/card-defaults.ts'
36
39
  import { buildTaskBoardTools, TASK_BOARD_TOOL_NAMES } from './host/agent-tools.ts'
37
40
  import { PLUGIN_TOOL_SECTION_ORDERS, visibleToolText } from './tool-surface.ts'
38
41
  import { makeTaskBoardRoutes } from './host-routes.ts'
@@ -106,6 +109,17 @@ export interface Config {
106
109
  * at all, so this cadence only bounds how quickly a running card settles.
107
110
  */
108
111
  sessionPollSeconds?: Volatile<number>
112
+ /**
113
+ * How many run launches this board may have in flight at once (1..16,
114
+ * default 3). One run opens its whole subtree, so without a bound the Host
115
+ * asks DSH for every member's session in the same tick; this is the mechanism
116
+ * that enforces the repository's measured guidance for heavy implementation
117
+ * subagents. Members wait their turn instead of being dropped: the subtree
118
+ * still opens completely, they only enter the running column in batches. Read
119
+ * at each admission, so an edit bounds the next launch of a run that is still
120
+ * queueing.
121
+ */
122
+ runLaunchWidth?: Volatile<number>
109
123
  /**
110
124
  * Continuable-subagent provider the Agent Teams service uses to compose a
111
125
  * teammate. Matches the Agent Teams tool plugin's `freshProvider` default;
@@ -128,6 +142,33 @@ export interface Config {
128
142
  * becomes a goal run.
129
143
  */
130
144
  goalRunEnabled?: Volatile<boolean>
145
+ /**
146
+ * Agent preset a NEW card starts from (volatile, default blank = not set).
147
+ * A prefill, never a pin: the create dialog opens with it selected, the
148
+ * caller may override it, and the card stores what it was created with. It
149
+ * reaches the create path only when that caller opts in with
150
+ * `applyCardDefaults`, so the extension provider's own create and the legacy
151
+ * import are untouched.
152
+ */
153
+ cardDefaultMode?: Volatile<string>
154
+ /**
155
+ * Permission a NEW card starts from (volatile, default blank = not set), one
156
+ * of the three presets. A prefill like the rest: a value above
157
+ * `sessionDefaultPermission` still has to be confirmed by a human before the
158
+ * card may run, because the pre-execution gate is what decides that.
159
+ */
160
+ cardDefaultPermission?: Volatile<TaskPermission | ''>
161
+ /**
162
+ * Model a NEW card starts from (volatile, default blank = not set).
163
+ */
164
+ cardDefaultModel?: Volatile<string>
165
+ /**
166
+ * Reasoning level a NEW card starts from (volatile, default blank = not set).
167
+ * Bound to `cardDefaultModel` exactly as a card's own level is bound to its
168
+ * own model: it applies only while the card's final model IS that default
169
+ * model, and a blank `cardDefaultModel` makes it inert.
170
+ */
171
+ cardDefaultReasoningEffort?: Volatile<string>
131
172
  /**
132
173
  * Goal acceptance for executions this board starts (default ON). When on,
133
174
  * update_goal(action: complete) inside a task execution is refused until an
@@ -205,10 +246,20 @@ export interface ConfigInput {
205
246
  maxSubtaskDepth?: number
206
247
  /** Roster-poll cadence in seconds while the board has work to reconcile. */
207
248
  sessionPollSeconds?: number
249
+ /** Concurrent run launches allowed at once, 1..16. */
250
+ runLaunchWidth?: number
208
251
  /** Continuable-subagent provider the Agent Teams service composes a teammate from. */
209
252
  teamProvider?: string
210
253
  /** Global native /goal switch; default off, frozen per execution. */
211
254
  goalRunEnabled?: boolean
255
+ /** Agent preset a new card starts from; blank = not set. */
256
+ cardDefaultMode?: string
257
+ /** Permission a new card starts from; blank = not set. */
258
+ cardDefaultPermission?: TaskPermission | ''
259
+ /** Model a new card starts from; blank = not set. */
260
+ cardDefaultModel?: string
261
+ /** Reasoning level a new card starts from; blank = not set. */
262
+ cardDefaultReasoningEffort?: string
212
263
  /** Goal acceptance switch. */
213
264
  goalVerification?: boolean
214
265
  /** Judge model route for goal acceptance; blank inherits the host default. */
@@ -230,8 +281,13 @@ export const Config: z<ConfigInput, Config> = z.object({
230
281
  sessionDefaultPermission: z.union(TASK_PERMISSIONS),
231
282
  maxSubtaskDepth: z.number().min(SUBTASK_DEPTH_MIN).max(SUBTASK_DEPTH_MAX).default(DEFAULT_SUBTASK_DEPTH).volatile(),
232
283
  sessionPollSeconds: z.number().min(SESSION_POLL_MIN_SECONDS).max(SESSION_POLL_MAX_SECONDS).default(DEFAULT_SESSION_POLL_SECONDS).volatile(),
284
+ runLaunchWidth: z.number().min(RUN_LAUNCH_WIDTH_MIN).max(RUN_LAUNCH_WIDTH_MAX).default(DEFAULT_RUN_LAUNCH_WIDTH).volatile(),
233
285
  teamProvider: z.string().min(1).default(DEFAULT_TEAM_PROVIDER),
234
286
  goalRunEnabled: z.boolean().default(false).volatile(),
287
+ cardDefaultMode: z.string().default('').volatile(),
288
+ cardDefaultPermission: z.union([...TASK_PERMISSIONS, '']).default('').volatile(),
289
+ cardDefaultModel: z.string().default('').volatile(),
290
+ cardDefaultReasoningEffort: z.string().default('').volatile(),
235
291
  goalVerification: z.boolean().default(true).volatile(),
236
292
  goalVerificationModel: z.string().default('').volatile(),
237
293
  goalVerificationReasoningEffort: z.string().default('').volatile(),
@@ -465,6 +521,70 @@ export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined {
465
521
  }
466
522
  }
467
523
 
524
+ /**
525
+ * The subset of `sessionController` the board uses to wake a cold persisted
526
+ * session before running a slash command on it (issue #1861).
527
+ */
528
+ interface SessionControllerFace {
529
+ resolveAgent(
530
+ sessionId: Parameters<Context['agents']['get']>[0],
531
+ ): Promise<
532
+ | { readonly agent: Parameters<Context['commands']['execute']>[0] }
533
+ | { readonly error: { readonly message?: string } }
534
+ >
535
+ }
536
+
537
+ /**
538
+ * Resolve the optional `sessionController` service. Like the tool registry and
539
+ * the llm runtime, it is read dynamically so capture-only test contexts that do
540
+ * not mount the full session controller still activate the board.
541
+ * @param ctx - the plugin context.
542
+ * @returns the session controller face, or undefined when unavailable.
543
+ */
544
+ export function resolveSessionController(ctx: Context): SessionControllerFace | undefined {
545
+ try {
546
+ const controller = (ctx as { get?: (name: string) => unknown }).get?.('sessionController') as SessionControllerFace | undefined
547
+ return controller !== undefined && typeof controller.resolveAgent === 'function' ? controller : undefined
548
+ } catch {
549
+ return undefined
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Resolve the live Agent for an execution session, resuming a cold persisted
555
+ * session through `sessionController.resolveAgent` when it is not yet in
556
+ * `ctx.agents` (issue #1861).
557
+ *
558
+ * A fresh run (`session/create`) or a session the browser is currently
559
+ * following is already in `ctx.agents`, so the fast path returns immediately.
560
+ * A scheduled or manual reuse of a cold session — one that `session/list`
561
+ * reports as idle from disk without activating its Agent — is not in
562
+ * `ctx.agents` when `pinAndPrompt` runs `/permission` ahead of
563
+ * `session/selectModel` and `session/prompt`; delegating to
564
+ * `sessionController.resolveAgent` resumes it with its recorded preset and
565
+ * model-selection state, matching what those gateway endpoints do next.
566
+ * @param ctx - the plugin context.
567
+ * @param sessionId - the target execution session id.
568
+ * @returns the live Agent for `sessionId`.
569
+ */
570
+ export async function resolveExecutionAgent(
571
+ ctx: Context,
572
+ sessionId: Parameters<Context['agents']['get']>[0],
573
+ ): Promise<Parameters<Context['commands']['execute']>[0]> {
574
+ const live = ctx.agents.get(sessionId)
575
+ if (live !== undefined) return live
576
+ const controller = resolveSessionController(ctx)
577
+ if (controller !== undefined) {
578
+ const resolved = await controller.resolveAgent(sessionId)
579
+ if ('agent' in resolved && resolved.agent !== undefined) return resolved.agent
580
+ const detail = 'error' in resolved && typeof resolved.error?.message === 'string' && resolved.error.message !== ''
581
+ ? `: ${resolved.error.message}`
582
+ : ''
583
+ throw new Error(`execution session ${sessionId} is not available${detail}`)
584
+ }
585
+ throw new Error(`execution session ${sessionId} is not available`)
586
+ }
587
+
468
588
  /**
469
589
  * Activate the board's host half: ledger, routes, cron scheduler, power
470
590
  * inhibitor, and the model-facing announcement.
@@ -491,6 +611,12 @@ function applyImpl(ctx: Context, config?: Config): void {
491
611
  const maxSubtaskDepth = (): number => readConfigField(config?.maxSubtaskDepth, DEFAULT_SUBTASK_DEPTH)
492
612
  /** Current roster-poll cadence in seconds (read live: the settings card edits it in place). */
493
613
  const sessionPollSeconds = (): number => readConfigField(config?.sessionPollSeconds, DEFAULT_SESSION_POLL_SECONDS)
614
+ /**
615
+ * Current run-launch admission width. Read live on every admission, so the
616
+ * settings card bounds the next launch of a run that is still queueing without
617
+ * remounting the row; the Host clamps a hand-edited value into the schema range.
618
+ */
619
+ const runLaunchWidth = (): number => readConfigField(config?.runLaunchWidth, DEFAULT_RUN_LAUNCH_WIDTH)
494
620
  /**
495
621
  * The baseline the permission confirmation gate judges against: the row's own
496
622
  * `sessionDefaultPermission` when the deployment pins one, otherwise the Host's
@@ -529,19 +655,40 @@ function applyImpl(ctx: Context, config?: Config): void {
529
655
  * executions that start afterwards.
530
656
  */
531
657
  const goalRunEnabled = (): boolean => readConfigField(config?.goalRunEnabled, DEFAULT_GOAL_RUN)
658
+ /**
659
+ * Live reader of the new-card defaults, read at every use like the switch
660
+ * above: the create dialog prefills from the published snapshot, and each
661
+ * creating session re-reads them when it submits the create. A blank value
662
+ * means "not set" and reaches the resolver as an absent target.
663
+ */
664
+ const cardDefaults = (): CardDefaults => {
665
+ const permission = readConfigField<TaskPermission | ''>(config?.cardDefaultPermission, '')
666
+ return {
667
+ mode: readConfigField(config?.cardDefaultMode, '').trim() || undefined,
668
+ permission: isTaskPermission(permission) ? permission : undefined,
669
+ model: readConfigField(config?.cardDefaultModel, '').trim() || undefined,
670
+ reasoningEffort: readConfigField(config?.cardDefaultReasoningEffort, '').trim() || undefined,
671
+ }
672
+ }
673
+ // The 0.2.1 gateway requires every request to name the peer it speaks for:
674
+ // the board calls as the host-local owner, so its peer is minted on this
675
+ // fiber and aborts with it (see createOwnerPeer).
676
+ const ownerPeer = createOwnerPeer(ctx)
532
677
  const host = new TaskBoardHostService(ctx.typertGateway, {
533
678
  workspaceRegistry: ctx.workspaceRegistry,
679
+ ownerPeer,
534
680
  sessionDefaultPermission,
535
681
  maxSubtaskDepth: maxSubtaskDepth(),
536
682
  goalRunEnabled,
683
+ runLaunchWidth,
684
+ cardDefaults,
537
685
  verificationSettings,
538
686
  verificationCatalog,
539
687
  timers: resolveHostTimers(ctx),
540
688
  team: buildTeamDispatcher(ctx, config?.teamProvider ?? DEFAULT_TEAM_PROVIDER),
541
689
  commandDispatcher: {
542
690
  async execute(sessionId, line, signal) {
543
- const agent = ctx.agents.get(sessionId)
544
- if (agent === undefined) throw new Error(`execution session ${sessionId} is not available`)
691
+ const agent = await resolveExecutionAgent(ctx, sessionId)
545
692
  return (await ctx.commands.execute(agent, line, [], signal))?.result
546
693
  },
547
694
  },