@linxin666/dsh-client-ui-task-board 0.4.5 → 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 +34 -18
- package/README.zh.md +34 -18
- package/lib/client.js +2804 -1024
- package/lib/client.js.map +1 -1
- package/lib/index.js +2576 -416
- package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -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/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +81 -0
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/core/card-defaults.d.ts +78 -0
- package/lib/types/core/controller.d.ts +41 -17
- package/lib/types/core/launch-width.d.ts +31 -0
- package/lib/types/core/poll-cadence.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 +163 -14
- package/lib/types/core/use-cases/task-create.d.ts +10 -5
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- package/lib/types/host/launch-admission.d.ts +72 -0
- package/lib/types/host/verification-gate.d.ts +24 -0
- package/lib/types/host/verification-runner.d.ts +38 -7
- package/lib/types/host-ledger.d.ts +91 -11
- package/lib/types/host-runner.d.ts +111 -13
- package/lib/types/host-service.d.ts +199 -8
- package/lib/types/index.d.ts +141 -0
- package/lib/types/protocol.d.ts +63 -12
- package/package.json +29 -33
- package/src/client/CardDefaultPresetField.tsx +101 -0
- package/src/client/TaskBoardSettingsCard.tsx +288 -17
- package/src/client/board/LinkSubtaskModal.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +237 -60
- package/src/client/board/ReasoningEffortField.tsx +50 -0
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +261 -24
- package/src/client/board/VerificationReport.tsx +132 -13
- 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/status-key.ts +3 -0
- package/src/client/board/team-run-promotion.ts +39 -0
- package/src/client/index.ts +105 -108
- package/src/client/locales.ts +171 -6
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/settings-entry-form.ts +0 -1
- package/src/core/card-defaults.ts +132 -0
- package/src/core/controller.ts +55 -16
- package/src/core/launch-width.ts +38 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/reasoning-effort.ts +92 -0
- package/src/core/store.ts +69 -15
- package/src/core/subtask.ts +48 -13
- package/src/core/tasks.ts +213 -39
- package/src/core/use-cases/task-create.ts +57 -25
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +9 -4
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +127 -29
- package/src/host/launch-admission.ts +140 -0
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +346 -48
- package/src/host-runner.ts +234 -35
- package/src/host-service.ts +539 -52
- package/src/index.ts +250 -7
- package/src/protocol.ts +160 -32
- package/lib/invariant.js +0 -6
- package/lib/types/client/board-card-seat.d.ts +0 -34
- package/lib/types/invariant.d.ts +0 -3
- package/src/client/board-card-seat.ts +0 -102
- package/src/invariant.ts +0 -4
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>>
|
|
@@ -159,12 +218,12 @@ export interface PromptContext {
|
|
|
159
218
|
/** True when those members run as teammates inside this session (Team Lead). */
|
|
160
219
|
team?: boolean
|
|
161
220
|
/**
|
|
162
|
-
* Scheduled-run provenance: the instant the
|
|
163
|
-
* IANA zone its wall clock was read in. Present only for a
|
|
164
|
-
* run, so the model can resolve an unqualified date in the
|
|
165
|
-
* instead of guessing from the Host clock (issue #1722).
|
|
221
|
+
* Scheduled-run provenance: the instant the rule actually fired, its kind,
|
|
222
|
+
* and the IANA zone its wall clock was read in. Present only for a
|
|
223
|
+
* timer-triggered run, so the model can resolve an unqualified date in the
|
|
224
|
+
* run's own terms instead of guessing from the Host clock (issue #1722).
|
|
166
225
|
*/
|
|
167
|
-
schedule?: { triggeredAt: number; timeZone: string;
|
|
226
|
+
schedule?: { triggeredAt: number; timeZone: string; mode: 'cron' | 'once'; cron?: string }
|
|
168
227
|
}
|
|
169
228
|
|
|
170
229
|
/**
|
|
@@ -184,16 +243,20 @@ function peerPromptPreamble(context: PromptContext): string | undefined {
|
|
|
184
243
|
}
|
|
185
244
|
|
|
186
245
|
/**
|
|
187
|
-
* The scheduled-run section: this execution was triggered by the card's
|
|
188
|
-
*
|
|
189
|
-
* and the rule text, so the run's own clock is unambiguous — the board's
|
|
246
|
+
* The scheduled-run section: this execution was triggered by the card's
|
|
247
|
+
* schedule rather than by a person. It states the firing instant, the rule's
|
|
248
|
+
* zone, and the rule text, so the run's own clock is unambiguous — the board's
|
|
190
249
|
* scheduler fires on a wall clock in a specific IANA zone, which is not
|
|
191
|
-
* necessarily the zone of whatever session the run lands in.
|
|
250
|
+
* necessarily the zone of whatever session the run lands in. A one-shot says
|
|
251
|
+
* so explicitly, so the run does not expect a recurrence.
|
|
192
252
|
*/
|
|
193
253
|
function schedulePromptPreamble(context: PromptContext): string | undefined {
|
|
194
254
|
const schedule = context.schedule
|
|
195
255
|
if (schedule === undefined) return undefined
|
|
196
256
|
const stamp = new Date(schedule.triggeredAt).toISOString()
|
|
257
|
+
if (schedule.mode === 'once') {
|
|
258
|
+
return `本次执行由任务看板的单次定时自动触发:计划时间 ${stamp}(UTC),时区 ${schedule.timeZone}。这是一次性计划,本次执行之后该卡片不会再自动执行。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`
|
|
259
|
+
}
|
|
197
260
|
return `本次执行由任务看板的定时规则自动触发:触发时间 ${stamp}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`
|
|
198
261
|
}
|
|
199
262
|
|
|
@@ -309,10 +372,20 @@ function invokeWireArgs(namespace: string, method: string, request: Record<strin
|
|
|
309
372
|
}
|
|
310
373
|
|
|
311
374
|
export class HostExecutionRunner {
|
|
312
|
-
/**
|
|
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
|
+
*/
|
|
313
380
|
private readonly scanMemos = new Map<string, number>()
|
|
314
381
|
private readonly unavailableAttempts: number
|
|
315
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
|
|
316
389
|
private unsupportedSessionListWarned = false
|
|
317
390
|
|
|
318
391
|
constructor(
|
|
@@ -320,18 +393,20 @@ export class HostExecutionRunner {
|
|
|
320
393
|
private readonly commands?: SessionCommandDispatcher,
|
|
321
394
|
private readonly workspaceRegistry?: TaskBoardWorkspaceRegistry,
|
|
322
395
|
unavailableRetry?: { attempts?: number; backoffMs?: number },
|
|
396
|
+
ownerPeer?: GatewayPeer,
|
|
323
397
|
) {
|
|
324
398
|
this.unavailableAttempts = unavailableRetry?.attempts ?? SERVICE_UNAVAILABLE_ATTEMPTS
|
|
325
399
|
this.unavailableBackoffMs = unavailableRetry?.backoffMs ?? SERVICE_UNAVAILABLE_BACKOFF_MS
|
|
400
|
+
this.ownerPeer = ownerPeer ?? createOwnerPeer(new Context())
|
|
326
401
|
}
|
|
327
402
|
|
|
328
403
|
private invoke(namespace: string, method: string, request: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
329
|
-
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 }) })
|
|
330
405
|
}
|
|
331
406
|
|
|
332
407
|
private stream(namespace: string, method: string, request: Record<string, unknown>, signal?: AbortSignal): Promise<AsyncIterable<unknown>> {
|
|
333
408
|
if (!('stream' in this.gateway) || this.gateway.stream === undefined) throw new Error('gateway stream is unavailable')
|
|
334
|
-
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 }) })
|
|
335
410
|
}
|
|
336
411
|
|
|
337
412
|
/**
|
|
@@ -356,7 +431,32 @@ export class HostExecutionRunner {
|
|
|
356
431
|
* @param options - optional session to continue in.
|
|
357
432
|
* @returns the session id the execution runs in.
|
|
358
433
|
*/
|
|
359
|
-
async launch(task: TaskRecord, options: {
|
|
434
|
+
async launch(task: TaskRecord, options: {
|
|
435
|
+
reuseSessionId?: string
|
|
436
|
+
promptContext?: PromptContext
|
|
437
|
+
onSession?: (sessionId: string) => void
|
|
438
|
+
onGoalArmed?: (armed: boolean) => void
|
|
439
|
+
/**
|
|
440
|
+
* GLOBAL native /goal switch, resolved by the caller ONCE per execution and
|
|
441
|
+
* carried here so the decision is frozen for this launch.
|
|
442
|
+
*
|
|
443
|
+
* Frozen deliberately: a settings edit made while the run is in flight must
|
|
444
|
+
* not arm, disarm or otherwise change the goal/acceptance contract of a run
|
|
445
|
+
* already running. Absent means off — the default for every deployment that
|
|
446
|
+
* never touched the switch, including cards written before the option
|
|
447
|
+
* existed. A plain single turn is what an off switch produces; the task-level
|
|
448
|
+
* `goalRun` still applies on top of it (both must ASK for a goal).
|
|
449
|
+
*/
|
|
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
|
|
459
|
+
} = {}): Promise<string> {
|
|
360
460
|
// A handover bundle overrides the legacy pin fields: the bundle is the
|
|
361
461
|
// authoritative execution triplet for a continuation card (issue #5). A
|
|
362
462
|
// card that pins nothing is resolved to the deployment's most recently used
|
|
@@ -396,7 +496,7 @@ export class HostExecutionRunner {
|
|
|
396
496
|
// at the gate without its binding.
|
|
397
497
|
options.onSession?.(reused)
|
|
398
498
|
await this.assertReusedPreset(reused, mode)
|
|
399
|
-
await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed)
|
|
499
|
+
await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true, options.catalog)
|
|
400
500
|
} catch (error) {
|
|
401
501
|
throw new SessionLaunchError(reused, error)
|
|
402
502
|
}
|
|
@@ -410,7 +510,7 @@ export class HostExecutionRunner {
|
|
|
410
510
|
options.onSession?.(sessionId)
|
|
411
511
|
try {
|
|
412
512
|
await this.invoke('session', 'rename', { sessionId, title: task.title })
|
|
413
|
-
await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed)
|
|
513
|
+
await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed, options.goalEnabled === true, options.catalog)
|
|
414
514
|
} catch (error) {
|
|
415
515
|
throw new SessionLaunchError(sessionId, error)
|
|
416
516
|
}
|
|
@@ -445,10 +545,59 @@ export class HostExecutionRunner {
|
|
|
445
545
|
)
|
|
446
546
|
}
|
|
447
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
|
+
|
|
448
595
|
/**
|
|
449
596
|
* Re-assert the pinned execution contract on a session and queue the task
|
|
450
597
|
* prompt. Shared by the fresh-session and reuse paths so both apply exactly
|
|
451
|
-
* 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.
|
|
452
601
|
*/
|
|
453
602
|
private async pinAndPrompt(
|
|
454
603
|
sessionId: ExecutionSessionId,
|
|
@@ -456,6 +605,8 @@ export class HostExecutionRunner {
|
|
|
456
605
|
permission: TaskPermission | undefined,
|
|
457
606
|
context: PromptContext = {},
|
|
458
607
|
onGoalArmed?: (armed: boolean) => void,
|
|
608
|
+
goalEnabled = false,
|
|
609
|
+
catalog?: ModelCatalogView,
|
|
459
610
|
): Promise<void> {
|
|
460
611
|
if (permission !== undefined) {
|
|
461
612
|
if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
|
|
@@ -463,19 +614,43 @@ export class HostExecutionRunner {
|
|
|
463
614
|
if (command === undefined) throw new Error('permission command was not acknowledged')
|
|
464
615
|
if (command.kind !== 'success') throw new Error(command.text ?? 'permission command failed')
|
|
465
616
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
471
636
|
try {
|
|
472
637
|
await this.invoke('session', 'selectModel', {
|
|
473
638
|
sessionId,
|
|
474
|
-
...(provider ? {
|
|
639
|
+
...(provider === undefined ? {} : { provider }),
|
|
475
640
|
model: modelId,
|
|
641
|
+
...(decision.effort === undefined ? {} : { reasoningEffort: decision.effort }),
|
|
476
642
|
})
|
|
477
643
|
} catch (modelError) {
|
|
478
|
-
|
|
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)
|
|
479
654
|
}
|
|
480
655
|
}
|
|
481
656
|
await this.invoke('session', 'prompt', {
|
|
@@ -484,7 +659,7 @@ export class HostExecutionRunner {
|
|
|
484
659
|
mode: 'queue' as const,
|
|
485
660
|
content: [{ type: 'text' as const, text: promptText(task, context) }],
|
|
486
661
|
})
|
|
487
|
-
const armed = await this.armGoal(sessionId, task, context)
|
|
662
|
+
const armed = await this.armGoal(sessionId, task, context, goalEnabled)
|
|
488
663
|
onGoalArmed?.(armed)
|
|
489
664
|
}
|
|
490
665
|
|
|
@@ -512,14 +687,25 @@ export class HostExecutionRunner {
|
|
|
512
687
|
* dsh's goal-round driver keeps starting continuation rounds until the agent
|
|
513
688
|
* marks it complete (see {@link goalVerdict} for how the run settles).
|
|
514
689
|
*
|
|
515
|
-
*
|
|
690
|
+
* Two switches gate the arming, and BOTH must ask for it:
|
|
691
|
+
* - the GLOBAL native-/goal switch (`goalEnabled`, default OFF), frozen when
|
|
692
|
+
* this execution started; and
|
|
693
|
+
* - the task's own `goalRun` (absent means off, only an explicit true opts
|
|
694
|
+
* the card into a goal run).
|
|
695
|
+
*
|
|
516
696
|
* A refusal (no command dispatcher, no `/goal` command in this cohort, an
|
|
517
|
-
* objective the command rejects) is reported and the run continues as
|
|
518
|
-
*
|
|
519
|
-
*
|
|
697
|
+
* objective the command rejects) is reported and the run continues as a plain
|
|
698
|
+
* turn: the task was asked to run, and a missing goal mode must not lose the
|
|
699
|
+
* work.
|
|
700
|
+
* @param sessionId - the session to arm in.
|
|
701
|
+
* @param task - the task being run.
|
|
702
|
+
* @param context - the run's prompt context.
|
|
703
|
+
* @param goalEnabled - the GLOBAL switch, frozen for this execution.
|
|
704
|
+
* @returns whether `/goal` was really armed.
|
|
520
705
|
*/
|
|
521
|
-
private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<boolean> {
|
|
522
|
-
if (
|
|
706
|
+
private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext, goalEnabled: boolean): Promise<boolean> {
|
|
707
|
+
if (!goalEnabled) return false
|
|
708
|
+
if (task.goalRun !== true) return false
|
|
523
709
|
if (this.commands === undefined) {
|
|
524
710
|
console.warn('[dsh-task-board] no command dispatcher is available; task ' + task.id + ' runs without /goal')
|
|
525
711
|
return false
|
|
@@ -671,10 +857,21 @@ export class HostExecutionRunner {
|
|
|
671
857
|
}
|
|
672
858
|
const openingEvents = opening.records.map(record => ({ event: recordEvent(record) }))
|
|
673
859
|
const newestSeq = openingEvents.reduce<number | undefined>((newest, entry) => newest === undefined ? entry.event.seq : Math.max(newest, entry.event.seq), undefined)
|
|
674
|
-
|
|
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
|
|
675
872
|
const events: Array<{ event: { type: string; seq: number; time: number; data: unknown } }> = [...openingEvents]
|
|
676
873
|
let beforeSeq: number | undefined
|
|
677
|
-
let reachedExecutionBoundary = !opening.hasMore
|
|
874
|
+
let reachedExecutionBoundary = !opening.hasMore || coveredThrough
|
|
678
875
|
for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
|
|
679
876
|
let history: SessionPage
|
|
680
877
|
try {
|
|
@@ -691,11 +888,13 @@ export class HostExecutionRunner {
|
|
|
691
888
|
const pageEntries = pageEvents(history)
|
|
692
889
|
events.push(...pageEntries)
|
|
693
890
|
const oldestTime = pageEntries.reduce<number | undefined>((oldest, entry) => oldest === undefined ? entry.event.time : Math.min(oldest, entry.event.time), undefined)
|
|
694
|
-
|
|
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)) {
|
|
695
895
|
reachedExecutionBoundary = true
|
|
696
896
|
break
|
|
697
897
|
}
|
|
698
|
-
const oldestSeq = pageEntries.reduce<number | undefined>((oldest, entry) => oldest === undefined ? entry.event.seq : Math.min(oldest, entry.event.seq), undefined)
|
|
699
898
|
if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
|
|
700
899
|
beforeSeq = oldestSeq
|
|
701
900
|
}
|