@ljwei-stak/dsh-model-router 0.23.0 → 0.24.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.
@@ -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'
@@ -13,6 +13,8 @@ import {
13
13
  } from './shared/subscription-billing.mjs'
14
14
  import { createRouterState } from './shared/router-state.mjs'
15
15
  import { createRunJobs } from './shared/run-jobs.mjs'
16
+ import { createBudgetReservations, reservationSpending } from './shared/budget-reservations.mjs'
17
+ import { beginExecutionLifecycle } from './shared/execution-lifecycle.mjs'
16
18
  import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
17
19
  import { buildRoutingEvidence, parseRoutingEvidenceJson } from './shared/routing-evidence.mjs'
18
20
  import { apiReasoningRequest } from './shared/reasoning-execution.mjs'
@@ -21,7 +23,7 @@ import { applyPricingSnapshot, createDynamicDataRefresher, dynamicDataSummary, r
21
23
  import { projectDynamicData } from './shared/dynamic-data-view.mjs'
22
24
  import {
23
25
  budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
24
- spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults,
26
+ spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults, plannedPackages,
25
27
  } from './shared/run-ledger.mjs'
26
28
  import { routeBoundaries } from './shared/security-boundaries.mjs'
27
29
  import { createPlanFromRoutes } from './shared/harness-plan.mjs'
@@ -377,13 +379,81 @@ function budgetSettings(config) {
377
379
  }
378
380
  }
379
381
 
380
- function budgetFor(config, saved, estimateUsd) {
382
+ function budgetFor(config, saved, estimateUsd, { checkEstimate = true } = {}) {
381
383
  const settings = budgetSettings(config)
382
384
  if ((saved.storageAvailable === false || saved.historyVerified === false) && (settings.dailyLimitUsd > 0 || settings.monthlyLimitUsd > 0)) {
383
385
  throw new Error('无法读取费用历史,不能核验日/月预算;请恢复存储连接后再执行。')
384
386
  }
385
- const spent = spending(saved.runs, undefined, saved.archivedSpending)
386
- return { ...budgetCheck({ estimateUsd, spent, ...settings }), spent, ...settings }
387
+ const recorded = spending(saved.runs, undefined, saved.archivedSpending)
388
+ const reserved = reservationSpending(saved)
389
+ const spent = { ...recorded, today: recorded.today + reserved.today, month: recorded.month + reserved.month,
390
+ unknownToday: recorded.unknownToday + reserved.unknownToday, unknownMonth: recorded.unknownMonth + reserved.unknownMonth }
391
+ const check = budgetCheck({ estimateUsd, spent, ...settings })
392
+ if (check.limited && (checkEstimate && !check.estimateKnown || settings.dailyLimitUsd > 0 && spent.unknownToday > 0
393
+ || settings.monthlyLimitUsd > 0 && spent.unknownMonth > 0) && !check.exceeded) {
394
+ check.exceeded = 'unknown'
395
+ check.message = '存在未核对费用或缺少单价,无法核验预算;请先核对账单或明确确认继续。'
396
+ }
397
+ return { ...check, spent, reserved, ...settings }
398
+ }
399
+
400
+ function pausedExecutionBudget(lifecycle) {
401
+ if (['history-incomplete', 'active-run', 'reservation-capacity'].includes(lifecycle.reason)) {
402
+ throw new Error(lifecycle.reason === 'active-run' ? '该运行仍在执行或费用待核对,不能重复启动。'
403
+ : lifecycle.reason === 'history-incomplete' ? '费用历史不完整,不能核验执行预算。' : '待核对运行过多,请先核对执行记录和费用。')
404
+ }
405
+ return { ...lifecycle.budget, paused: true, exceeded: lifecycle.budget?.exceeded ?? 'unknown' }
406
+ }
407
+
408
+ function packageEstimates(plan, task) {
409
+ return plannedPackages(plan, task).map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCost ?? null }))
410
+ }
411
+
412
+ /** Reprice a subscription fallback against the route that will actually bill. */
413
+ async function reserveChangedApiRoute(lifecycle, expected, route, task, config, confirmOverBudget) {
414
+ if (expected?.provider === route.provider && expected?.model === route.model) return null
415
+ const id = lifecycle.activePackageId()
416
+ if (!id) throw new Error('缺少执行检查点,不能启动 API 路线。')
417
+ const saved = await routingSavedState(config)
418
+ const estimate = createPlanFromRoutes(task, [route], { ...planDataOptions(saved, config),
419
+ mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost
420
+ const reserved = await lifecycle.reserveExtra(id, estimate, { confirmOverBudget: confirmOverBudget === true })
421
+ if (reserved.accepted) return null
422
+ return { ok: false, notStarted: true, error: `实际 API 路线预算未确认,已停止调用。${reserved.budget.message}` }
423
+ }
424
+
425
+ /** A changed budget stops before dispatch; storage failures retain their hold. */
426
+ async function stopExecutionLifecycle(lifecycle, error, run = lifecycle.current('interrupted')) {
427
+ if (error?.code === 'ROUTER_BUDGET_CHANGED') {
428
+ run.status = 'paused-budget'
429
+ run.error = error.message
430
+ run.finishedAt = Date.now()
431
+ try { await lifecycle.finish(run); error.runId = run.id; return true }
432
+ catch { await lifecycle.interrupt(run); return false }
433
+ }
434
+ await lifecycle.interrupt(run)
435
+ return false
436
+ }
437
+
438
+ /** The independent-opinion tool uses the same durable API budget boundary. */
439
+ export async function executeBudgetedConsult(ctx, route, task, config = {}, { signal, limit = 12_000, confirmOverBudget = false } = {}) {
440
+ const problem = taskTextProblem(text(task))
441
+ if (problem) throw new Error(problem)
442
+ const plan = createPlanFromRoutes(task, [route], { mode: 'direct', directProvider: route.provider, directModel: route.model })
443
+ const id = randomUUID(), at = Date.now()
444
+ const record = (status, results) => buildRunRecord({ id, createdAt: at, finishedAt: Date.now(), task, plan,
445
+ execution: { status, packages: results }, workspace: process.cwd(), pricingFor: () => route.pricing ?? null, billingFor: () => 'api' })
446
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: ({ status, results }) => record(status, results),
447
+ packageEstimates: packageEstimates(plan, task), settings: budgetSettings(config), confirmOverBudget })
448
+ if (!lifecycle.accepted) return { status: 'paused-budget', paused: true, budget: pausedExecutionBudget(lifecycle), ok: false }
449
+ let result
450
+ try {
451
+ await lifecycle.onCheckpointStart({ id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm' })
452
+ result = await consultConfiguredModel(ctx, route, task, limit, signal)
453
+ await lifecycle.onCheckpointResult({ ...result, id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm' })
454
+ await lifecycle.finish(record(result.cancelled ? 'cancelled' : result.ok ? 'completed' : 'failed', lifecycle.results()))
455
+ } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
456
+ return { ...result, runId: id }
387
457
  }
388
458
 
389
459
  /**
@@ -674,7 +744,7 @@ function reviewScore(answer) {
674
744
  * 质量回路: a stronger configured model spot-checks answers from cheaper
675
745
  * routes. `sample` reviews a random share; `always` reviews every cheaper answer.
676
746
  */
677
- export async function reviewRunPackages(ctx, run, routes, config, { signal, random = Math.random } = {}) {
747
+ export async function reviewRunPackages(ctx, run, routes, config, { signal, random = Math.random, lifecycle } = {}) {
678
748
  const mode = valueOf(config, 'reviewMode', 'off')
679
749
  if (mode !== 'sample' && mode !== 'always') return []
680
750
  const rate = Math.min(1, Math.max(0, finiteNumber(valueOf(config, 'reviewSampleRate', 0.2), 0.2)))
@@ -696,6 +766,24 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
696
766
  `工作包:${item.name}\n${item.objective.slice(0, 1_500)}`,
697
767
  `待审结果:\n${item.answer.slice(0, 3_500)}`,
698
768
  ].join('\n\n')
769
+ const reviewId = `review:${item.id}`
770
+ let pendingReview = null
771
+ if (lifecycle) {
772
+ const estimate = createPlanFromRoutes(prompt, [reviewer], { mode: 'direct', directProvider: reviewer.provider,
773
+ directModel: reviewer.model }).estimatedCost
774
+ const reserved = await lifecycle.reserveExtra(reviewId, estimate)
775
+ if (!reserved.accepted) { item.review = { score: null, summary: '预算未确认,已跳过额外审阅。', at: Date.now() }; continue }
776
+ pendingReview = { id: reviewId, packageId: item.id, provider: reviewer.provider, model: reviewer.model,
777
+ status: 'running', inFlight: true, ran: false, costUsd: null, billing: 'api', finishedAt: Date.now() }
778
+ run.reviews.push(pendingReview)
779
+ try { await lifecycle.saveRun(run, { startedIds: [reviewId] }) }
780
+ catch (error) {
781
+ if (error.code !== 'ROUTER_BUDGET_CHANGED') throw error
782
+ run.reviews = run.reviews.filter(entry => entry !== pendingReview)
783
+ item.review = { score: null, summary: '预算已变化,已跳过额外审阅。', at: Date.now() }
784
+ break
785
+ }
786
+ }
699
787
  try {
700
788
  const result = await consultConfiguredModel(ctx, reviewer, prompt, 1_200, signal)
701
789
  const score = result.ok ? reviewScore(result.answer) : null
@@ -707,9 +795,16 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
707
795
  }
708
796
  const cost = actualCost(result, reviewer.pricing ?? null)
709
797
  const entry = { packageId: item.id, provider: reviewer.provider, model: reviewer.model, ran: true, finishedAt: Date.now(), usage: result.usage ?? null, ...cost, billing: 'api', referenceCostUsd: null }
710
- run.reviews.push(entry)
798
+ if (pendingReview) Object.assign(pendingReview, entry, { status: 'completed', inFlight: false })
799
+ else run.reviews.push(entry)
711
800
  reviews.push({ ...entry, score })
801
+ if (lifecycle) await lifecycle.saveRun(run, { completedIds: [reviewId] })
712
802
  } catch (error) {
803
+ if (error.code === 'ROUTER_CHECKPOINT_FAILED') throw error
804
+ if (pendingReview) {
805
+ Object.assign(pendingReview, { status: 'failed', inFlight: false, ran: true, costUsd: null, finishedAt: Date.now() })
806
+ await lifecycle.saveRun(run, { completedIds: [reviewId] })
807
+ }
713
808
  if (signal?.aborted) break
714
809
  item.review = { provider: reviewer.provider, model: reviewer.model, score: null, summary: `审阅失败:${errorText(error).slice(0, 200)}`, at: Date.now() }
715
810
  }
@@ -724,6 +819,8 @@ async function reportRunProgress(options, event) {
724
819
 
725
820
  function executionHooks(ctx, options) {
726
821
  return {
822
+ onCheckpointStart: options.onCheckpointStart,
823
+ onCheckpointResult: options.onCheckpointResult,
727
824
  skipOfficial: options.skipOfficial ?? (request => healthCache.skipReason(request)),
728
825
  onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
729
826
  onPackageStart: async item => {
@@ -877,9 +974,24 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
877
974
  const billing = await billingContext(config, options)
878
975
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
879
976
  const startedAt = Date.now()
977
+ const runId = randomUUID()
978
+ const makeRecord = execution => buildRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(),
979
+ task: taskText, plan, execution, preset: plan.preset ?? preset, workspace: options.workspace ?? '',
980
+ pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
981
+ budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true,
982
+ confirmed: options.confirmOverBudget === true } })
983
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(),
984
+ packageEstimates: packageEstimates(plan, taskText), settings: budgetSettings(config),
985
+ confirmOverBudget: options.confirmOverBudget === true,
986
+ buildRun: ({ status, results }) => makeRecord({ status, packages: results }) })
987
+ if (!lifecycle.accepted) return { plan, execution: null, paused: true, budget: pausedExecutionBudget(lifecycle) }
880
988
  const gate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
881
989
  const gatedApi = async ({ route, task: packageTask, signal, subscription }) => {
882
990
  if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, signal)
991
+ const plannedPackage = plannedPackages(plan, taskText).find(item => item.id === lifecycle.activePackageId())
992
+ const denied = await reserveChangedApiRoute(lifecycle, { provider: plannedPackage?.recommendedProvider,
993
+ model: plannedPackage?.recommendedModel }, route, packageTask, config, options.confirmOverBudget)
994
+ if (denied) return denied
883
995
  const quality = routeQuality(route)
884
996
  const alternatives = gate && !direct && route.manualPinned !== true && apiUpgradeEligible(route, routes)
885
997
  && valueOf(config, 'qualityEscalationEnabled', false) === true && quality !== null
@@ -906,10 +1018,22 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
906
1018
  && (result.blocked || result.notStarted || billingForResult(result) === 'subscription') ? { costUsd: 0 }
907
1019
  : actualCost(result, routePricing(routes, result))), ...attempts.map(result => actualCost(result, result.pricingSnapshot))]
908
1020
  const pending = plan.costBreakdown.slice(finished.length + 1).map(item => item.estimatedCost)
909
- if (costs.some(item => item.costUsd === null) || pending.some(value => !Number.isFinite(value))) return !constrained
910
- const projected = costs.reduce((sum, item) => sum + item.costUsd, estimate) + pending.reduce((sum, value) => sum + value, 0)
911
- if (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit) return false
912
- return !budgetFor(config, saved, projected).exceeded
1021
+ const unknownProjection = costs.some(item => item.costUsd === null) || pending.some(value => !Number.isFinite(value))
1022
+ if (unknownProjection && constrained) return false
1023
+ const projected = unknownProjection ? null
1024
+ : costs.reduce((sum, item) => sum + item.costUsd, estimate) + pending.reduce((sum, value) => sum + value, 0)
1025
+ if (!unknownProjection && (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit)) return false
1026
+ const id = lifecycle.activePackageId()
1027
+ if (!id) return false
1028
+ await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, qualityAttempts: attempts })
1029
+ if (!(await lifecycle.reserveExtra(id, estimate)).accepted) return false
1030
+ // The extra request can be charged before its receipt arrives. Persist
1031
+ // both the first receipt and the unknown second attempt before dispatch.
1032
+ await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, qualityAttempts: [...attempts, {
1033
+ provider: to.provider, model: to.model, channel: 'harness-llm', ok: false,
1034
+ usage: null, usageIncomplete: true, pricingSnapshot: to.pricing ?? null, finishedAt: Date.now(),
1035
+ }] }, { checkBudget: true })
1036
+ return true
913
1037
  },
914
1038
  })
915
1039
  }
@@ -922,40 +1046,49 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
922
1046
  workspace: options.workspace,
923
1047
  signal: options.signal,
924
1048
  ...hooks,
1049
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item),
1050
+ onCheckpointResult: result => lifecycle.onCheckpointResult(result),
925
1051
  billing,
926
1052
  apiFallback: gatedApi,
927
1053
  })
928
1054
  } catch (error) {
929
1055
  // Steps that already ran (and may have been paid for) are always recorded with their spend.
930
1056
  const message = String(error?.message ?? error)
931
- const partial = { status: options.signal?.aborted ? 'cancelled' : 'failed', packages: finished, aggregate: '', error: message }
1057
+ const receipts = new Map([...finished, ...lifecycle.results(), ...(error.execution?.packages ?? [])].map(item => [item.id, item]))
1058
+ const partial = { status: options.signal?.aborted ? 'cancelled' : 'failed', packages: [...receipts.values()], aggregate: '', error: message }
932
1059
  const run = buildRunRecord({
933
- id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution: partial,
1060
+ id: runId, createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution: partial,
934
1061
  preset: plan.preset ?? preset, workspace: options.workspace ?? '',
935
1062
  pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
936
1063
  budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
937
1064
  })
938
1065
  run.error = message.slice(0, 2_000)
939
1066
  let stored = true
940
- try { await routerStateStore().appendRun(run) } catch { stored = false }
1067
+ try {
1068
+ if (error.code === 'ROUTER_BUDGET_CHANGED') stored = await stopExecutionLifecycle(lifecycle, error, run)
1069
+ else if (error.code === 'ROUTER_CHECKPOINT_FAILED') { await lifecycle.interrupt(run); stored = false }
1070
+ else await lifecycle.finish(run)
1071
+ } catch { stored = false; await lifecycle.interrupt(run) }
941
1072
  const wrapped = new Error(`${message}${finished.length ? `(已完成 ${finished.length} 个步骤,${stored ? `已记录到运行 ${run.id},费用计入预算` : '但运行记录保存失败'})` : ''}`)
942
1073
  wrapped.runId = stored ? run.id : null
943
1074
  wrapped.cause = error
944
1075
  throw wrapped
945
1076
  }
946
1077
  const run = buildRunRecord({
947
- id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution,
1078
+ id: runId, createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution,
948
1079
  preset: plan.preset ?? preset, workspace: options.workspace ?? '',
949
1080
  pricingFor: item => routePricing(routes, item),
950
1081
  billingFor: billingForResult,
951
1082
  budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
952
1083
  })
953
1084
  await reportRunProgress(options, { type: 'stage', stage: 'reviewing' })
954
- const reviews = await reviewRunPackages(ctx, run, routes, config, { signal: options.signal, random: options.random })
1085
+ let reviews
1086
+ try { reviews = await reviewRunPackages(ctx, run, routes, config, { signal: options.signal, random: options.random, lifecycle }) }
1087
+ catch (error) { await lifecycle.interrupt(run); throw error }
955
1088
  if (options.signal?.aborted) { run.status = 'cancelled'; execution.status = 'cancelled' }
956
1089
  await reportRunProgress(options, { type: 'stage', stage: 'saving' })
957
1090
  let stored = true
958
- try { await routerStateStore().appendRun(run) } catch { stored = false }
1091
+ try { await lifecycle.finish(run) } catch { stored = false; await lifecycle.interrupt(run) }
959
1092
  const awaiting = pausedSteps(execution)
960
1093
  return { plan, execution, budget, runId: run.id, run, reviews, stored, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
961
1094
  }
@@ -995,8 +1128,34 @@ export function rerunSupport(run) {
995
1128
  return { ok: true, kind: run.kind === 'team' ? 'team' : 'assign', writes: false }
996
1129
  }
997
1130
 
1131
+ /** Estimate exactly the packages a retry will start, retaining unknown prices. */
1132
+ function rerunEstimates(run, ids, targetId, override, routes, dataOptions) {
1133
+ const estimates = new Map()
1134
+ for (const item of run.packages.filter(item => ids.has(item.id))) {
1135
+ const assigned = item.id === targetId ? override : null
1136
+ const provider = assigned?.provider ?? item.provider ?? item.recommendedProvider
1137
+ const model = assigned?.model ?? item.model ?? item.recommendedModel
1138
+ const changedRoute = provider !== item.recommendedProvider || model !== item.recommendedModel
1139
+ const reassignsRoute = assigned && (assigned.provider !== (item.provider ?? item.recommendedProvider)
1140
+ || assigned.model !== (item.model ?? item.recommendedModel))
1141
+ let estimate = item.estimatedCost
1142
+ // An unchanged route can retain its planned effort; changing identity uses
1143
+ // the new adapter's default effort, just like rerunAssignmentPackage.
1144
+ if (reassignsRoute || changedRoute) {
1145
+ const route = routes.find(route => route.provider === provider && route.model === model)
1146
+ estimate = route ? estimateAssignedPackage(route, item, run.task,
1147
+ run.decision?.complexity?.band ?? 'balanced', dataOptions).estimatedCost : null
1148
+ }
1149
+ estimates.set(item.id, Number.isFinite(estimate) && estimate >= 0 ? estimate : null)
1150
+ }
1151
+ const values = [...estimates.values()]
1152
+ const total = values.every(value => value !== null) ? values.reduce((sum, value) => sum + value, 0) : null
1153
+ return { byId: estimates, total: Number.isFinite(total) ? total : null }
1154
+ }
1155
+
998
1156
  export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, confirmUnsandboxed = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
999
1157
  const { run, saved } = await storedRun(runId)
1158
+ if (saved.budgetReservations?.[run.id]) throw new Error('该运行仍在执行或费用待核对,请先核对原任务与厂商账单,不能直接重跑。')
1000
1159
  const target = run.packages.find(item => item.id === text(packageId))
1001
1160
  if (!target) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
1002
1161
  const choice = text(subscriptionChoice) || null
@@ -1033,16 +1192,17 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1033
1192
  return { paused: true, needsConfirmation: missingConfirmations, run,
1034
1193
  budget: null, message: '请确认续跑所需的文件修改与执行环境权限后继续;厂商 CLI 正常环境以当前用户权限运行,独立 Git 工作树不是操作系统读写边界。' }
1035
1194
  }
1036
- const sameRoute = !override || (override.provider === target.recommendedProvider && override.model === target.recommendedModel)
1037
- const budget = budgetFor(config, saved, sameRoute ? target.estimatedCost : null)
1195
+ const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
1196
+ .filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
1197
+ const estimates = rerunEstimates(run, retryIds, target.id, override, routes, planDataOptions(refreshed, config))
1198
+ const budget = budgetFor(config, saved, estimates.total)
1038
1199
  if (budget.exceeded && confirmOverBudget !== true) {
1039
1200
  return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
1040
1201
  }
1041
1202
  const cwd = text(workspace) || run.workspace
1042
1203
  if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
1043
- if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root: text(root) || cwd, signal, options, config })
1044
- const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
1045
- .filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
1204
+ if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget,
1205
+ ids: [...retryIds], estimates: estimates.byId, confirmOverBudget, cwd, root: text(root) || cwd, signal, options, config })
1046
1206
  const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
1047
1207
  .map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
1048
1208
  if (Object.keys(manualRetries).length) {
@@ -1062,7 +1222,18 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1062
1222
  }
1063
1223
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
1064
1224
  const retryGate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
1065
- const execution = await rerunAssignmentPackage({
1225
+ const buildRetry = ({ status, results }) => {
1226
+ const next = structuredClone(run)
1227
+ mergeRerun(next, { status, packages: results }, { rerunIds: [...retryIds], pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1228
+ for (const item of next.packages) if (retryIds.has(item.id)) item.estimatedCost = estimates.byId.get(item.id) ?? null
1229
+ return next
1230
+ }
1231
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
1232
+ packageEstimates: [...estimates.byId].map(([id, estimatedCostUsd]) => ({ id, estimatedCostUsd })),
1233
+ settings: budgetSettings(config), confirmOverBudget })
1234
+ if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
1235
+ let execution
1236
+ try { execution = await rerunAssignmentPackage({
1066
1237
  task: run.task,
1067
1238
  packages: run.packages.map(item => ({
1068
1239
  id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn,
@@ -1082,14 +1253,21 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1082
1253
  signal,
1083
1254
  ...(choice ? { subscriptionChoice: choice } : {}),
1084
1255
  ...executionHooks(ctx, options),
1256
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item),
1257
+ onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1085
1258
  billing: await billingContext(config, options),
1086
- apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => subscription
1087
- ? consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
1088
- : consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate }),
1089
- })
1090
- const updated = await routerStateStore().updateRun(run.id, current => {
1091
- mergeRerun(current, execution, { rerunIds: execution.ranIds, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1092
- })
1259
+ apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => {
1260
+ if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
1261
+ const id = lifecycle.activePackageId()
1262
+ const previous = run.packages.find(item => item.id === id)
1263
+ const expected = override && id === target.id ? override
1264
+ : { provider: previous?.provider ?? previous?.recommendedProvider, model: previous?.model ?? previous?.recommendedModel }
1265
+ const denied = await reserveChangedApiRoute(lifecycle, expected, route, packageTask, config, confirmOverBudget)
1266
+ return denied ?? consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate })
1267
+ },
1268
+ }) } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1269
+ const updated = buildRetry({ status: execution.status, results: execution.packages })
1270
+ try { await lifecycle.finish(updated) } catch (error) { await lifecycle.interrupt(updated); throw error }
1093
1271
  const awaiting = pausedSteps(execution)
1094
1272
  return { run: updated, execution, budget, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
1095
1273
  }
@@ -1114,7 +1292,7 @@ async function cancelPausedStep(runId, packageId) {
1114
1292
  }
1115
1293
 
1116
1294
  /** Stored team packages in plan shape; `override` reassigns the retried package. */
1117
- function teamPlanFromRun(run, ids, override, targetId) {
1295
+ function teamPlanFromRun(run, ids, override, targetId, estimates) {
1118
1296
  const workPackages = run.packages.filter(item => ids.includes(item.id)).map(item => ({
1119
1297
  id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn ?? [],
1120
1298
  type: item.type ?? 'general', purpose: item.purpose ?? 'execution',
@@ -1124,7 +1302,7 @@ function teamPlanFromRun(run, ids, override, targetId) {
1124
1302
  ...(item.plannedReasoningEffort && !(item.id === targetId && override
1125
1303
  && (override.provider !== item.recommendedProvider || override.model !== item.recommendedModel))
1126
1304
  ? { recommendedReasoningEffort: item.plannedReasoningEffort } : {}),
1127
- estimatedCost: item.estimatedCost ?? null,
1305
+ estimatedCost: estimates.get(item.id) ?? null,
1128
1306
  }))
1129
1307
  return { mode: 'team', team: { workPackages } }
1130
1308
  }
@@ -1133,13 +1311,10 @@ function teamPlanFromRun(run, ids, override, targetId) {
1133
1311
  * Read-only team retry: the failed step plus downstream steps that had not
1134
1312
  * succeeded run through the same signed runner; earlier answers are context.
1135
1313
  */
1136
- async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root, signal, options, config = {} }) {
1314
+ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, confirmOverBudget, cwd, root, signal, options, config = {} }) {
1137
1315
  const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
1138
1316
  const executionEnvironment = teamExecutionEnvironment(run.executionEnvironment, mode)
1139
- const downstream = downstreamPackageIds(run.packages, target.id)
1140
- .filter(id => !run.packages.find(item => item.id === id)?.ok)
1141
- const ids = [target.id, ...downstream]
1142
- const plan = teamPlanFromRun(run, ids, override, target.id)
1317
+ const plan = teamPlanFromRun(run, ids, override, target.id, estimates)
1143
1318
  const cliModels = Object.fromEntries(Object.entries(run.cliModels ?? {}).filter(([key]) => ids.includes(key) && !(override && key === target.id)))
1144
1319
  if (override) {
1145
1320
  const reassignedPlan = { team: { workPackages: plan.team.workPackages.filter(item => item.id === target.id) } }
@@ -1149,10 +1324,31 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd,
1149
1324
  .map(item => ({ id: item.id, name: item.name, finalText: item.answer }))
1150
1325
  const runTeam = options.runTeam ?? runOfficialTeam
1151
1326
  const installed = Array.isArray(options.installedToolIds) ? options.installedToolIds : await installedToolIds()
1152
- const execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode, executionEnvironment,
1327
+ const buildRetry = ({ status, results, workspace = {} }) => {
1328
+ const next = structuredClone(run)
1329
+ const converted = teamExecutionResults(plan, { status, results })
1330
+ if (override) {
1331
+ const item = converted.packages.find(item => item.id === target.id)
1332
+ if (item) item.reassigned = true
1333
+ }
1334
+ mergeRerun(next, converted, { rerunIds: ids, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1335
+ for (const item of next.packages) if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
1336
+ next.executionEnvironment = executionEnvironment
1337
+ if (mode === 'workspace-write' && workspace.workspace) next.isolatedWorkspace = workspace.workspace
1338
+ if (workspace.baseCommit) next.baseCommit = workspace.baseCommit
1339
+ return next
1340
+ }
1341
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
1342
+ packageEstimates: [...estimates].map(([id, estimatedCostUsd]) => ({ id, estimatedCostUsd })), settings: budgetSettings(config), confirmOverBudget })
1343
+ if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
1344
+ let execution
1345
+ try { execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode, executionEnvironment,
1153
1346
  installedIds: installed, cliModels, signal, sandbox: ctx.sandbox, previous, onlyIds: ids,
1347
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1348
+ onWorkspace: value => lifecycle.onWorkspace(value),
1154
1349
  ...(mode === 'workspace-write' ? { seedFrom: { workspace: run.isolatedWorkspace, baseCommit: run.baseCommit } } : {}),
1155
- ...(options.runtime ? { runtime: options.runtime } : {}) })
1350
+ ...(options.runtime ? { runtime: options.runtime } : {}) }) }
1351
+ catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1156
1352
  const converted = teamExecutionResults(plan, execution)
1157
1353
  if (override) {
1158
1354
  const entry = converted.packages.find(item => item.id === target.id)
@@ -1164,8 +1360,12 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd,
1164
1360
  const note = await noteCliQuota(config, result.toolId, `${result.error ?? ''}\n${result.outputTail ?? ''}`)
1165
1361
  if (note) quotaNotes.push([result.id, note])
1166
1362
  }
1167
- const updated = await routerStateStore().updateRun(run.id, current => {
1168
- mergeRerun(current, converted, { rerunIds: ids, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1363
+ const updated = buildRetry({ status: execution.status, results: execution.results,
1364
+ workspace: { workspace: execution.workspace, baseCommit: execution.baseCommit } })
1365
+ const current = updated
1366
+ for (const item of current.packages) {
1367
+ if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
1368
+ }
1169
1369
  for (const [id, note] of quotaNotes) {
1170
1370
  const stored = current.packages.find(item => item.id === id)
1171
1371
  if (stored) stored.billingSwitch = note
@@ -1185,7 +1385,7 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd,
1185
1385
  if (cliModels[target.id]) bindings[target.id] = cliModels[target.id]
1186
1386
  current.cliModels = bindings
1187
1387
  }
1188
- })
1388
+ try { await lifecycle.finish(updated) } catch (error) { await lifecycle.interrupt(updated); throw error }
1189
1389
  return { run: updated, execution: { ...converted, ranIds: ids, raw: execution }, budget }
1190
1390
  }
1191
1391
 
@@ -1211,6 +1411,7 @@ async function rerunToolRun(ctx, config, { run, confirmOverBudget, confirmWrite,
1211
1411
  const cwd = text(workspace) || run.workspace
1212
1412
  if (!cwd || !(await fileExists(cwd))) throw new Error('原运行的工作区不可用,无法重跑;请在原工作区的会话中重试。')
1213
1413
  const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root: text(root) || cwd, signal, rerunOf: run.id,
1414
+ confirmOverBudget,
1214
1415
  ...(options.runTask ? { runTask: options.runTask } : {}) })
1215
1416
  const execution = { status: result.status, packages: recorded?.packages ?? [], ranIds: ['direct'], raw: result }
1216
1417
  return { run: recorded ?? run, execution, budget: planned.budget, rerunOf: run.id, newRunId: recorded?.id ?? null }
@@ -1279,16 +1480,40 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
1279
1480
  }
1280
1481
 
1281
1482
  /** Run a planned tool call and record it; `rerunOf` links a rerun to the original run. */
1282
- async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf = null, runTask = runOfficialTask }) {
1483
+ async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf = null, confirmOverBudget = false, runTask = runOfficialTask }) {
1283
1484
  const startedAt = Date.now()
1284
- const result = await runTask({ toolId: planned.toolId, task: planned.taskText, modelId: planned.modelId, workspace: cwd, allowedRoot: root ?? cwd,
1285
- mode: planned.mode, signal, sandbox: ctx.sandbox })
1286
- const recorded = result.status === 'unsupported' ? null : await recordToolRun({
1287
- toolId: planned.toolId, task: planned.taskText, provider: planned.provider, model: planned.model, cliModel: planned.cliModel,
1288
- mode: planned.mode, workspace: cwd, result, startedAt, routes: planned.routes, config,
1289
- estimatedCost: planned.estimate, budget: planned.budget, rerunOf,
1290
- })
1291
- return { result, recorded }
1485
+ const runId = randomUUID()
1486
+ const makeToolRecord = (result, location = {}) => {
1487
+ const run = buildToolRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(),
1488
+ toolId: planned.toolId, toolLabel: planned.tool?.label ?? planned.toolId, task: planned.taskText,
1489
+ provider: planned.provider, model: planned.model, cliModel: planned.cliModel,
1490
+ mode: planned.mode, workspace: cwd, result, estimatedCost: planned.estimate, rerunOf,
1491
+ pricingFor: item => routePricing(planned.routes, item), billingFor: billingForResult, budget: planned.budget })
1492
+ if (location.isolatedWorkspace) run.isolatedWorkspace = location.isolatedWorkspace
1493
+ if (location.baseCommit) run.baseCommit = location.baseCommit
1494
+ return run
1495
+ }
1496
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), settings: budgetSettings(config), confirmOverBudget,
1497
+ packageEstimates: [{ id: 'direct', estimatedCostUsd: planned.estimate ?? null }],
1498
+ buildRun: ({ status, results, workspace }) => makeToolRecord(results[0] ?? { status, notStarted: true }, workspace) })
1499
+ if (!lifecycle.accepted) {
1500
+ planned.budget = pausedExecutionBudget(lifecycle)
1501
+ return { result: { status: 'paused-budget', budget: planned.budget }, recorded: null }
1502
+ }
1503
+ let result
1504
+ try {
1505
+ result = await runTask({ toolId: planned.toolId, task: planned.taskText, modelId: planned.modelId, workspace: cwd, allowedRoot: root ?? cwd,
1506
+ mode: planned.mode, signal, sandbox: ctx.sandbox,
1507
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: item => lifecycle.onCheckpointResult(item),
1508
+ onWorkspace: value => lifecycle.onWorkspace(value) })
1509
+ } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1510
+ const recorded = makeToolRecord(result, { isolatedWorkspace: result.isolatedWorkspace, baseCommit: result.baseCommit })
1511
+ try {
1512
+ if (result.status !== 'succeeded') await annotateCliQuota(recorded, config,
1513
+ [{ id: 'direct', toolId: planned.toolId, text: `${result.error ?? result.reason ?? ''}\n${result.outputTail ?? ''}` }])
1514
+ await lifecycle.finish(recorded)
1515
+ } catch (error) { await lifecycle.interrupt(recorded); throw error }
1516
+ return { result, recorded: result.status === 'unsupported' ? null : recorded }
1292
1517
  }
1293
1518
 
1294
1519
  /** Ledger record for model_router_tool_run. */
@@ -1327,9 +1552,13 @@ export async function rateRecordedResult({ runId, packageId, rating, expectedFin
1327
1552
 
1328
1553
  /** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
1329
1554
  export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1555
+ await createBudgetReservations({ state: routerStateStore() }).recoverInterruptedExecutions()
1330
1556
  const saved = await routingSavedState(config)
1331
1557
  const spent = spending(saved.runs, undefined, saved.archivedSpending)
1332
1558
  const settings = budgetSettings(config)
1559
+ const entries = Object.values(saved.budgetReservations ?? {})
1560
+ const reserved = { ...reservationSpending(saved), activeCount: entries.filter(item => item.status === 'active').length,
1561
+ interruptedCount: entries.filter(item => item.status === 'interrupted').length }
1333
1562
  return {
1334
1563
  runs: saved.runs.slice(-limit).reverse().map(run => ({
1335
1564
  ...run,
@@ -1338,7 +1567,12 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1338
1567
  .map(item => ({ ...item, answer: text(item.answer).slice(0, 1_500) })),
1339
1568
  })),
1340
1569
  spent,
1341
- budget: { ...settings, ...budgetCheck({ estimateUsd: null, spent, ...settings }), historyVerified: saved.historyVerified !== false },
1570
+ reserved,
1571
+ reservations: entries.map(entry => ({ id: entry.id, runId: entry.runId, status: entry.status, updatedAt: entry.updatedAt,
1572
+ unknown: entry.packages.some(item => item.estimatedCostUsd === null || entry.status === 'interrupted' && item.started),
1573
+ remainingEstimateUsd: entry.packages.every(item => Number.isFinite(item.estimatedCostUsd))
1574
+ ? entry.packages.reduce((sum, item) => sum + item.estimatedCostUsd, 0) : null })),
1575
+ budget: { ...budgetFor(config, saved, null, { checkEstimate: false }), historyVerified: saved.historyVerified !== false },
1342
1576
  storageAvailable: saved.storageAvailable !== false,
1343
1577
  storageNotice: saved.storageAvailable === false ? '执行记录存储暂不可用;下方费用与学习摘要尚未核验,不能当作零花费或没有反馈。'
1344
1578
  : saved.historyVerified === false ? '此前执行记录已损坏或不完整,当前摘要只包含可读记录;不能视为完整费用历史,请从备份恢复后再核验预算。' : '',
@@ -1499,15 +1733,39 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
1499
1733
  reason: '官方 Harness 进程沙箱不可用,不能执行官方 CLI 团队。' }
1500
1734
  throwIfAborted(options.signal)
1501
1735
  const startedAt = Date.now()
1502
- const execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
1736
+ const runId = randomUUID()
1737
+ const makeTeamRecord = (execution, location = {}) => {
1738
+ const run = buildTeamRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(), task: planned.taskText,
1739
+ plan: planned.plan, execution, mode: planned.mode, workspace,
1740
+ preset: planned.plan.preset ?? DEFAULT_ROUTING_PRESET, pricingFor: item => routePricing(planned.routes, item), billingFor: billingForResult,
1741
+ budget: { estimateUsd: budget.estimateUsd, confirmed: options.confirmOverBudget === true } })
1742
+ run.executionEnvironment = executionEnvironment
1743
+ run.cliModels = { ...planned.cliModels }
1744
+ if (planned.mode === 'workspace-write' && location.workspace) run.isolatedWorkspace = location.workspace
1745
+ if (location.baseCommit) run.baseCommit = location.baseCommit
1746
+ return run
1747
+ }
1748
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), settings: budgetSettings(config),
1749
+ confirmOverBudget: options.confirmOverBudget === true, packageEstimates: packageEstimates(planned.plan, planned.taskText),
1750
+ buildRun: ({ status, results, workspace: location }) => makeTeamRecord({ status, results }, location) })
1751
+ if (!lifecycle.accepted) return { ...common, status: 'paused-budget', budget: pausedExecutionBudget(lifecycle) }
1752
+ let execution
1753
+ try { execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
1503
1754
  allowedRoot: options.root ?? workspace, mode: planned.mode, executionEnvironment, installedIds: planned.installed,
1504
1755
  cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox,
1505
1756
  onPackageStart: item => reportRunProgress(options, { type: 'package-start', package: item }),
1506
1757
  onPackage: item => reportRunProgress(options, { type: 'package-complete', package: item }),
1507
- onStage: stage => reportRunProgress(options, { type: 'stage', stage }) })
1758
+ onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
1759
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1760
+ onWorkspace: value => lifecycle.onWorkspace(value) }) }
1761
+ catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1508
1762
  await reportRunProgress(options, { type: 'stage', stage: 'saving' })
1509
- const run = await recordTeamRun({ task: planned.taskText, plan: planned.plan, execution, mode: planned.mode, executionEnvironment, workspace,
1510
- routes: planned.routes, cliModels: planned.cliModels, budget, startedAt, config })
1763
+ const run = makeTeamRecord(execution, { workspace: execution.workspace, baseCommit: execution.baseCommit })
1764
+ try {
1765
+ await annotateCliQuota(run, config, (execution.results ?? []).filter(item => item.status !== 'succeeded' && item.toolId)
1766
+ .map(item => ({ id: item.id, toolId: item.toolId, text: `${item.error ?? ''}\n${item.outputTail ?? ''}` })))
1767
+ await lifecycle.finish(run)
1768
+ } catch (error) { await lifecycle.interrupt(run); throw error }
1511
1769
  return { ...common, status: execution.status, execution, run, runId: run?.id ?? null,
1512
1770
  rerunNotice: planned.mode === 'read-only'
1513
1771
  ? '失败的工作包可用 model_router_rerun_step 单步重跑(只重跑该步及其未完成的下游)。'
@@ -1605,7 +1863,7 @@ const APPROVAL_TEXT = Object.freeze({
1605
1863
  */
1606
1864
  export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = null } = {}) {
1607
1865
  const codes = []
1608
- const runKinds = ['model_router_execute', 'model_router_rerun_step', 'model_router_team_execute', 'model_router_tool_run']
1866
+ const runKinds = ['model_router_execute', 'model_router_rerun_step', 'model_router_team_execute', 'model_router_tool_run', 'model_router_consult']
1609
1867
  if (!runKinds.includes(name)) return codes
1610
1868
  if ((name === 'model_router_tool_run' || name === 'model_router_team_execute') && args.mode === 'workspace-write') codes.push('workspace-write')
1611
1869
  if (name === 'model_router_rerun_step' && rerunRun && rerunSupport(rerunRun).writes) codes.push(rerunRun.kind === 'tool' ? 'workspace-write' : 'rerun-write')
@@ -1983,6 +2241,7 @@ export function apply(ctx, config = {}) {
1983
2241
  provider: { type: 'string', description: 'Configured provider id; pair with model.' },
1984
2242
  model: { type: 'string', description: 'Configured model id; pair with provider.' },
1985
2243
  outputLimit: { type: 'number', description: 'Maximum returned characters, from 500 to 50000.' },
2244
+ confirmOverBudget: { type: 'boolean', description: 'Only after explicit user approval when the API budget is exceeded or unverified.' },
1986
2245
  },
1987
2246
  output: JSON_OUTPUT,
1988
2247
  async execute(args, exec) {
@@ -1993,7 +2252,7 @@ export function apply(ctx, config = {}) {
1993
2252
  const route = requested ?? chooseConsultRoute(plan, routes, exec.agent)
1994
2253
  const fallback = valueOf(config, 'maxConsultOutputChars', 12_000)
1995
2254
  const limit = boundedInteger(args.outputLimit, finiteNumber(fallback, 12_000), 500, 50_000)
1996
- return jsonValue(await consultConfiguredModel(ctx, route, args.task, limit, exec.signal))
2255
+ return jsonValue(await executeBudgetedConsult(ctx, route, args.task, config, { limit, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true }))
1997
2256
  },
1998
2257
  }))
1999
2258
  ctx.commands.register({
@@ -2213,7 +2472,7 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
2213
2472
  return jsonValue({ status: 'paused-budget', budget: { ...planned.budget, paused: true }, estimateUsd: planned.estimate,
2214
2473
  notice: `${planned.budget.message} 已暂停,未启动 ${planned.tool.label}。请向用户说明后,以 confirmOverBudget: true 重新调用。` })
2215
2474
  }
2216
- const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal })
2475
+ const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true })
2217
2476
  return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.estimate, budget: planned.budget,
2218
2477
  ...(!planned.modelId && planned.model ? { modelNotice: result.modelNotice
2219
2478
  ?? 'Harness 模型 ID 未经此厂商 CLI 验证;本次使用厂商 CLI 已配置的默认模型。' } : {}),