@ljwei-stak/dsh-model-router 0.22.1 → 0.23.0

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.
@@ -12,6 +12,7 @@ import {
12
12
  DEFAULT_COOLDOWN_MINUTES, billingOverview, billingPlan, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
13
13
  } from './shared/subscription-billing.mjs'
14
14
  import { createRouterState } from './shared/router-state.mjs'
15
+ import { createRunJobs } from './shared/run-jobs.mjs'
15
16
  import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
16
17
  import { buildRoutingEvidence, parseRoutingEvidenceJson } from './shared/routing-evidence.mjs'
17
18
  import { apiReasoningRequest } from './shared/reasoning-execution.mjs'
@@ -28,7 +29,7 @@ import { applyManualPackagePlan } from './shared/manual-plan.mjs'
28
29
  import { parsePackageAssignments, packagePlanKey } from './shared/package-assignments.mjs'
29
30
  import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
30
31
  import { registerOfficialToolsRemote } from './official-tools-remote-service.mjs'
31
- import { parseRunRequest } from './shared/official-tools-remote.mjs'
32
+ import { parseRunRequest, parseRunJobRequest } from './shared/official-tools-remote.mjs'
32
33
  import { createTerminalManager } from './shared/cli-terminal.mjs'
33
34
  import {
34
35
  parseTerminalRead,
@@ -682,6 +683,7 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
682
683
  const reviewerQuality = routeQuality(reviewer)
683
684
  const reviews = []
684
685
  for (const item of run.packages) {
686
+ if (signal?.aborted) break
685
687
  if (!item.ok || !text(item.answer)) continue
686
688
  if (item.provider === reviewer.provider && item.model === reviewer.model) continue
687
689
  const own = routeQuality(routes.find(route => route.provider === item.provider && route.model === item.model))
@@ -708,16 +710,26 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
708
710
  run.reviews.push(entry)
709
711
  reviews.push({ ...entry, score })
710
712
  } catch (error) {
711
- if (signal?.aborted) throw error
713
+ if (signal?.aborted) break
712
714
  item.review = { provider: reviewer.provider, model: reviewer.model, score: null, summary: `审阅失败:${errorText(error).slice(0, 200)}`, at: Date.now() }
713
715
  }
714
716
  }
715
717
  return reviews
716
718
  }
717
719
 
720
+ async function reportRunProgress(options, event) {
721
+ try { if (typeof options.onProgress === 'function') await options.onProgress(event) }
722
+ catch { /* Observing progress must not discard results or incurred spend. */ }
723
+ }
724
+
718
725
  function executionHooks(ctx, options) {
719
726
  return {
720
727
  skipOfficial: options.skipOfficial ?? (request => healthCache.skipReason(request)),
728
+ onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
729
+ onPackageStart: async item => {
730
+ await reportRunProgress(options, { type: 'package-start', package: item })
731
+ if (typeof options.onPackageStart === 'function') await options.onPackageStart(item)
732
+ },
721
733
  onPackage: async result => {
722
734
  // A CLI that reports "not logged in" is marked; with a subscription the router then asks instead of using the API key.
723
735
  const failed = result?.toolId && (result.fallback?.loginRequired ? result.fallback.error : result.pause?.loginRequired && result.pause.kind !== 'subscription-login' ? result.pause.detail : null)
@@ -729,6 +741,7 @@ function executionHooks(ctx, options) {
729
741
  healthCache.clearLoggedOut(result.toolId)
730
742
  try { await routerStateStore().saveAuthFailure(result.toolId, null) } catch { /* the cache still serves this process */ }
731
743
  }
744
+ await reportRunProgress(options, { type: 'package-complete', package: result })
732
745
  if (typeof options.onPackage === 'function') await options.onPackage(result)
733
746
  },
734
747
  credentialsFor: route => credentialsForRoute(ctx, route),
@@ -857,7 +870,10 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
857
870
  }
858
871
  }
859
872
  const finished = []
860
- const hooks = executionHooks(ctx, { ...options, onPackage: result => { finished.push(result) } })
873
+ const hooks = executionHooks(ctx, { ...options, onPackage: async result => {
874
+ finished.push(result)
875
+ if (typeof options.onPackage === 'function') await options.onPackage(result)
876
+ } })
861
877
  const billing = await billingContext(config, options)
862
878
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
863
879
  const startedAt = Date.now()
@@ -934,7 +950,10 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
934
950
  billingFor: billingForResult,
935
951
  budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
936
952
  })
953
+ await reportRunProgress(options, { type: 'stage', stage: 'reviewing' })
937
954
  const reviews = await reviewRunPackages(ctx, run, routes, config, { signal: options.signal, random: options.random })
955
+ if (options.signal?.aborted) { run.status = 'cancelled'; execution.status = 'cancelled' }
956
+ await reportRunProgress(options, { type: 'stage', stage: 'saving' })
938
957
  let stored = true
939
958
  try { await routerStateStore().appendRun(run) } catch { stored = false }
940
959
  const awaiting = pausedSteps(execution)
@@ -1482,7 +1501,11 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
1482
1501
  const startedAt = Date.now()
1483
1502
  const execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
1484
1503
  allowedRoot: options.root ?? workspace, mode: planned.mode, executionEnvironment, installedIds: planned.installed,
1485
- cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox })
1504
+ cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox,
1505
+ onPackageStart: item => reportRunProgress(options, { type: 'package-start', package: item }),
1506
+ onPackage: item => reportRunProgress(options, { type: 'package-complete', package: item }),
1507
+ onStage: stage => reportRunProgress(options, { type: 'stage', stage }) })
1508
+ await reportRunProgress(options, { type: 'stage', stage: 'saving' })
1486
1509
  const run = await recordTeamRun({ task: planned.taskText, plan: planned.plan, execution, mode: planned.mode, executionEnvironment, workspace,
1487
1510
  routes: planned.routes, cliModels: planned.cliModels, budget, startedAt, config })
1488
1511
  return { ...common, status: execution.status, execution, run, runId: run?.id ?? null,
@@ -1720,15 +1743,26 @@ export async function previewWorkbenchRun(ctx, config, request = {}, options = {
1720
1743
  /** Execute from the workbench; refuses until every current reason is in confirmedReasons. */
1721
1744
  export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}) {
1722
1745
  const prepared = await prepareWorkbenchPreview(ctx, config, supplied, options)
1723
- const { preview, request } = prepared
1746
+ return refusedWorkbenchStart(prepared) ?? executePreparedWorkbenchRun(ctx, config, prepared, options)
1747
+ }
1748
+
1749
+ function refusedWorkbenchStart({ preview, request }) {
1724
1750
  if (preview.status === 'blocked') return preview
1725
- const confirmed = new Set(Array.isArray(request.confirmedReasons) ? request.confirmedReasons : [])
1751
+ const confirmed = new Set(request.confirmedReasons ?? [])
1726
1752
  const missing = preview.reasons.filter(item => !confirmed.has(item.code))
1727
- if (missing.length) return { ...preview, status: 'needs-confirmation', reasons: preview.reasons, missing: missing.map(item => item.code) }
1753
+ return missing.length ? { ...preview, status: 'needs-confirmation', reasons: preview.reasons,
1754
+ missing: missing.map(item => item.code) } : null
1755
+ }
1756
+
1757
+ async function executePreparedWorkbenchRun(ctx, config, prepared, options = {}) {
1758
+ const { preview, request } = prepared
1759
+ const confirmed = new Set(Array.isArray(request.confirmedReasons) ? request.confirmedReasons : [])
1760
+ throwIfAborted(options.signal)
1761
+ await reportRunProgress(options, { type: 'stage', stage: 'executing' })
1728
1762
  if (request.executor === 'official-team') {
1729
1763
  const result = await (options.executeOfficialTeam ?? executeOfficialTeamPlan)(ctx, config, prepared.planned, {
1730
- workspace: preview.workspace, root: preview.workspace, signal: options.signal,
1731
1764
  confirmOverBudget: confirmed.has('over-budget'), confirmUnsandboxed: confirmed.has('unsandboxed'), ...(options.teamOptions ?? {}),
1765
+ workspace: preview.workspace, root: preview.workspace, signal: options.signal, onProgress: options.onProgress,
1732
1766
  })
1733
1767
  return { status: result.status, runId: result.runId ?? null, workspace: preview.workspace,
1734
1768
  executor: 'official-team', executionMode: request.executionMode, executionEnvironment: preview.executionEnvironment, decision: preview.decision,
@@ -1740,8 +1774,8 @@ export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}
1740
1774
  provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
1741
1775
  ...manualRequestOptions(request),
1742
1776
  budgetUsd: finiteNumber(request.budgetUsd, finiteNumber(configuredBudget, 0)),
1743
- confirmOverBudget: confirmed.has('over-budget'), workspace: preview.workspace, signal: options.signal, ...options.planOptions,
1744
- preparedAssignment: prepared.planned,
1777
+ confirmOverBudget: confirmed.has('over-budget'), workspace: preview.workspace, ...options.planOptions,
1778
+ preparedAssignment: prepared.planned, signal: options.signal, onProgress: options.onProgress,
1745
1779
  })
1746
1780
  const awaiting = result.awaitingConfirmation ?? []
1747
1781
  return {
@@ -1751,6 +1785,25 @@ export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}
1751
1785
  }
1752
1786
  }
1753
1787
 
1788
+ /** Desktop starts an observable job after fresh preflight and confirmation checks. */
1789
+ export async function launchWorkbenchRun(ctx, config, supplied = {}, { runJobs, ...options } = {}) {
1790
+ if (!runJobs) throw new Error('工作台任务服务不可用,请重启 Harness 后重试。')
1791
+ const prepared = await prepareWorkbenchPreview(ctx, config, supplied, options)
1792
+ const refused = refusedWorkbenchStart(prepared)
1793
+ if (refused) return refused
1794
+ return runJobs.start({
1795
+ packages: prepared.preview.decision?.packages ?? [],
1796
+ run: async ({ signal, onProgress }) => {
1797
+ try { return await executePreparedWorkbenchRun(ctx, config, prepared, { ...options, signal, onProgress }) }
1798
+ catch (error) {
1799
+ if (error?.runId) return { status: signal.aborted ? 'cancelled' : 'failed', runId: error.runId,
1800
+ workspace: prepared.preview.workspace, notice: '执行未完成,已产生的结果与费用请查看执行记录。' }
1801
+ throw error
1802
+ }
1803
+ },
1804
+ })
1805
+ }
1806
+
1754
1807
  /** Host operations behind the workbench RPC; the client never supplies commands or paths. */
1755
1808
  /**
1756
1809
  * One terminal manager per Host plugin fiber. Sessions run outside the
@@ -1786,7 +1839,7 @@ function terminalServices(terminals, assertPreferences = () => {}) {
1786
1839
  }
1787
1840
  }
1788
1841
 
1789
- export function routerRemoteServices(ctx, config, { terminals = null, assertPreferences } = {}) {
1842
+ export function routerRemoteServices(ctx, config, { terminals = null, assertPreferences, runJobs = createRunJobs(), runOptions = {} } = {}) {
1790
1843
  return {
1791
1844
  ...terminalServices(terminals, assertPreferences),
1792
1845
  health: async fresh => {
@@ -1804,8 +1857,10 @@ export function routerRemoteServices(ctx, config, { terminals = null, assertPref
1804
1857
  ...(request?.subscriptionChoice ? { subscriptionChoice: request.subscriptionChoice } : {}),
1805
1858
  }),
1806
1859
  boundaries: () => securityBoundaries(ctx, config),
1807
- previewRun: request => previewWorkbenchRun(ctx, config, request),
1808
- startRun: request => startWorkbenchRun(ctx, config, request),
1860
+ previewRun: request => previewWorkbenchRun(ctx, config, request, runOptions),
1861
+ startRun: request => launchWorkbenchRun(ctx, config, request, { ...runOptions, runJobs }),
1862
+ runStatus: request => runJobs.status(parseRunJobRequest(request).jobId),
1863
+ cancelRun: request => runJobs.cancel(parseRunJobRequest(request).jobId),
1809
1864
  }
1810
1865
  }
1811
1866
 
@@ -1835,14 +1890,15 @@ export function apply(ctx, config = {}) {
1835
1890
  // supply only the model/command services and do not expose the Desktop RPC.
1836
1891
  if (ctx.typert) {
1837
1892
  const terminals = createRouterTerminals()
1893
+ const runJobs = createRunJobs()
1838
1894
  // Plugin unload or patch reload kills every interactive session.
1839
1895
  ctx.effect(() => {
1840
- const onExit = () => { terminals.disposeAll('dispose') }
1896
+ const onExit = () => { terminals.disposeAll('dispose'); runJobs.dispose() }
1841
1897
  process.once('exit', onExit)
1842
- return () => { process.off('exit', onExit); terminals.disposeAll('dispose') }
1898
+ return () => { process.off('exit', onExit); terminals.disposeAll('dispose'); runJobs.dispose() }
1843
1899
  }, 'model-router-galgame: interactive terminals')
1844
1900
  registerOfficialToolsRemote(ctx, {
1845
- ...routerRemoteServices(ctx, config, { terminals, assertPreferences: assertInstallPreferences }),
1901
+ ...routerRemoteServices(ctx, config, { terminals, runJobs, assertPreferences: assertInstallPreferences }),
1846
1902
  installSettingsError: () => installPreferenceError,
1847
1903
  installRequestedDirectory: () => valueOf(config, 'toolInstallDir', ''),
1848
1904
  zcodeRequestedDirectory: () => valueOf(config, 'zcodeInstallDir', ''),
@@ -149,6 +149,8 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
149
149
 
150
150
  /** Execute a previewed run once every listed reason was confirmed. */
151
151
  startRun(request) { return settled(() => (this.services.startRun ?? unavailable)(request)) }
152
+ runStatus(request) { return settled(() => (this.services.runStatus ?? unavailable)(request)) }
153
+ cancelRun(request) { return settled(() => (this.services.cancelRun ?? unavailable)(request)) }
152
154
 
153
155
  /** Terminal backend (PTY or pipe fallback), limits and live sessions. */
154
156
  terminalInfo() { return settled(() => (this.services.terminalInfo ?? unavailable)()) }
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
11
11
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
12
12
  import { runOfficialTool, officialToolExecutionCapabilities, officialToolReadiness } from './official-tool-executor.mjs'
13
13
  import { toolForProvider } from './official-tool-registry.mjs'
14
- import { redactDiagnostic, verifiedDiagnostic } from './task-executors.mjs'
14
+ import { observeExecutionProgress, redactDiagnostic, verifiedDiagnostic } from './task-executors.mjs'
15
15
  import { unsupportedCliReasoning } from './reasoning-execution.mjs'
16
16
 
17
17
  const MAX_GIT_OUTPUT = 64_000_000
@@ -96,6 +96,8 @@ export async function sessionWorkspace(ctx, exec) {
96
96
  }
97
97
 
98
98
  function git(args, { cwd, input, signal, timeoutMs = GIT_TIMEOUT_MS } = {}) {
99
+ // In particular, never start git apply with an already cancelled signal.
100
+ if (signal?.aborted) return Promise.resolve({ ok: false, code: null, output: Buffer.alloc(0), cancelled: true })
99
101
  return new Promise(resolveResult => {
100
102
  let child
101
103
  try {
@@ -123,6 +125,7 @@ function git(args, { cwd, input, signal, timeoutMs = GIT_TIMEOUT_MS } = {}) {
123
125
  const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM') }, timeoutMs)
124
126
  child.on('error', error => finish(null, error))
125
127
  child.on('close', code => finish(code))
128
+ if (signal?.aborted) onAbort()
126
129
  if (input === undefined) child.stdin.end()
127
130
  else child.stdin.end(input)
128
131
  let finished = false
@@ -412,7 +415,8 @@ async function seedWorktree(isolated, seedFrom, signal) {
412
415
  }
413
416
 
414
417
  /** Sequential execution preserves DAG dependencies and avoids edit conflicts. */
415
- export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode = 'read-only', executionEnvironment = 'harness', installedIds = [], cliModels, signal, sandbox, runtime, previous = [], onlyIds = null, seedFrom = null }) {
418
+ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode = 'read-only', executionEnvironment = 'harness', installedIds = [], cliModels, signal, sandbox, runtime, previous = [], onlyIds = null, seedFrom = null, onPackageStart, onPackage, onStage }) {
419
+ await observeExecutionProgress(onStage, 'preparing')
416
420
  if (!['harness', 'cli-native'].includes(executionEnvironment)
417
421
  || (executionEnvironment === 'cli-native' && mode !== 'workspace-write')) throw new TypeError('invalid team CLI execution environment')
418
422
  // A retry runs only `onlyIds`; earlier results feed dependency context. An
@@ -431,6 +435,7 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
431
435
  if (mode !== 'read-only' && mode !== 'workspace-write') throw new TypeError('team mode must be read-only or workspace-write')
432
436
  const configuredCliModels = checkedTeamCliModels(cliModels, assigned)
433
437
  const source = await approvedSource(workspace, allowedRoot)
438
+ if (signal?.aborted) return { status: 'cancelled', workspace: source, results: [] }
434
439
  if (executionEnvironment === 'harness' && typeof sandbox?.confine !== 'function') return { status: 'blocked', blocking: [{ reason: '官方 Harness 进程沙箱不可用。' }], results: [] }
435
440
  // The Host passes no runtime override. Tests can inject inert CLI adapters to
436
441
  // exercise assignment, stopping, and Git integration without provider accounts.
@@ -440,6 +445,7 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
440
445
  const unready = readiness.filter(item => !item.ready || !(item.readyModes ?? item.modes ?? [mode]).includes(mode))
441
446
  if (unready.length > 0) return { status: 'blocked', blocking: unready.map(item => ({ toolId: item.id,
442
447
  reason: item.reason ?? `官方 CLI 尚未就绪,或不支持 ${mode} 模式。` })), results: [] }
448
+ if (signal?.aborted) return { status: 'cancelled', workspace: source, results: [] }
443
449
  const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
444
450
  const runWorkspace = isolated?.workspace ?? source
445
451
  let seeded = null
@@ -451,13 +457,18 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
451
457
  }
452
458
  }
453
459
  const completed = []
460
+ const cancelledRun = () => ({ status: 'cancelled', workspace: runWorkspace, results: completed,
461
+ ...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) })
454
462
  const context = retry ? (Array.isArray(previous) ? previous : []).filter(item => item?.id && typeof item.finalText === 'string') : []
463
+ if (!signal?.aborted) await observeExecutionProgress(onStage, 'executing')
455
464
  for (const item of assigned) {
456
- if (signal?.aborted) return { status: 'cancelled', workspace: runWorkspace, results: completed,
457
- ...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) }
465
+ if (signal?.aborted) return cancelledRun()
458
466
  const cliModel = teamCliModel(item, configuredCliModels)
459
467
  const prompt = packagePrompt(task, item, [...context, ...completed])
460
468
  const startedAt = Date.now()
469
+ await observeExecutionProgress(onPackageStart, { ...item, provider: item.recommendedProvider,
470
+ model: item.recommendedModel, requestedCliModel: cliModel, startedAt })
471
+ if (signal?.aborted) return cancelledRun()
461
472
  let result
462
473
  try {
463
474
  result = await runTool({
@@ -507,11 +518,15 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
507
518
  error: modelMismatch ? '官方 CLI 实际模型与指定模型不一致。'
508
519
  : (result.error || result.reason) ? executionDiagnostic(result.error || result.reason) : null,
509
520
  outputTail: result.status === 'succeeded' ? null : executionDiagnostic(verifiedDiagnostic(result)) || null })
521
+ await observeExecutionProgress(onPackage, completed.at(-1))
522
+ if (signal?.aborted) return cancelledRun()
510
523
  if (packageStatus !== 'succeeded') return { status: packageStatus === 'cancelled' ? 'cancelled' : 'incomplete', workspace: runWorkspace, results: completed,
511
524
  ...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) }
512
525
  }
513
526
  if (!isolated) return { status: 'cli-completed', workspace: runWorkspace, results: completed,
514
527
  notice: '只读模式已收集各工作包结果;模型输出仍需人工验收。' }
528
+ await observeExecutionProgress(onStage, 'integrating')
529
+ if (signal?.aborted) return cancelledRun()
515
530
  try {
516
531
  const integration = await integrateWorktree(source, runWorkspace, isolated.baseCommit, signal)
517
532
  return { status: integration.ignoredArtifacts > 0 ? 'integration-pending' : 'cli-completed',
@@ -520,6 +535,8 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
520
535
  ? '受 Git 管理的源文件已整合;忽略产物保留在独立工作区,仍需核对和补交。'
521
536
  : '各 CLI 进程成功结束且可用补丁已整合;文件内容仍需按任务要求验收。' }
522
537
  } catch (error) {
538
+ if (signal?.aborted) return { ...cancelledRun(), error: String(error?.message ?? error),
539
+ notice: '执行已取消;独立工作区及工作包结果已保留。' }
523
540
  return { status: 'integration-pending', workspace: runWorkspace, results: completed, baseCommit: isolated.baseCommit,
524
541
  ...(seeded ? { seeded } : {}),
525
542
  error: String(error?.message ?? error), notice: '独立工作区已保留;原工作区未自动整合。' }
@@ -529,12 +546,14 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
529
546
  /** One editable CLI task also uses the same isolated workspace and merge gate. */
530
547
  export async function runOfficialTask({ toolId, task, workspace, allowedRoot, modelId, mode = 'read-only', signal, sandbox, runtime }) {
531
548
  const source = await approvedSource(workspace, allowedRoot)
549
+ if (signal?.aborted) return { toolId, status: 'cancelled', notStarted: true, finalText: '' }
532
550
  if (typeof sandbox?.confine !== 'function') return { toolId, status: 'unsupported', reason: '官方 Harness 进程沙箱不可用。' }
533
551
  // The Host supplies no runtime override; offline tests use inert adapters.
534
552
  const readiness = await (runtime?.readiness ?? officialToolReadiness)(toolId, source)
535
553
  const readyModes = readiness.readyModes ?? readiness.modes ?? []
536
554
  if (!readiness.ready || !readyModes.includes(mode)) return { toolId, status: 'unsupported',
537
555
  reason: readiness.reason ?? readiness.modeReasons?.[mode] ?? `官方 CLI 尚未就绪,或不支持 ${mode} 模式。` }
556
+ if (signal?.aborted) return { toolId, status: 'cancelled', notStarted: true, finalText: '' }
538
557
  const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
539
558
  let result
540
559
  try {
@@ -545,6 +564,8 @@ export async function runOfficialTask({ toolId, task, workspace, allowedRoot, mo
545
564
  result = executionException(error, signal)
546
565
  }
547
566
  result = withFailureDiagnostic(result)
567
+ if (signal?.aborted) return { ...result, status: 'cancelled',
568
+ ...(isolated ? { isolatedWorkspace: isolated.workspace, baseCommit: isolated.baseCommit } : {}) }
548
569
  if (!isolated || result.status !== 'succeeded') return { ...result, ...(isolated ? { isolatedWorkspace: isolated.workspace, baseCommit: isolated.baseCommit } : {}) }
549
570
  try {
550
571
  const integration = await integrateWorktree(source, isolated.workspace, isolated.baseCommit, signal)
@@ -552,6 +573,8 @@ export async function runOfficialTask({ toolId, task, workspace, allowedRoot, mo
552
573
  ...(integration.ignoredArtifacts > 0 ? { status: 'integration-pending' } : {}),
553
574
  isolatedWorkspace: isolated.workspace, integration }
554
575
  } catch (error) {
576
+ if (signal?.aborted) return { ...result, status: 'cancelled', isolatedWorkspace: isolated.workspace,
577
+ baseCommit: isolated.baseCommit, integrationError: String(error?.message ?? error) }
555
578
  return { ...result, status: 'integration-pending', isolatedWorkspace: isolated.workspace,
556
579
  integrationError: String(error?.message ?? error) }
557
580
  }
@@ -104,6 +104,15 @@ const idText = (value, subject) => {
104
104
  return value
105
105
  }
106
106
 
107
+ /** A status/cancel request can only address an existing Host-owned run. */
108
+ export function parseRunJobRequest(value) {
109
+ const request = plainObject(value, 'run job request')
110
+ if (Object.keys(request).some(key => key !== 'jobId')) throw new TypeError('run job request only accepts jobId')
111
+ return { jobId: idText(request.jobId, 'jobId') }
112
+ }
113
+
114
+ const runJobRequestCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#RunJobRequest`, parseRunJobRequest)
115
+
107
116
  const optionalRouteText = (value, subject) => {
108
117
  if (value === undefined || value === null || value === '') return undefined
109
118
  if (typeof value !== 'string' || value.length > 240 || value.includes('\0')) throw new TypeError(`${subject} is invalid`)
@@ -231,6 +240,8 @@ export const OFFICIAL_TOOLS_REMOTE_DESCRIPTORS = Object.freeze([
231
240
  // Workbench "start a run": plan preview with cost estimate and confirmation reasons, then execute.
232
241
  descriptor('previewRun', [jsonParameter('request', runRequestCodec)], anyObjectCodec('RunPreview')),
233
242
  descriptor('startRun', [jsonParameter('request', runRequestCodec)], anyObjectCodec('RunStarted')),
243
+ descriptor('runStatus', [jsonParameter('request', runJobRequestCodec)], anyObjectCodec('RunJobStatus')),
244
+ descriptor('cancelRun', [jsonParameter('request', runJobRequestCodec)], anyObjectCodec('RunJobStatus')),
234
245
  // Workbench "官方工具终端": interactive shell / fixed official CLI sessions.
235
246
  // Output streams through long-poll reads; input and resize are unary calls.
236
247
  descriptor('terminalInfo', [], anyObjectCodec('TerminalInfo')),
@@ -269,7 +269,12 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
269
269
  try {
270
270
  cache = null
271
271
  const { state } = await readFresh({ recoverCorrupt: true })
272
- if (pendingNotices.length) state.notices.push(...pendingNotices)
272
+ if (pendingNotices.length) {
273
+ state.notices.push(...pendingNotices)
274
+ // Recovery may already have moved the damaged file before a failed
275
+ // update. Its pending notice must carry the missing-history marker.
276
+ state.historyIncomplete ||= pendingNotices.some(notice => notice.kind === 'state-corrupt')
277
+ }
273
278
  const result = await change(state)
274
279
  const returned = result === undefined ? undefined : structuredClone(result)
275
280
  retainRuns(state, historyLimit)
@@ -0,0 +1,169 @@
1
+ /**
2
+ * In-memory Host workbench jobs. Only package identity and state enter progress;
3
+ * answers and the rest of the workbench response stay in the final result.
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ const ACTIVE = new Set(['running', 'cancelling'])
8
+ const PACKAGE_DONE = new Set(['succeeded', 'failed', 'cancelled', 'blocked', 'skipped'])
9
+ const FAILURE = 'Run failed. Please try again.'
10
+
11
+ const copy = value => JSON.parse(JSON.stringify(value))
12
+ const label = (value, fallback = '') => typeof value === 'string' ? value : fallback
13
+
14
+ function positiveLimit(value, name) {
15
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer.`)
16
+ return value
17
+ }
18
+
19
+ function completionStatus(item) {
20
+ if (item.cancelled === true || item.status === 'cancelled') return 'cancelled'
21
+ if (item.blocked === true || item.status === 'blocked') return 'blocked'
22
+ if (item.notStarted === true || item.status === 'skipped') return 'skipped'
23
+ if (item.status === 'succeeded' || item.status === 'failed') return item.status
24
+ return item.ok === true ? 'succeeded' : 'failed'
25
+ }
26
+
27
+ /**
28
+ * start() returns before invoking run(). Cancellation requests abort the same
29
+ * signal given to run(), but the job remains active until run actually settles.
30
+ * maxJobs bounds all retained jobs, and only terminal jobs may be evicted.
31
+ */
32
+ export function createRunJobs({ now = Date.now, maxJobs = 50, maxActive = 4 } = {}) {
33
+ if (typeof now !== 'function') throw new TypeError('now must be a function.')
34
+ positiveLimit(maxJobs, 'maxJobs')
35
+ positiveLimit(maxActive, 'maxActive')
36
+ const jobs = new Map()
37
+ let disposed = false
38
+
39
+ // A clock supplied by a Host must not turn a settled worker into an unhandled
40
+ // rejection or make a snapshot impossible to send over JSON.
41
+ const timestamp = () => {
42
+ try {
43
+ const value = now()
44
+ if (typeof value === 'number' && Number.isFinite(value)) return value
45
+ } catch { /* retain a usable timestamp if the injected clock fails */ }
46
+ return Date.now()
47
+ }
48
+ const snapshot = job => copy(job.snapshot)
49
+ const find = jobId => {
50
+ const job = jobs.get(jobId)
51
+ if (!job) throw new Error('Unknown run job id.')
52
+ return job
53
+ }
54
+
55
+ function refreshProgress(job) {
56
+ const progress = job.snapshot.progress
57
+ progress.total = progress.packages.length
58
+ progress.completed = progress.packages.filter(item => PACKAGE_DONE.has(item.status)).length
59
+ const current = progress.packages.findLast(item => item.status === 'running')
60
+ progress.current = current ? { id: current.id, name: current.name } : null
61
+ }
62
+
63
+ function packageFor(job, value) {
64
+ if (!value || typeof value !== 'object') return null
65
+ const id = label(value.id)
66
+ if (!id) return null
67
+ let item = job.snapshot.progress.packages.find(item => item.id === id)
68
+ if (!item) {
69
+ item = { id, name: label(value.name, id), status: 'pending' }
70
+ job.snapshot.progress.packages.push(item)
71
+ }
72
+ return item
73
+ }
74
+
75
+ function progressFor(job, event) {
76
+ if (!ACTIVE.has(job.snapshot.status) || !event || typeof event !== 'object') return
77
+ // Progress is advisory: malformed events must not break the actual worker.
78
+ try {
79
+ if (event.type === 'stage') {
80
+ if (typeof event.stage === 'string') job.snapshot.progress.stage = event.stage.slice(0, 120)
81
+ return
82
+ }
83
+ if (event.type !== 'package-start' && event.type !== 'package-complete') return
84
+ const item = packageFor(job, event.package)
85
+ if (!item || PACKAGE_DONE.has(item.status)) return
86
+ item.status = event.type === 'package-start' ? 'running' : completionStatus(event.package)
87
+ refreshProgress(job)
88
+ } catch { /* progress cannot affect the final workbench response */ }
89
+ }
90
+
91
+ function finish(job, status, result = null) {
92
+ if (!ACTIVE.has(job.snapshot.status)) return
93
+ const state = job.snapshot
94
+ state.status = status
95
+ state.finishedAt = timestamp()
96
+ state.result = result
97
+ state.progress.stage = status
98
+ for (const item of state.progress.packages) {
99
+ if (PACKAGE_DONE.has(item.status)) continue
100
+ // Pending packages were never observed running; once the worker settles,
101
+ // they cannot start. An interrupted running package has a known outcome.
102
+ item.status = status === 'cancelled' ? 'cancelled'
103
+ : item.status === 'running' ? (status === 'failed' ? 'failed' : 'succeeded') : 'skipped'
104
+ }
105
+ refreshProgress(job)
106
+ if (status === 'failed') state.error = FAILURE
107
+ }
108
+
109
+ function fail(job) {
110
+ finish(job, job.controller.signal.aborted ? 'cancelled' : 'failed')
111
+ }
112
+
113
+ function requestCancel(job) {
114
+ if (ACTIVE.has(job.snapshot.status) && !job.snapshot.cancelRequested) {
115
+ job.snapshot.cancelRequested = true
116
+ job.snapshot.status = 'cancelling'
117
+ job.snapshot.progress.stage = 'cancelling'
118
+ job.controller.abort()
119
+ }
120
+ return snapshot(job)
121
+ }
122
+
123
+ return {
124
+ start({ run, packages = [] } = {}) {
125
+ if (disposed) throw new Error('Run job manager is disposed.')
126
+ if (typeof run !== 'function') throw new TypeError('Run job requires a run function.')
127
+ if ([...jobs.values()].filter(job => ACTIVE.has(job.snapshot.status)).length >= maxActive) {
128
+ throw new Error('Too many active run jobs.')
129
+ }
130
+ // Evict the oldest terminal snapshots first. A cancelling worker still
131
+ // occupies capacity because its process/cleanup may still be running.
132
+ if (jobs.size >= maxJobs) {
133
+ const terminal = [...jobs].find(([, job]) => !ACTIVE.has(job.snapshot.status))
134
+ if (!terminal) throw new Error('Run job capacity reached; active jobs are still running.')
135
+ jobs.delete(terminal[0])
136
+ }
137
+ const job = {
138
+ controller: new AbortController(),
139
+ snapshot: {
140
+ jobId: randomUUID(), status: 'running', startedAt: timestamp(), finishedAt: null,
141
+ cancelRequested: false,
142
+ progress: { total: 0, completed: 0, current: null, stage: 'starting', packages: [] },
143
+ result: null,
144
+ },
145
+ }
146
+ for (const item of Array.isArray(packages) ? packages : []) packageFor(job, item)
147
+ refreshProgress(job)
148
+ jobs.set(job.snapshot.jobId, job)
149
+ // Both rejection paths are attached before the worker can run. Result
150
+ // cloning also detaches the worker's mutable response from stored state.
151
+ Promise.resolve().then(() => run({
152
+ signal: job.controller.signal,
153
+ onProgress: event => progressFor(job, event),
154
+ })).then(result => {
155
+ const value = result == null ? null : copy(result)
156
+ const cancelled = value?.status === 'cancelled' || value?.execution?.status === 'cancelled'
157
+ finish(job, cancelled ? 'cancelled' : 'completed', value)
158
+ }, () => fail(job)).catch(() => fail(job))
159
+ return snapshot(job)
160
+ },
161
+ status(jobId) { return snapshot(find(jobId)) },
162
+ cancel(jobId) { return requestCancel(find(jobId)) },
163
+ dispose() {
164
+ if (disposed) return
165
+ disposed = true
166
+ for (const job of jobs.values()) requestCancel(job)
167
+ },
168
+ }
169
+ }
@@ -1223,6 +1223,12 @@ function aggregateOf(results) {
1223
1223
  return { status, packages: results, aggregate }
1224
1224
  }
1225
1225
 
1226
+ /** Host-only observation hooks must never discard execution or billing evidence. */
1227
+ export async function observeExecutionProgress(hook, value) {
1228
+ if (typeof hook !== 'function') return
1229
+ try { await hook(value) } catch { /* Progress reporting does not control execution. */ }
1230
+ }
1231
+
1226
1232
  function checkedPackageSelections(packages, routes, overrides, runVerified, targets) {
1227
1233
  const preceding = new Set()
1228
1234
  const selected = {}
@@ -1299,10 +1305,12 @@ async function runManualTool(target, prompt, options) {
1299
1305
  */
1300
1306
  async function runPackages({ packages, task, routingBypassed, routes, previous = [], targets = null, overrides = {}, choices = {}, options }) {
1301
1307
  const routeList = Array.isArray(routes) ? routes : []
1302
- const { credentialsFor, onPackage, billing, ...runOptions } = options
1308
+ const { credentialsFor, onPackageStart, onPackage, onStage, billing, ...runOptions } = options
1309
+ await observeExecutionProgress(onStage, 'preparing')
1303
1310
  const effectivePackages = checkedPackageSelections(packages, routeList, overrides, runOptions.runVerified, targets)
1304
1311
  const results = []
1305
1312
  const ranIds = []
1313
+ if (!runOptions.signal?.aborted) await observeExecutionProgress(onStage, 'executing')
1306
1314
  for (const item of effectivePackages) {
1307
1315
  const kept = previous.find(result => result.id === item.id)
1308
1316
  if (targets && !targets.has(item.id) && kept) {
@@ -1315,14 +1323,17 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1315
1323
  const provider = target?.kind === 'tool' ? 'official-tool' : target?.provider ?? override?.provider ?? item.recommendedProvider
1316
1324
  const model = target?.kind === 'tool' ? target.toolId : target?.model ?? override?.model ?? item.recommendedModel
1317
1325
  const identity = { ...selection, ...(item.type ? { type: item.type } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) }
1318
- if (runOptions.signal?.aborted) {
1326
+ const cancelledBeforeStart = async () => {
1319
1327
  // Cancelled: later packages are not started (no CLI process, no API spend).
1320
1328
  const cancelled = { id: item.id, name: item.name, ok: false, provider, model,
1321
1329
  channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm', ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}),
1322
1330
  ...identity, answer: '', fallback: null, cancelled: true, notStarted: true, error: '执行已取消,此步骤未开始。' }
1323
1331
  results.push(cancelled)
1324
1332
  ranIds.push(item.id)
1325
- if (typeof onPackage === 'function') await onPackage(cancelled)
1333
+ await observeExecutionProgress(onPackage, cancelled)
1334
+ }
1335
+ if (runOptions.signal?.aborted) {
1336
+ await cancelledBeforeStart()
1326
1337
  continue
1327
1338
  }
1328
1339
  const unmet = (item.dependsOn ?? []).filter(id => results.find(result => result.id === id)?.ok !== true)
@@ -1338,7 +1349,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1338
1349
  }
1339
1350
  results.push(blocked)
1340
1351
  ranIds.push(item.id)
1341
- if (typeof onPackage === 'function') await onPackage(blocked)
1352
+ await observeExecutionProgress(onPackage, blocked)
1342
1353
  continue
1343
1354
  }
1344
1355
  const plannedRoute = routeList.find(candidate => candidate.provider === provider && candidate.model === model)
@@ -1347,13 +1358,24 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1347
1358
  : target?.kind === 'tool' ? { provider, model } : reasoningRouteForAssignment(plannedRoute, item, override)
1348
1359
  if (target?.kind === 'api') delete route.reasoningEffort
1349
1360
  const promptText = routingBypassed ? checkedTask(task) : packagePrompt(task, item, results)
1361
+ const credentials = !target ? (typeof credentialsFor === 'function' ? await credentialsFor(route) : runOptions.credentials ?? null) : null
1362
+ if (runOptions.signal?.aborted) {
1363
+ await cancelledBeforeStart()
1364
+ continue
1365
+ }
1350
1366
  const startedAt = Date.now()
1367
+ await observeExecutionProgress(onPackageStart, { ...item, ...identity, provider, model, startedAt,
1368
+ channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm',
1369
+ ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}) })
1370
+ if (runOptions.signal?.aborted) {
1371
+ await cancelledBeforeStart()
1372
+ continue
1373
+ }
1351
1374
  let result
1352
1375
  if (target?.kind === 'tool') result = await runManualTool(target, promptText, runOptions)
1353
1376
  else if (target?.kind === 'api') result = { ...await useApi(runOptions.apiFallback, { route, task: promptText,
1354
1377
  signal: runOptions.signal, preference: 'api', reason: '按工作包手动选择模型目录 API。' }), fallback: null }
1355
1378
  else {
1356
- const credentials = typeof credentialsFor === 'function' ? await credentialsFor(route) : runOptions.credentials ?? null
1357
1379
  result = await executeRouteWithBilling({
1358
1380
  ...runOptions,
1359
1381
  billing: billing ? { ...billing, routes: billing.routes ?? routeList, ...(choices[item.id] ? { choice: choices[item.id] } : {}) } : null,
@@ -1382,7 +1404,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1382
1404
  }
1383
1405
  results.push(entry)
1384
1406
  ranIds.push(item.id)
1385
- if (typeof onPackage === 'function') await onPackage(entry)
1407
+ await observeExecutionProgress(onPackage, entry)
1386
1408
  }
1387
1409
  return { ...aggregateOf(results), ranIds }
1388
1410
  }