@ljwei-stak/dsh-model-router 0.22.2 → 0.23.1

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.
@@ -4,7 +4,7 @@ import { homedir } from 'node:os'
4
4
  import { isAbsolute } from 'node:path'
5
5
  import z from '@deepseek-ai/schemastery'
6
6
  import { defineTool } from '@deepseek-ai/dsh-tools'
7
- import { DEFAULT_ROUTER_SETTINGS, modelMetadata } from './shared/router.mjs'
7
+ import { DEFAULT_ROUTER_SETTINGS, estimateAssignedPackage, modelMetadata } from './shared/router.mjs'
8
8
  import { DEFAULT_ROUTING_PRESET, normalizeRoutingPreset, routingPreset } from './shared/routing-presets.mjs'
9
9
  import { HEALTH_CACHE_MS, apiKeyEnvPresent, healthCache, runHealthCheck, subscriptionLoginOf } from './shared/tool-health.mjs'
10
10
  import { latestVersions } from './shared/latest-versions.mjs'
@@ -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)
@@ -976,6 +995,31 @@ export function rerunSupport(run) {
976
995
  return { ok: true, kind: run.kind === 'team' ? 'team' : 'assign', writes: false }
977
996
  }
978
997
 
998
+ /** Estimate exactly the packages a retry will start, retaining unknown prices. */
999
+ function rerunEstimates(run, ids, targetId, override, routes, dataOptions) {
1000
+ const estimates = new Map()
1001
+ for (const item of run.packages.filter(item => ids.has(item.id))) {
1002
+ const assigned = item.id === targetId ? override : null
1003
+ const provider = assigned?.provider ?? item.provider ?? item.recommendedProvider
1004
+ const model = assigned?.model ?? item.model ?? item.recommendedModel
1005
+ const changedRoute = provider !== item.recommendedProvider || model !== item.recommendedModel
1006
+ const reassignsRoute = assigned && (assigned.provider !== (item.provider ?? item.recommendedProvider)
1007
+ || assigned.model !== (item.model ?? item.recommendedModel))
1008
+ let estimate = item.estimatedCost
1009
+ // An unchanged route can retain its planned effort; changing identity uses
1010
+ // the new adapter's default effort, just like rerunAssignmentPackage.
1011
+ if (reassignsRoute || changedRoute) {
1012
+ const route = routes.find(route => route.provider === provider && route.model === model)
1013
+ estimate = route ? estimateAssignedPackage(route, item, run.task,
1014
+ run.decision?.complexity?.band ?? 'balanced', dataOptions).estimatedCost : null
1015
+ }
1016
+ estimates.set(item.id, Number.isFinite(estimate) && estimate >= 0 ? estimate : null)
1017
+ }
1018
+ const values = [...estimates.values()]
1019
+ const total = values.every(value => value !== null) ? values.reduce((sum, value) => sum + value, 0) : null
1020
+ return { byId: estimates, total: Number.isFinite(total) ? total : null }
1021
+ }
1022
+
979
1023
  export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, confirmUnsandboxed = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
980
1024
  const { run, saved } = await storedRun(runId)
981
1025
  const target = run.packages.find(item => item.id === text(packageId))
@@ -1014,16 +1058,17 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1014
1058
  return { paused: true, needsConfirmation: missingConfirmations, run,
1015
1059
  budget: null, message: '请确认续跑所需的文件修改与执行环境权限后继续;厂商 CLI 正常环境以当前用户权限运行,独立 Git 工作树不是操作系统读写边界。' }
1016
1060
  }
1017
- const sameRoute = !override || (override.provider === target.recommendedProvider && override.model === target.recommendedModel)
1018
- const budget = budgetFor(config, saved, sameRoute ? target.estimatedCost : null)
1061
+ const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
1062
+ .filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
1063
+ const estimates = rerunEstimates(run, retryIds, target.id, override, routes, planDataOptions(refreshed, config))
1064
+ const budget = budgetFor(config, saved, estimates.total)
1019
1065
  if (budget.exceeded && confirmOverBudget !== true) {
1020
1066
  return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
1021
1067
  }
1022
1068
  const cwd = text(workspace) || run.workspace
1023
1069
  if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
1024
- if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root: text(root) || cwd, signal, options, config })
1025
- const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
1026
- .filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
1070
+ if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget,
1071
+ ids: [...retryIds], estimates: estimates.byId, cwd, root: text(root) || cwd, signal, options, config })
1027
1072
  const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
1028
1073
  .map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
1029
1074
  if (Object.keys(manualRetries).length) {
@@ -1070,6 +1115,9 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1070
1115
  })
1071
1116
  const updated = await routerStateStore().updateRun(run.id, current => {
1072
1117
  mergeRerun(current, execution, { rerunIds: execution.ranIds, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1118
+ for (const item of current.packages) {
1119
+ if (execution.ranIds.includes(item.id)) item.estimatedCost = estimates.byId.get(item.id) ?? null
1120
+ }
1073
1121
  })
1074
1122
  const awaiting = pausedSteps(execution)
1075
1123
  return { run: updated, execution, budget, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
@@ -1095,7 +1143,7 @@ async function cancelPausedStep(runId, packageId) {
1095
1143
  }
1096
1144
 
1097
1145
  /** Stored team packages in plan shape; `override` reassigns the retried package. */
1098
- function teamPlanFromRun(run, ids, override, targetId) {
1146
+ function teamPlanFromRun(run, ids, override, targetId, estimates) {
1099
1147
  const workPackages = run.packages.filter(item => ids.includes(item.id)).map(item => ({
1100
1148
  id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn ?? [],
1101
1149
  type: item.type ?? 'general', purpose: item.purpose ?? 'execution',
@@ -1105,7 +1153,7 @@ function teamPlanFromRun(run, ids, override, targetId) {
1105
1153
  ...(item.plannedReasoningEffort && !(item.id === targetId && override
1106
1154
  && (override.provider !== item.recommendedProvider || override.model !== item.recommendedModel))
1107
1155
  ? { recommendedReasoningEffort: item.plannedReasoningEffort } : {}),
1108
- estimatedCost: item.estimatedCost ?? null,
1156
+ estimatedCost: estimates.get(item.id) ?? null,
1109
1157
  }))
1110
1158
  return { mode: 'team', team: { workPackages } }
1111
1159
  }
@@ -1114,13 +1162,10 @@ function teamPlanFromRun(run, ids, override, targetId) {
1114
1162
  * Read-only team retry: the failed step plus downstream steps that had not
1115
1163
  * succeeded run through the same signed runner; earlier answers are context.
1116
1164
  */
1117
- async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root, signal, options, config = {} }) {
1165
+ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, cwd, root, signal, options, config = {} }) {
1118
1166
  const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
1119
1167
  const executionEnvironment = teamExecutionEnvironment(run.executionEnvironment, mode)
1120
- const downstream = downstreamPackageIds(run.packages, target.id)
1121
- .filter(id => !run.packages.find(item => item.id === id)?.ok)
1122
- const ids = [target.id, ...downstream]
1123
- const plan = teamPlanFromRun(run, ids, override, target.id)
1168
+ const plan = teamPlanFromRun(run, ids, override, target.id, estimates)
1124
1169
  const cliModels = Object.fromEntries(Object.entries(run.cliModels ?? {}).filter(([key]) => ids.includes(key) && !(override && key === target.id)))
1125
1170
  if (override) {
1126
1171
  const reassignedPlan = { team: { workPackages: plan.team.workPackages.filter(item => item.id === target.id) } }
@@ -1147,6 +1192,9 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd,
1147
1192
  }
1148
1193
  const updated = await routerStateStore().updateRun(run.id, current => {
1149
1194
  mergeRerun(current, converted, { rerunIds: ids, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1195
+ for (const item of current.packages) {
1196
+ if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
1197
+ }
1150
1198
  for (const [id, note] of quotaNotes) {
1151
1199
  const stored = current.packages.find(item => item.id === id)
1152
1200
  if (stored) stored.billingSwitch = note
@@ -1482,7 +1530,11 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
1482
1530
  const startedAt = Date.now()
1483
1531
  const execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
1484
1532
  allowedRoot: options.root ?? workspace, mode: planned.mode, executionEnvironment, installedIds: planned.installed,
1485
- cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox })
1533
+ cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox,
1534
+ onPackageStart: item => reportRunProgress(options, { type: 'package-start', package: item }),
1535
+ onPackage: item => reportRunProgress(options, { type: 'package-complete', package: item }),
1536
+ onStage: stage => reportRunProgress(options, { type: 'stage', stage }) })
1537
+ await reportRunProgress(options, { type: 'stage', stage: 'saving' })
1486
1538
  const run = await recordTeamRun({ task: planned.taskText, plan: planned.plan, execution, mode: planned.mode, executionEnvironment, workspace,
1487
1539
  routes: planned.routes, cliModels: planned.cliModels, budget, startedAt, config })
1488
1540
  return { ...common, status: execution.status, execution, run, runId: run?.id ?? null,
@@ -1720,15 +1772,26 @@ export async function previewWorkbenchRun(ctx, config, request = {}, options = {
1720
1772
  /** Execute from the workbench; refuses until every current reason is in confirmedReasons. */
1721
1773
  export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}) {
1722
1774
  const prepared = await prepareWorkbenchPreview(ctx, config, supplied, options)
1723
- const { preview, request } = prepared
1775
+ return refusedWorkbenchStart(prepared) ?? executePreparedWorkbenchRun(ctx, config, prepared, options)
1776
+ }
1777
+
1778
+ function refusedWorkbenchStart({ preview, request }) {
1724
1779
  if (preview.status === 'blocked') return preview
1725
- const confirmed = new Set(Array.isArray(request.confirmedReasons) ? request.confirmedReasons : [])
1780
+ const confirmed = new Set(request.confirmedReasons ?? [])
1726
1781
  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) }
1782
+ return missing.length ? { ...preview, status: 'needs-confirmation', reasons: preview.reasons,
1783
+ missing: missing.map(item => item.code) } : null
1784
+ }
1785
+
1786
+ async function executePreparedWorkbenchRun(ctx, config, prepared, options = {}) {
1787
+ const { preview, request } = prepared
1788
+ const confirmed = new Set(Array.isArray(request.confirmedReasons) ? request.confirmedReasons : [])
1789
+ throwIfAborted(options.signal)
1790
+ await reportRunProgress(options, { type: 'stage', stage: 'executing' })
1728
1791
  if (request.executor === 'official-team') {
1729
1792
  const result = await (options.executeOfficialTeam ?? executeOfficialTeamPlan)(ctx, config, prepared.planned, {
1730
- workspace: preview.workspace, root: preview.workspace, signal: options.signal,
1731
1793
  confirmOverBudget: confirmed.has('over-budget'), confirmUnsandboxed: confirmed.has('unsandboxed'), ...(options.teamOptions ?? {}),
1794
+ workspace: preview.workspace, root: preview.workspace, signal: options.signal, onProgress: options.onProgress,
1732
1795
  })
1733
1796
  return { status: result.status, runId: result.runId ?? null, workspace: preview.workspace,
1734
1797
  executor: 'official-team', executionMode: request.executionMode, executionEnvironment: preview.executionEnvironment, decision: preview.decision,
@@ -1740,8 +1803,8 @@ export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}
1740
1803
  provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
1741
1804
  ...manualRequestOptions(request),
1742
1805
  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,
1806
+ confirmOverBudget: confirmed.has('over-budget'), workspace: preview.workspace, ...options.planOptions,
1807
+ preparedAssignment: prepared.planned, signal: options.signal, onProgress: options.onProgress,
1745
1808
  })
1746
1809
  const awaiting = result.awaitingConfirmation ?? []
1747
1810
  return {
@@ -1751,6 +1814,25 @@ export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}
1751
1814
  }
1752
1815
  }
1753
1816
 
1817
+ /** Desktop starts an observable job after fresh preflight and confirmation checks. */
1818
+ export async function launchWorkbenchRun(ctx, config, supplied = {}, { runJobs, ...options } = {}) {
1819
+ if (!runJobs) throw new Error('工作台任务服务不可用,请重启 Harness 后重试。')
1820
+ const prepared = await prepareWorkbenchPreview(ctx, config, supplied, options)
1821
+ const refused = refusedWorkbenchStart(prepared)
1822
+ if (refused) return refused
1823
+ return runJobs.start({
1824
+ packages: prepared.preview.decision?.packages ?? [],
1825
+ run: async ({ signal, onProgress }) => {
1826
+ try { return await executePreparedWorkbenchRun(ctx, config, prepared, { ...options, signal, onProgress }) }
1827
+ catch (error) {
1828
+ if (error?.runId) return { status: signal.aborted ? 'cancelled' : 'failed', runId: error.runId,
1829
+ workspace: prepared.preview.workspace, notice: '执行未完成,已产生的结果与费用请查看执行记录。' }
1830
+ throw error
1831
+ }
1832
+ },
1833
+ })
1834
+ }
1835
+
1754
1836
  /** Host operations behind the workbench RPC; the client never supplies commands or paths. */
1755
1837
  /**
1756
1838
  * One terminal manager per Host plugin fiber. Sessions run outside the
@@ -1786,7 +1868,7 @@ function terminalServices(terminals, assertPreferences = () => {}) {
1786
1868
  }
1787
1869
  }
1788
1870
 
1789
- export function routerRemoteServices(ctx, config, { terminals = null, assertPreferences } = {}) {
1871
+ export function routerRemoteServices(ctx, config, { terminals = null, assertPreferences, runJobs = createRunJobs(), runOptions = {} } = {}) {
1790
1872
  return {
1791
1873
  ...terminalServices(terminals, assertPreferences),
1792
1874
  health: async fresh => {
@@ -1804,8 +1886,10 @@ export function routerRemoteServices(ctx, config, { terminals = null, assertPref
1804
1886
  ...(request?.subscriptionChoice ? { subscriptionChoice: request.subscriptionChoice } : {}),
1805
1887
  }),
1806
1888
  boundaries: () => securityBoundaries(ctx, config),
1807
- previewRun: request => previewWorkbenchRun(ctx, config, request),
1808
- startRun: request => startWorkbenchRun(ctx, config, request),
1889
+ previewRun: request => previewWorkbenchRun(ctx, config, request, runOptions),
1890
+ startRun: request => launchWorkbenchRun(ctx, config, request, { ...runOptions, runJobs }),
1891
+ runStatus: request => runJobs.status(parseRunJobRequest(request).jobId),
1892
+ cancelRun: request => runJobs.cancel(parseRunJobRequest(request).jobId),
1809
1893
  }
1810
1894
  }
1811
1895
 
@@ -1835,14 +1919,15 @@ export function apply(ctx, config = {}) {
1835
1919
  // supply only the model/command services and do not expose the Desktop RPC.
1836
1920
  if (ctx.typert) {
1837
1921
  const terminals = createRouterTerminals()
1922
+ const runJobs = createRunJobs()
1838
1923
  // Plugin unload or patch reload kills every interactive session.
1839
1924
  ctx.effect(() => {
1840
- const onExit = () => { terminals.disposeAll('dispose') }
1925
+ const onExit = () => { terminals.disposeAll('dispose'); runJobs.dispose() }
1841
1926
  process.once('exit', onExit)
1842
- return () => { process.off('exit', onExit); terminals.disposeAll('dispose') }
1927
+ return () => { process.off('exit', onExit); terminals.disposeAll('dispose'); runJobs.dispose() }
1843
1928
  }, 'model-router-galgame: interactive terminals')
1844
1929
  registerOfficialToolsRemote(ctx, {
1845
- ...routerRemoteServices(ctx, config, { terminals, assertPreferences: assertInstallPreferences }),
1930
+ ...routerRemoteServices(ctx, config, { terminals, runJobs, assertPreferences: assertInstallPreferences }),
1846
1931
  installSettingsError: () => installPreferenceError,
1847
1932
  installRequestedDirectory: () => valueOf(config, 'toolInstallDir', ''),
1848
1933
  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`)
@@ -196,7 +205,7 @@ const terminalStopCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#Terminal
196
205
 
197
206
  const runRequestCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#RunRequest`, parseRunRequest)
198
207
 
199
- function descriptor(method, parameters, result) {
208
+ function descriptor(method, parameters, result, cancellable = false) {
200
209
  return Object.freeze({
201
210
  id: `${OFFICIAL_TOOLS_REMOTE_PACKAGE}#${OFFICIAL_TOOLS_REMOTE_NAMESPACE}/${method}`,
202
211
  service: OFFICIAL_TOOLS_REMOTE_NAMESPACE,
@@ -205,6 +214,7 @@ function descriptor(method, parameters, result) {
205
214
  invocation: { kind: 'direct' },
206
215
  parameters,
207
216
  result,
217
+ ...(cancellable ? { cancellation: { parameter: 'signal' } } : {}),
208
218
  })
209
219
  }
210
220
 
@@ -229,8 +239,10 @@ export const OFFICIAL_TOOLS_REMOTE_DESCRIPTORS = Object.freeze([
229
239
  descriptor('rerunStep', [jsonParameter('request', rerunRequestCodec)], anyObjectCodec('RerunResult')),
230
240
  descriptor('boundaries', [], anyObjectCodec('SecurityBoundaries')),
231
241
  // Workbench "start a run": plan preview with cost estimate and confirmation reasons, then execute.
232
- descriptor('previewRun', [jsonParameter('request', runRequestCodec)], anyObjectCodec('RunPreview')),
242
+ descriptor('previewRun', [jsonParameter('request', runRequestCodec)], anyObjectCodec('RunPreview'), true),
233
243
  descriptor('startRun', [jsonParameter('request', runRequestCodec)], anyObjectCodec('RunStarted')),
244
+ descriptor('runStatus', [jsonParameter('request', runJobRequestCodec)], anyObjectCodec('RunJobStatus'), true),
245
+ descriptor('cancelRun', [jsonParameter('request', runJobRequestCodec)], anyObjectCodec('RunJobStatus'), true),
234
246
  // Workbench "官方工具终端": interactive shell / fixed official CLI sessions.
235
247
  // Output streams through long-poll reads; input and resize are unary calls.
236
248
  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)