@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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Admission control for the session-creating half of a run.
3
+ *
4
+ * One board action can open a whole subtree, and a team run hands every member
5
+ * of it to the Lead at once. Both paths used to fire every launch in the same
6
+ * tick, so the Host asked DSH for N sessions at once — the burst the repository's
7
+ * measured guidance caps at three heavy implementation subagents
8
+ * (docs/multi-agent-resources.md), with no mechanism enforcing it.
9
+ *
10
+ * The queue is SERVICE-wide, not per run group: the pressure it bounds is the
11
+ * machine's (the Host process's memory and the swap it drives), and two run
12
+ * groups opened from two tabs share one Host. Admission is round-robin across
13
+ * run groups — FIFO inside a group, so a group's first run (the Lead, which
14
+ * carries the run shape and the teammate namespace) always starts before its
15
+ * members — so a large subtree cannot starve a run opened after it, and no
16
+ * entry can be starved.
17
+ *
18
+ * The width is read through a live callback, so a settings edit takes effect on
19
+ * the next admission without remounting anything.
20
+ *
21
+ * @module dsh-task-board/host/launch-admission
22
+ */
23
+
24
+ /** One launch the queue has not started yet. */
25
+ export interface QueuedLaunch {
26
+ /** Run group this launch belongs to; the unit of round-robin fairness. */
27
+ readonly groupId: string
28
+ /**
29
+ * Perform the launch. The slot is held until this promise settles, which
30
+ * makes "in flight" mean "still creating and prompting its session".
31
+ * A rejection is swallowed here so one broken launch can never wedge the
32
+ * slot; the caller owns reporting.
33
+ */
34
+ start(): Promise<void>
35
+ /**
36
+ * Called by {@link LaunchAdmissionQueue.drain} for a launch that never
37
+ * started, so the caller can take its execution out of the running column.
38
+ */
39
+ abandon(): void
40
+ }
41
+
42
+ export class LaunchAdmissionQueue {
43
+ /** Waiting launches per run group, in arrival order. */
44
+ private readonly waiting = new Map<string, QueuedLaunch[]>()
45
+ /** Run groups in round-robin order. */
46
+ private rotation: string[] = []
47
+ /** Slot holders of the launches in flight, one per admitted launch. */
48
+ private readonly inFlight = new Set<() => void>()
49
+ /** Highest number of launches in flight at once; observability and tests. */
50
+ peakInFlight = 0
51
+
52
+ /**
53
+ * @param width - live admission width (already normalized by the caller).
54
+ */
55
+ constructor(private readonly width: () => number) {}
56
+
57
+ /** Launches the queue has not admitted yet. */
58
+ get pendingCount(): number {
59
+ let total = 0
60
+ for (const queue of this.waiting.values()) total += queue.length
61
+ return total
62
+ }
63
+
64
+ /** Launches currently holding a slot. */
65
+ get inFlightCount(): number {
66
+ return this.inFlight.size
67
+ }
68
+
69
+ /** Admit one launch, immediately if a slot is free. */
70
+ enqueue(launch: QueuedLaunch): void {
71
+ const queue = this.waiting.get(launch.groupId)
72
+ if (queue === undefined) {
73
+ this.waiting.set(launch.groupId, [launch])
74
+ this.rotation.push(launch.groupId)
75
+ } else {
76
+ queue.push(launch)
77
+ }
78
+ this.pump()
79
+ }
80
+
81
+ /**
82
+ * Abandon every waiting launch: the queue is being torn down, so an entry
83
+ * that never started can never start. Launches already in flight are left
84
+ * alone — their session may already exist, and the callers that own them
85
+ * settle them through the normal path.
86
+ * @returns the abandoned launches, in abandonment order.
87
+ */
88
+ drain(): QueuedLaunch[] {
89
+ const abandoned: QueuedLaunch[] = []
90
+ for (const groupId of this.rotation) {
91
+ for (const launch of this.waiting.get(groupId) ?? []) abandoned.push(launch)
92
+ }
93
+ this.rotation = []
94
+ this.waiting.clear()
95
+ for (const launch of abandoned) launch.abandon()
96
+ return abandoned
97
+ }
98
+
99
+ private pump(): void {
100
+ while (this.inFlight.size < this.width()) {
101
+ const launch = this.takeNext()
102
+ if (launch === undefined) return
103
+ this.admit(launch)
104
+ }
105
+ }
106
+
107
+ /** The next launch by round-robin across groups and FIFO inside one. */
108
+ private takeNext(): QueuedLaunch | undefined {
109
+ while (this.rotation.length > 0) {
110
+ const groupId = this.rotation.shift()
111
+ if (groupId === undefined) return undefined
112
+ const queue = this.waiting.get(groupId)
113
+ if (queue === undefined || queue.length === 0) {
114
+ this.waiting.delete(groupId)
115
+ continue
116
+ }
117
+ const launch = queue.shift()
118
+ if (launch === undefined) continue
119
+ if (queue.length === 0) this.waiting.delete(groupId)
120
+ // A group that still has work goes to the back of the rotation, so every
121
+ // other run group gets its turn before this one's next member.
122
+ else this.rotation.push(groupId)
123
+ return launch
124
+ }
125
+ return undefined
126
+ }
127
+
128
+ private admit(launch: QueuedLaunch): void {
129
+ const release = (): void => {
130
+ this.inFlight.delete(release)
131
+ this.pump()
132
+ }
133
+ this.inFlight.add(release)
134
+ if (this.inFlight.size > this.peakInFlight) this.peakInFlight = this.inFlight.size
135
+ void Promise.resolve()
136
+ .then(() => launch.start())
137
+ .catch(() => { /* a rejected launch still releases its slot; the caller reports it */ })
138
+ .then(release)
139
+ }
140
+ }
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_SUBTASK_DEPTH,
11
11
  cascadeTargets,
12
12
  combineCascadeOutcome,
13
+ joinsCascadeRun,
13
14
  normalizeSubtaskDepth,
14
15
  openGroupExecution,
15
16
  pendingCascadeChildren,
@@ -17,6 +18,7 @@ import {
17
18
  } from './core/subtask.ts'
18
19
  import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
19
20
  import { applyCreateTask } from './core/use-cases/task-create.ts'
21
+ import type { CardDefaults } from './core/card-defaults.ts'
20
22
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
21
23
  import { applySetSchedule, applyScheduleProgress, applyScheduleRefund, type ScheduleProgress } from './core/use-cases/task-schedule.ts'
22
24
  import { applySetParent } from './core/use-cases/task-parent.ts'
@@ -488,11 +490,15 @@ export class HostTaskLedger {
488
490
  */
489
491
  private depthLimit: number
490
492
 
491
- constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
493
+ /** Live reader of the deployment's new-card defaults (a volatile config read). */
494
+ private readonly cardDefaults: () => CardDefaults
495
+
496
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number; cardDefaults?: () => CardDefaults } = {}) {
492
497
  // Resolved before the load/repair passes below: they can evaluate the gate.
493
498
  const baseline = options.sessionDefaultPermission
494
499
  this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
495
500
  this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
501
+ this.cardDefaults = options.cardDefaults ?? (() => ({}))
496
502
  mkdirSync(dir, { recursive: true })
497
503
  this.file = join(dir, 'ledger-v2.json')
498
504
  this.lockFile = join(dir, 'ledger-v2.lock')
@@ -974,7 +980,7 @@ export class HostTaskLedger {
974
980
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
975
981
  ? action.input
976
982
  : { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
977
- const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
983
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone(), this.cardDefaults())
978
984
  if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
979
985
  this.document.tasks = [...result.tasks]
980
986
  break
@@ -1262,10 +1268,11 @@ export class HostTaskLedger {
1262
1268
  }
1263
1269
 
1264
1270
  /**
1265
- * Open one execution per cascade participant (the requested task and its
1266
- * on-board descendants, within the depth limit) under a single run group, and
1267
- * commit the ledger once. The returned runs are ordered root-first, so the
1268
- * Host launches the parent before its subtasks.
1271
+ * Open one execution per cascade participant that still has work (the
1272
+ * requested task and its on-board descendants, within the depth limit) under
1273
+ * a single run group, and commit the ledger once. A descendant in 'done' is
1274
+ * not a participant ({@link joinsCascadeRun}). The returned runs are ordered
1275
+ * root-first, so the Host launches the parent before its subtasks.
1269
1276
  * @param root - the task the user ran.
1270
1277
  * @param now - clock instant (ms epoch).
1271
1278
  * @param initiator - the DSH session that asked for the run (audit only).
@@ -1285,6 +1292,11 @@ export class HostTaskLedger {
1285
1292
  const runs: OpenedRun[] = []
1286
1293
  for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
1287
1294
  if (task.archivedAt !== undefined) continue
1295
+ // A subtask that already sits in 'done' is left alone: running the parent
1296
+ // repeats the parent, never the work a subtask has already finished. The
1297
+ // rule is the card's column, so it holds for a manual run, a re-run and a
1298
+ // due occurrence alike.
1299
+ if (!joinsCascadeRun(task, root.id)) continue
1288
1300
  if (hasOpenExecution(task)) continue
1289
1301
  const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
1290
1302
  const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
@@ -1,9 +1,12 @@
1
- import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
1
+ import { Context } from '@deepseek-ai/cordis'
2
+ import type { InvokeRemoteRequest, TypertGateway } from '@deepseek-ai/dsh-api-gateway'
2
3
  import type { SessionAddress, SessionHistoryRecord, SessionListValue, SessionPage, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/types'
3
4
  import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
4
5
  import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
5
6
  import { teammateName } from './core/subtask.ts'
6
7
  import { mostRecentWorkspaceId } from './core/workspace-target.ts'
8
+ import { declaredRoute, normalizeReasoningEffort, resolveReasoningEffort, type ReasoningRoute } from './core/reasoning-effort.ts'
9
+ import type { ModelCatalogView } from './core/verification.ts'
7
10
  import type { TaskPermission, TaskRecord } from './core/tasks.ts'
8
11
 
9
12
  /**
@@ -14,13 +17,69 @@ export interface TaskBoardWorkspaceRegistry {
14
17
  list(): readonly Workspace[]
15
18
  }
16
19
 
20
+ /**
21
+ * The peer every gateway request names: derived from the public
22
+ * `InvokeRemoteRequest` type so this package needs no direct dependency on
23
+ * `@deepseek-ai/dsh-typert-protocol` for the shape.
24
+ */
25
+ export type GatewayPeer = InvokeRemoteRequest['peer']
26
+
17
27
  interface GatewayRequest {
18
28
  namespace: string
19
29
  method: string
20
30
  args: Record<string, unknown>
31
+ peer: GatewayPeer
21
32
  signal?: AbortSignal
22
33
  }
23
34
 
35
+ /**
36
+ * Mint the in-process owner peer this plugin's Host calls speak for.
37
+ *
38
+ * The 0.2.1 gateway made `peer` mandatory on every request, and the
39
+ * authorization layer reads only `peer.principal`: a principal of
40
+ * `{ kind: 'owner' }` is how host-local code states that the call IS the local
41
+ * user driving their own Host, so the gateway answers it directly instead of
42
+ * running a remote-access decision (only non-owner principals are judged).
43
+ * That is exactly the task board's position — it creates, renames, prompts,
44
+ * and reads sessions as the Host itself, which before 0.2.1 was the implicit
45
+ * privilege of every in-process caller. The board claims owner explicitly, and
46
+ * only ever inside its own Host process: no wire field can set a principal,
47
+ * and this plugin never forwards a browser-supplied one.
48
+ *
49
+ * `interactions` stays `'none'`: the board only invokes methods and opens the
50
+ * short `session/follow` stream. The gateway's interactions check gates
51
+ * exactly the forwarded-event endpoints (opening an event stream, answering a
52
+ * waterfall), which this caller never touches; an `'answer'` peer would
53
+ * additionally receive forwarded Host events, a capability the board neither
54
+ * needs nor wants.
55
+ *
56
+ * The peer's signal aborts once `ctx` disposes, so streams opened for it end
57
+ * with the plugin fiber, and `dispose()` is idempotent with racing calls
58
+ * sharing one completion. The id carries no credential — the protocol fixes
59
+ * `principal` as the only identity persisted in a session log — so a fresh
60
+ * unique id per mount is sufficient.
61
+ */
62
+ export function createOwnerPeer(ctx: Context): GatewayPeer {
63
+ const controller = new AbortController()
64
+ let completion: Promise<void> | undefined
65
+ const peer: GatewayPeer = {
66
+ id: `task-board-owner:${crypto.randomUUID()}` as GatewayPeer['id'],
67
+ ctx,
68
+ principal: { kind: 'owner' },
69
+ signal: controller.signal,
70
+ interactions: 'none',
71
+ dispose() {
72
+ if (completion === undefined) {
73
+ controller.abort()
74
+ completion = Promise.resolve()
75
+ }
76
+ return completion
77
+ },
78
+ }
79
+ ctx.effect(() => () => { void peer.dispose() }, 'task-board owner peer')
80
+ return peer
81
+ }
82
+
24
83
  interface SessionGateway {
25
84
  invoke(request: GatewayRequest): Promise<unknown>
26
85
  stream?(request: GatewayRequest): Promise<AsyncIterable<unknown>>
@@ -313,10 +372,20 @@ function invokeWireArgs(namespace: string, method: string, request: Record<strin
313
372
  }
314
373
 
315
374
  export class HostExecutionRunner {
316
- /** Newest scanned event sequence per session with no matching execution end. */
375
+ /**
376
+ * Newest event sequence per session whose COMPLETE history walk (reaching
377
+ * the execution boundary) found no matching execution end. A later walk may
378
+ * trust everything at or below it and examine only newer records.
379
+ */
317
380
  private readonly scanMemos = new Map<string, number>()
318
381
  private readonly unavailableAttempts: number
319
382
  private readonly unavailableBackoffMs: number
383
+ /**
384
+ * The peer every request speaks for. Production passes the peer bound to the
385
+ * plugin fiber; a bare runner (unit tests around fake gateways) gets one
386
+ * bound to a fresh root context instead.
387
+ */
388
+ private readonly ownerPeer: GatewayPeer
320
389
  private unsupportedSessionListWarned = false
321
390
 
322
391
  constructor(
@@ -324,18 +393,20 @@ export class HostExecutionRunner {
324
393
  private readonly commands?: SessionCommandDispatcher,
325
394
  private readonly workspaceRegistry?: TaskBoardWorkspaceRegistry,
326
395
  unavailableRetry?: { attempts?: number; backoffMs?: number },
396
+ ownerPeer?: GatewayPeer,
327
397
  ) {
328
398
  this.unavailableAttempts = unavailableRetry?.attempts ?? SERVICE_UNAVAILABLE_ATTEMPTS
329
399
  this.unavailableBackoffMs = unavailableRetry?.backoffMs ?? SERVICE_UNAVAILABLE_BACKOFF_MS
400
+ this.ownerPeer = ownerPeer ?? createOwnerPeer(new Context())
330
401
  }
331
402
 
332
403
  private invoke(namespace: string, method: string, request: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
333
- return this.gateway.invoke({ namespace, method, args: invokeWireArgs(namespace, method, request), ...(signal === undefined ? {} : { signal }) })
404
+ return this.gateway.invoke({ namespace, method, args: invokeWireArgs(namespace, method, request), peer: this.ownerPeer, ...(signal === undefined ? {} : { signal }) })
334
405
  }
335
406
 
336
407
  private stream(namespace: string, method: string, request: Record<string, unknown>, signal?: AbortSignal): Promise<AsyncIterable<unknown>> {
337
408
  if (!('stream' in this.gateway) || this.gateway.stream === undefined) throw new Error('gateway stream is unavailable')
338
- return this.gateway.stream({ namespace, method, args: { request }, ...(signal === undefined ? {} : { signal }) })
409
+ return this.gateway.stream({ namespace, method, args: { request }, peer: this.ownerPeer, ...(signal === undefined ? {} : { signal }) })
339
410
  }
340
411
 
341
412
  /**
@@ -377,6 +448,14 @@ export class HostExecutionRunner {
377
448
  * `goalRun` still applies on top of it (both must ASK for a goal).
378
449
  */
379
450
  goalEnabled?: boolean
451
+ /**
452
+ * The host model catalog, read ONCE per execution by the caller. The card's
453
+ * pinned reasoning effort is sent only when this catalog declares it for the
454
+ * route the execution runs at, so one snapshot decides both the acceptance
455
+ * contract and the effort this run freezes. Absent means "no capability
456
+ * evidence": an effort is then not sent at all.
457
+ */
458
+ catalog?: ModelCatalogView
380
459
  } = {}): Promise<string> {
381
460
  // A handover bundle overrides the legacy pin fields: the bundle is the
382
461
  // authoritative execution triplet for a continuation card (issue #5). A
@@ -417,7 +496,7 @@ export class HostExecutionRunner {
417
496
  // at the gate without its binding.
418
497
  options.onSession?.(reused)
419
498
  await this.assertReusedPreset(reused, mode)
420
- await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true)
499
+ await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true, options.catalog)
421
500
  } catch (error) {
422
501
  throw new SessionLaunchError(reused, error)
423
502
  }
@@ -431,7 +510,7 @@ export class HostExecutionRunner {
431
510
  options.onSession?.(sessionId)
432
511
  try {
433
512
  await this.invoke('session', 'rename', { sessionId, title: task.title })
434
- await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true)
513
+ await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true, options.catalog)
435
514
  } catch (error) {
436
515
  throw new SessionLaunchError(sessionId, error)
437
516
  }
@@ -466,10 +545,59 @@ export class HostExecutionRunner {
466
545
  )
467
546
  }
468
547
 
548
+ /**
549
+ * Split a `provider/model` pin. A pin without a provider keeps only the model
550
+ * id: the provider is the one the host catalog declares for it, resolved at
551
+ * launch because the session wire contract requires a provider.
552
+ */
553
+ private static pinnedRoute(model: string | undefined): ReasoningRoute | undefined {
554
+ const raw = model?.trim()
555
+ if (raw === undefined || raw === '') return undefined
556
+ const slash = raw.indexOf('/')
557
+ if (slash < 0) return { model: raw }
558
+ const provider = raw.slice(0, slash).trim()
559
+ const modelId = raw.slice(slash + 1).trim()
560
+ if (provider === '' || modelId === '') return undefined
561
+ return { provider, model: modelId }
562
+ }
563
+
564
+ /**
565
+ * The route a card that pins no model actually runs at: the session's own
566
+ * recorded selection (the durable fold the GUI reads; `next` is the pending
567
+ * user choice, `lastUsed` what the last request consumed), or — for a session
568
+ * that has selected none yet, which is exactly a session that would start at
569
+ * the deployment default — the host default from the catalog.
570
+ *
571
+ * A route that cannot be READ is undefined and the effort is not sent:
572
+ * falling back to the host default there would silently switch the model of a
573
+ * session this run only meant to continue.
574
+ */
575
+ private async sessionRoute(sessionId: ExecutionSessionId, catalog?: ModelCatalogView): Promise<ReasoningRoute | undefined> {
576
+ let selection: { provider?: unknown; model?: unknown } | null | undefined
577
+ try {
578
+ const projections = await this.invoke('session', 'projections', { sessionId }) as
579
+ | { values?: { modelSelection?: { lastUsed?: { provider?: unknown; model?: unknown } | null; next?: { provider?: unknown; model?: unknown } | null } } }
580
+ | null
581
+ selection = projections?.values?.modelSelection?.next ?? projections?.values?.modelSelection?.lastUsed
582
+ } catch (error) {
583
+ console.warn('[dsh-task-board] session/projections failed while resolving the execution model route', error)
584
+ return undefined
585
+ }
586
+ if (typeof selection?.provider === 'string' && selection.provider !== ''
587
+ && typeof selection.model === 'string' && selection.model !== '') {
588
+ return { provider: selection.provider, model: selection.model }
589
+ }
590
+ return catalog?.default === undefined
591
+ ? undefined
592
+ : { provider: catalog.default.provider, model: catalog.default.model }
593
+ }
594
+
469
595
  /**
470
596
  * Re-assert the pinned execution contract on a session and queue the task
471
597
  * prompt. Shared by the fresh-session and reuse paths so both apply exactly
472
- * the same permission/model pins before the prompt.
598
+ * the same permission/model/effort pins before the prompt. A session that
599
+ * refuses a selection carrying a catalog-confirmed effort throws here, so the
600
+ * prompt is never queued behind a run at the wrong level.
473
601
  */
474
602
  private async pinAndPrompt(
475
603
  sessionId: ExecutionSessionId,
@@ -478,6 +606,7 @@ export class HostExecutionRunner {
478
606
  context: PromptContext = {},
479
607
  onGoalArmed?: (armed: boolean) => void,
480
608
  goalEnabled = false,
609
+ catalog?: ModelCatalogView,
481
610
  ): Promise<void> {
482
611
  if (permission !== undefined) {
483
612
  if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
@@ -485,19 +614,43 @@ export class HostExecutionRunner {
485
614
  if (command === undefined) throw new Error('permission command was not acknowledged')
486
615
  if (command.kind !== 'success') throw new Error(command.text ?? 'permission command failed')
487
616
  }
488
- if (task.model !== undefined && task.model.trim() !== '') {
489
- const rawModel = task.model.trim()
490
- const slashIdx = rawModel.indexOf('/')
491
- const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : undefined
492
- const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel
617
+ // The effort is bound to the model route the execution runs at. A card that
618
+ // pins no model of its own runs at the session's route, so that route is
619
+ // resolved first — and only when there is an effort to place: a card that
620
+ // never chose one must not gain a model-selection call it did not have.
621
+ const requestedEffort = normalizeReasoningEffort(task.reasoningEffort)
622
+ const pinned = HostExecutionRunner.pinnedRoute(task.model)
623
+ const route = pinned ?? (requestedEffort === undefined ? undefined : await this.sessionRoute(sessionId, catalog))
624
+ if (route !== undefined) {
625
+ const routeLabel = `${route.provider === undefined ? '' : route.provider + '/'}${route.model}`
626
+ const decision = resolveReasoningEffort(requestedEffort, catalog, route)
627
+ if (decision.dropped !== undefined) {
628
+ console.warn(`[dsh-task-board] model route ${routeLabel} does not declare reasoning effort "${decision.dropped}"; session ${sessionId} runs at the model default`)
629
+ }
630
+ // The catalog completes a bare model pin into the provider the session
631
+ // request must name; an undeclared pin keeps its own spelling exactly as
632
+ // before, minus an effort that could not be verified.
633
+ const declared = declaredRoute(catalog, route)
634
+ const provider = declared?.provider ?? route.provider
635
+ const modelId = declared?.model ?? route.model
493
636
  try {
494
637
  await this.invoke('session', 'selectModel', {
495
638
  sessionId,
496
- ...(provider ? { provider } : {}),
639
+ ...(provider === undefined ? {} : { provider }),
497
640
  model: modelId,
641
+ ...(decision.effort === undefined ? {} : { reasoningEffort: decision.effort }),
498
642
  })
499
643
  } catch (modelError) {
500
- console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError)
644
+ // An effort the catalog CONFIRMED for this route is a contract, not a
645
+ // preference: if the session refuses the selection, the run fails here,
646
+ // before the prompt, rather than silently executing at the model default
647
+ // the user did not pick. A selection that carries no confirmed effort
648
+ // keeps the best-effort behavior: the card was asked to run, and losing
649
+ // the work over a model switch this run never promised is worse.
650
+ if (decision.effort !== undefined) {
651
+ throw new Error(`session ${sessionId} refused the model selection "${provider === undefined ? modelId : provider + '/' + modelId}" with reasoning effort "${decision.effort}": ` + (modelError instanceof Error ? modelError.message : String(modelError)))
652
+ }
653
+ console.warn(`[dsh-task-board] failed to select model "${provider === undefined ? modelId : provider + '/' + modelId}" for session ${sessionId}, falling back to default:`, modelError)
501
654
  }
502
655
  }
503
656
  await this.invoke('session', 'prompt', {
@@ -704,10 +857,21 @@ export class HostExecutionRunner {
704
857
  }
705
858
  const openingEvents = opening.records.map(record => ({ event: recordEvent(record) }))
706
859
  const newestSeq = openingEvents.reduce<number | undefined>((newest, entry) => newest === undefined ? entry.event.seq : Math.max(newest, entry.event.seq), undefined)
707
- if (newestSeq !== undefined && this.scanMemos.get(sessionId) === newestSeq) return { outcome: 'pending' }
860
+ const scannedThrough = this.scanMemos.get(sessionId)
861
+ if (newestSeq !== undefined && scannedThrough === newestSeq) return { outcome: 'pending' }
862
+ // The memo is not only "nothing changed": it is the seq up to which a
863
+ // COMPLETE walk already proved this execution has no completed turn. A
864
+ // session log is append-only, so records at or below it can never acquire
865
+ // one — this pass only has to examine what arrived after it. Without that,
866
+ // a working teammate (whose roster entry never stops reporting `running`,
867
+ // so every poll re-inspects it) re-read its whole history since the run
868
+ // started once per poll: a 1200-record history cost 12 page RPCs and 1223
869
+ // materialized records per session per poll, against 0 and 21 here.
870
+ const oldestOpeningSeq = openingEvents.reduce<number | undefined>((oldest, entry) => oldest === undefined ? entry.event.seq : Math.min(oldest, entry.event.seq), undefined)
871
+ const coveredThrough = scannedThrough !== undefined && oldestOpeningSeq !== undefined && oldestOpeningSeq <= scannedThrough
708
872
  const events: Array<{ event: { type: string; seq: number; time: number; data: unknown } }> = [...openingEvents]
709
873
  let beforeSeq: number | undefined
710
- let reachedExecutionBoundary = !opening.hasMore
874
+ let reachedExecutionBoundary = !opening.hasMore || coveredThrough
711
875
  for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
712
876
  let history: SessionPage
713
877
  try {
@@ -724,11 +888,13 @@ export class HostExecutionRunner {
724
888
  const pageEntries = pageEvents(history)
725
889
  events.push(...pageEntries)
726
890
  const oldestTime = pageEntries.reduce<number | undefined>((oldest, entry) => oldest === undefined ? entry.event.time : Math.min(oldest, entry.event.time), undefined)
727
- if (!history.hasMore || (oldestTime !== undefined && oldestTime <= startedAt)) {
891
+ const oldestSeq = pageEntries.reduce<number | undefined>((oldest, entry) => oldest === undefined ? entry.event.seq : Math.min(oldest, entry.event.seq), undefined)
892
+ if (!history.hasMore
893
+ || (oldestTime !== undefined && oldestTime <= startedAt)
894
+ || (scannedThrough !== undefined && oldestSeq !== undefined && oldestSeq <= scannedThrough)) {
728
895
  reachedExecutionBoundary = true
729
896
  break
730
897
  }
731
- const oldestSeq = pageEntries.reduce<number | undefined>((oldest, entry) => oldest === undefined ? entry.event.seq : Math.min(oldest, entry.event.seq), undefined)
732
898
  if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
733
899
  beforeSeq = oldestSeq
734
900
  }