@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.
- package/README.i18n.yaml +2 -2
- package/README.md +14 -6
- package/README.zh.md +14 -6
- package/lib/client.js +1502 -820
- package/lib/client.js.map +1 -1
- package/lib/index.js +674 -75
- package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +49 -0
- package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
- package/lib/types/client/board/model-options.d.ts +67 -0
- package/lib/types/client/board/preset-label.d.ts +21 -0
- package/lib/types/client/board/preset-roster.d.ts +45 -0
- package/lib/types/client/board/team-run-promotion.d.ts +26 -0
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/core/card-defaults.d.ts +78 -0
- package/lib/types/core/controller.d.ts +32 -7
- package/lib/types/core/launch-width.d.ts +31 -0
- package/lib/types/core/reasoning-effort.d.ts +57 -0
- package/lib/types/core/subtask.d.ts +30 -8
- package/lib/types/core/tasks.d.ts +19 -0
- package/lib/types/core/use-cases/task-create.d.ts +10 -5
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/launch-admission.d.ts +72 -0
- package/lib/types/host-ledger.d.ts +9 -4
- package/lib/types/host-runner.d.ts +79 -4
- package/lib/types/host-service.d.ts +85 -2
- package/lib/types/index.d.ts +87 -0
- package/lib/types/protocol.d.ts +13 -0
- package/package.json +28 -28
- package/src/client/CardDefaultPresetField.tsx +101 -0
- package/src/client/TaskBoardSettingsCard.tsx +214 -16
- package/src/client/board/LinkSubtaskModal.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +86 -40
- package/src/client/board/ReasoningEffortField.tsx +50 -0
- package/src/client/board/TaskDetail.tsx +42 -6
- package/src/client/board/model-options.ts +178 -0
- package/src/client/board/preset-label.ts +32 -0
- package/src/client/board/preset-roster.ts +58 -0
- package/src/client/board/team-run-promotion.ts +39 -0
- package/src/client/index.ts +98 -94
- package/src/client/locales.ts +34 -2
- package/src/client/settings-entry-form.ts +0 -1
- package/src/core/card-defaults.ts +132 -0
- package/src/core/controller.ts +44 -9
- package/src/core/launch-width.ts +38 -0
- package/src/core/reasoning-effort.ts +92 -0
- package/src/core/store.ts +5 -0
- package/src/core/subtask.ts +48 -13
- package/src/core/tasks.ts +20 -0
- package/src/core/use-cases/task-create.ts +27 -16
- package/src/core/use-cases/task-update.ts +6 -1
- package/src/host/agent-tools.ts +21 -8
- package/src/host/launch-admission.ts +140 -0
- package/src/host-ledger.ts +18 -6
- package/src/host-runner.ts +184 -18
- package/src/host-service.ts +247 -23
- package/src/index.ts +149 -2
- 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
|
+
}
|
package/src/host-ledger.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1266
|
-
* on-board descendants, within the depth limit) under
|
|
1267
|
-
* commit the ledger once.
|
|
1268
|
-
*
|
|
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)
|
package/src/host-runner.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import
|
|
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
|
-
/**
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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 ? {
|
|
639
|
+
...(provider === undefined ? {} : { provider }),
|
|
497
640
|
model: modelId,
|
|
641
|
+
...(decision.effort === undefined ? {} : { reasoningEffort: decision.effort }),
|
|
498
642
|
})
|
|
499
643
|
} catch (modelError) {
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|