@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.
Files changed (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -18
  3. package/README.zh.md +34 -18
  4. package/lib/client.js +2804 -1024
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2576 -416
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -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/index.d.ts +4 -5
  15. package/lib/types/client/locales.d.ts +81 -0
  16. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  17. package/lib/types/client/schedule-zone.d.ts +50 -0
  18. package/lib/types/core/card-defaults.d.ts +78 -0
  19. package/lib/types/core/controller.d.ts +41 -17
  20. package/lib/types/core/launch-width.d.ts +31 -0
  21. package/lib/types/core/poll-cadence.d.ts +31 -0
  22. package/lib/types/core/reasoning-effort.d.ts +57 -0
  23. package/lib/types/core/subtask.d.ts +30 -8
  24. package/lib/types/core/tasks.d.ts +163 -14
  25. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  26. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  27. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  28. package/lib/types/core/verification-budget.d.ts +52 -0
  29. package/lib/types/core/verification.d.ts +376 -9
  30. package/lib/types/host/launch-admission.d.ts +72 -0
  31. package/lib/types/host/verification-gate.d.ts +24 -0
  32. package/lib/types/host/verification-runner.d.ts +38 -7
  33. package/lib/types/host-ledger.d.ts +91 -11
  34. package/lib/types/host-runner.d.ts +111 -13
  35. package/lib/types/host-service.d.ts +199 -8
  36. package/lib/types/index.d.ts +141 -0
  37. package/lib/types/protocol.d.ts +63 -12
  38. package/package.json +29 -33
  39. package/src/client/CardDefaultPresetField.tsx +101 -0
  40. package/src/client/TaskBoardSettingsCard.tsx +288 -17
  41. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  42. package/src/client/board/NewTaskModal.tsx +237 -60
  43. package/src/client/board/ReasoningEffortField.tsx +50 -0
  44. package/src/client/board/TaskCard.tsx +3 -3
  45. package/src/client/board/TaskDetail.tsx +261 -24
  46. package/src/client/board/VerificationReport.tsx +132 -13
  47. package/src/client/board/model-options.ts +178 -0
  48. package/src/client/board/preset-label.ts +32 -0
  49. package/src/client/board/preset-roster.ts +58 -0
  50. package/src/client/board/status-key.ts +3 -0
  51. package/src/client/board/team-run-promotion.ts +39 -0
  52. package/src/client/index.ts +105 -108
  53. package/src/client/locales.ts +171 -6
  54. package/src/client/plugin-card-seat.ts +7 -0
  55. package/src/client/schedule-zone.ts +100 -2
  56. package/src/client/settings-entry-form.ts +0 -1
  57. package/src/core/card-defaults.ts +132 -0
  58. package/src/core/controller.ts +55 -16
  59. package/src/core/launch-width.ts +38 -0
  60. package/src/core/poll-cadence.ts +41 -0
  61. package/src/core/reasoning-effort.ts +92 -0
  62. package/src/core/store.ts +69 -15
  63. package/src/core/subtask.ts +48 -13
  64. package/src/core/tasks.ts +213 -39
  65. package/src/core/use-cases/task-create.ts +57 -25
  66. package/src/core/use-cases/task-schedule.ts +190 -25
  67. package/src/core/use-cases/task-update.ts +9 -4
  68. package/src/core/verification-budget.ts +115 -0
  69. package/src/core/verification.ts +785 -17
  70. package/src/host/agent-tools.ts +127 -29
  71. package/src/host/launch-admission.ts +140 -0
  72. package/src/host/verification-gate.ts +172 -16
  73. package/src/host/verification-runner.ts +207 -40
  74. package/src/host-ledger.ts +346 -48
  75. package/src/host-runner.ts +234 -35
  76. package/src/host-service.ts +539 -52
  77. package/src/index.ts +250 -7
  78. package/src/protocol.ts +160 -32
  79. package/lib/invariant.js +0 -6
  80. package/lib/types/client/board-card-seat.d.ts +0 -34
  81. package/lib/types/invariant.d.ts +0 -3
  82. package/src/client/board-card-seat.ts +0 -102
  83. package/src/invariant.ts +0 -4
@@ -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>>
@@ -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 cron rule actually fired and the
163
- * IANA zone its wall clock was read in. Present only for a cron-triggered
164
- * run, so the model can resolve an unqualified date in the run's own terms
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; cron: 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 cron
188
- * rule rather than by a person. It states the firing instant, the rule's zone,
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
- /** 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
+ */
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: { reuseSessionId?: string; promptContext?: PromptContext; onSession?: (sessionId: string) => void; onGoalArmed?: (armed: boolean) => void } = {}): Promise<string> {
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
- if (task.model !== undefined && task.model.trim() !== '') {
467
- const rawModel = task.model.trim()
468
- const slashIdx = rawModel.indexOf('/')
469
- const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : undefined
470
- 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
471
636
  try {
472
637
  await this.invoke('session', 'selectModel', {
473
638
  sessionId,
474
- ...(provider ? { provider } : {}),
639
+ ...(provider === undefined ? {} : { provider }),
475
640
  model: modelId,
641
+ ...(decision.effort === undefined ? {} : { reasoningEffort: decision.effort }),
476
642
  })
477
643
  } catch (modelError) {
478
- 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)
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
- * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
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 that
518
- * plain turn: the task was asked to run, and a missing goal mode must not
519
- * lose the work.
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 (task.goalRun === false) return false
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
- 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
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
- 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)) {
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
  }