@ljwei-stak/dsh-model-router 0.23.1 → 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.
@@ -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
  }
@@ -1022,6 +1155,7 @@ function rerunEstimates(run, ids, targetId, override, routes, dataOptions) {
1022
1155
 
1023
1156
  export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, confirmUnsandboxed = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
1024
1157
  const { run, saved } = await storedRun(runId)
1158
+ if (saved.budgetReservations?.[run.id]) throw new Error('该运行仍在执行或费用待核对,请先核对原任务与厂商账单,不能直接重跑。')
1025
1159
  const target = run.packages.find(item => item.id === text(packageId))
1026
1160
  if (!target) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
1027
1161
  const choice = text(subscriptionChoice) || null
@@ -1068,7 +1202,7 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1068
1202
  const cwd = text(workspace) || run.workspace
1069
1203
  if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
1070
1204
  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 })
1205
+ ids: [...retryIds], estimates: estimates.byId, confirmOverBudget, cwd, root: text(root) || cwd, signal, options, config })
1072
1206
  const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
1073
1207
  .map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
1074
1208
  if (Object.keys(manualRetries).length) {
@@ -1088,7 +1222,18 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1088
1222
  }
1089
1223
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
1090
1224
  const retryGate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
1091
- 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({
1092
1237
  task: run.task,
1093
1238
  packages: run.packages.map(item => ({
1094
1239
  id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn,
@@ -1108,17 +1253,21 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1108
1253
  signal,
1109
1254
  ...(choice ? { subscriptionChoice: choice } : {}),
1110
1255
  ...executionHooks(ctx, options),
1256
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item),
1257
+ onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1111
1258
  billing: await billingContext(config, options),
1112
- apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => subscription
1113
- ? consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
1114
- : consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate }),
1115
- })
1116
- const updated = await routerStateStore().updateRun(run.id, current => {
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
- }
1121
- })
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 }
1122
1271
  const awaiting = pausedSteps(execution)
1123
1272
  return { run: updated, execution, budget, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
1124
1273
  }
@@ -1162,7 +1311,7 @@ function teamPlanFromRun(run, ids, override, targetId, estimates) {
1162
1311
  * Read-only team retry: the failed step plus downstream steps that had not
1163
1312
  * succeeded run through the same signed runner; earlier answers are context.
1164
1313
  */
1165
- async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, cwd, root, signal, options, config = {} }) {
1314
+ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, confirmOverBudget, cwd, root, signal, options, config = {} }) {
1166
1315
  const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
1167
1316
  const executionEnvironment = teamExecutionEnvironment(run.executionEnvironment, mode)
1168
1317
  const plan = teamPlanFromRun(run, ids, override, target.id, estimates)
@@ -1175,10 +1324,31 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
1175
1324
  .map(item => ({ id: item.id, name: item.name, finalText: item.answer }))
1176
1325
  const runTeam = options.runTeam ?? runOfficialTeam
1177
1326
  const installed = Array.isArray(options.installedToolIds) ? options.installedToolIds : await installedToolIds()
1178
- 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,
1179
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),
1180
1349
  ...(mode === 'workspace-write' ? { seedFrom: { workspace: run.isolatedWorkspace, baseCommit: run.baseCommit } } : {}),
1181
- ...(options.runtime ? { runtime: options.runtime } : {}) })
1350
+ ...(options.runtime ? { runtime: options.runtime } : {}) }) }
1351
+ catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1182
1352
  const converted = teamExecutionResults(plan, execution)
1183
1353
  if (override) {
1184
1354
  const entry = converted.packages.find(item => item.id === target.id)
@@ -1190,8 +1360,9 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
1190
1360
  const note = await noteCliQuota(config, result.toolId, `${result.error ?? ''}\n${result.outputTail ?? ''}`)
1191
1361
  if (note) quotaNotes.push([result.id, note])
1192
1362
  }
1193
- const updated = await routerStateStore().updateRun(run.id, current => {
1194
- 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
1195
1366
  for (const item of current.packages) {
1196
1367
  if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
1197
1368
  }
@@ -1214,7 +1385,7 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
1214
1385
  if (cliModels[target.id]) bindings[target.id] = cliModels[target.id]
1215
1386
  current.cliModels = bindings
1216
1387
  }
1217
- })
1388
+ try { await lifecycle.finish(updated) } catch (error) { await lifecycle.interrupt(updated); throw error }
1218
1389
  return { run: updated, execution: { ...converted, ranIds: ids, raw: execution }, budget }
1219
1390
  }
1220
1391
 
@@ -1240,6 +1411,7 @@ async function rerunToolRun(ctx, config, { run, confirmOverBudget, confirmWrite,
1240
1411
  const cwd = text(workspace) || run.workspace
1241
1412
  if (!cwd || !(await fileExists(cwd))) throw new Error('原运行的工作区不可用,无法重跑;请在原工作区的会话中重试。')
1242
1413
  const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root: text(root) || cwd, signal, rerunOf: run.id,
1414
+ confirmOverBudget,
1243
1415
  ...(options.runTask ? { runTask: options.runTask } : {}) })
1244
1416
  const execution = { status: result.status, packages: recorded?.packages ?? [], ranIds: ['direct'], raw: result }
1245
1417
  return { run: recorded ?? run, execution, budget: planned.budget, rerunOf: run.id, newRunId: recorded?.id ?? null }
@@ -1308,16 +1480,40 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
1308
1480
  }
1309
1481
 
1310
1482
  /** Run a planned tool call and record it; `rerunOf` links a rerun to the original run. */
1311
- 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 }) {
1312
1484
  const startedAt = Date.now()
1313
- const result = await runTask({ toolId: planned.toolId, task: planned.taskText, modelId: planned.modelId, workspace: cwd, allowedRoot: root ?? cwd,
1314
- mode: planned.mode, signal, sandbox: ctx.sandbox })
1315
- const recorded = result.status === 'unsupported' ? null : await recordToolRun({
1316
- toolId: planned.toolId, task: planned.taskText, provider: planned.provider, model: planned.model, cliModel: planned.cliModel,
1317
- mode: planned.mode, workspace: cwd, result, startedAt, routes: planned.routes, config,
1318
- estimatedCost: planned.estimate, budget: planned.budget, rerunOf,
1319
- })
1320
- 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 }
1321
1517
  }
1322
1518
 
1323
1519
  /** Ledger record for model_router_tool_run. */
@@ -1356,9 +1552,13 @@ export async function rateRecordedResult({ runId, packageId, rating, expectedFin
1356
1552
 
1357
1553
  /** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
1358
1554
  export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1555
+ await createBudgetReservations({ state: routerStateStore() }).recoverInterruptedExecutions()
1359
1556
  const saved = await routingSavedState(config)
1360
1557
  const spent = spending(saved.runs, undefined, saved.archivedSpending)
1361
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 }
1362
1562
  return {
1363
1563
  runs: saved.runs.slice(-limit).reverse().map(run => ({
1364
1564
  ...run,
@@ -1367,7 +1567,12 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1367
1567
  .map(item => ({ ...item, answer: text(item.answer).slice(0, 1_500) })),
1368
1568
  })),
1369
1569
  spent,
1370
- 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 },
1371
1576
  storageAvailable: saved.storageAvailable !== false,
1372
1577
  storageNotice: saved.storageAvailable === false ? '执行记录存储暂不可用;下方费用与学习摘要尚未核验,不能当作零花费或没有反馈。'
1373
1578
  : saved.historyVerified === false ? '此前执行记录已损坏或不完整,当前摘要只包含可读记录;不能视为完整费用历史,请从备份恢复后再核验预算。' : '',
@@ -1528,15 +1733,39 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
1528
1733
  reason: '官方 Harness 进程沙箱不可用,不能执行官方 CLI 团队。' }
1529
1734
  throwIfAborted(options.signal)
1530
1735
  const startedAt = Date.now()
1531
- 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,
1532
1754
  allowedRoot: options.root ?? workspace, mode: planned.mode, executionEnvironment, installedIds: planned.installed,
1533
1755
  cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox,
1534
1756
  onPackageStart: item => reportRunProgress(options, { type: 'package-start', package: item }),
1535
1757
  onPackage: item => reportRunProgress(options, { type: 'package-complete', package: item }),
1536
- 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 }
1537
1762
  await reportRunProgress(options, { type: 'stage', stage: 'saving' })
1538
- const run = await recordTeamRun({ task: planned.taskText, plan: planned.plan, execution, mode: planned.mode, executionEnvironment, workspace,
1539
- 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 }
1540
1769
  return { ...common, status: execution.status, execution, run, runId: run?.id ?? null,
1541
1770
  rerunNotice: planned.mode === 'read-only'
1542
1771
  ? '失败的工作包可用 model_router_rerun_step 单步重跑(只重跑该步及其未完成的下游)。'
@@ -1634,7 +1863,7 @@ const APPROVAL_TEXT = Object.freeze({
1634
1863
  */
1635
1864
  export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = null } = {}) {
1636
1865
  const codes = []
1637
- 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']
1638
1867
  if (!runKinds.includes(name)) return codes
1639
1868
  if ((name === 'model_router_tool_run' || name === 'model_router_team_execute') && args.mode === 'workspace-write') codes.push('workspace-write')
1640
1869
  if (name === 'model_router_rerun_step' && rerunRun && rerunSupport(rerunRun).writes) codes.push(rerunRun.kind === 'tool' ? 'workspace-write' : 'rerun-write')
@@ -2012,6 +2241,7 @@ export function apply(ctx, config = {}) {
2012
2241
  provider: { type: 'string', description: 'Configured provider id; pair with model.' },
2013
2242
  model: { type: 'string', description: 'Configured model id; pair with provider.' },
2014
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.' },
2015
2245
  },
2016
2246
  output: JSON_OUTPUT,
2017
2247
  async execute(args, exec) {
@@ -2022,7 +2252,7 @@ export function apply(ctx, config = {}) {
2022
2252
  const route = requested ?? chooseConsultRoute(plan, routes, exec.agent)
2023
2253
  const fallback = valueOf(config, 'maxConsultOutputChars', 12_000)
2024
2254
  const limit = boundedInteger(args.outputLimit, finiteNumber(fallback, 12_000), 500, 50_000)
2025
- 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 }))
2026
2256
  },
2027
2257
  }))
2028
2258
  ctx.commands.register({
@@ -2242,7 +2472,7 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
2242
2472
  return jsonValue({ status: 'paused-budget', budget: { ...planned.budget, paused: true }, estimateUsd: planned.estimate,
2243
2473
  notice: `${planned.budget.message} 已暂停,未启动 ${planned.tool.label}。请向用户说明后,以 confirmOverBudget: true 重新调用。` })
2244
2474
  }
2245
- 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 })
2246
2476
  return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.estimate, budget: planned.budget,
2247
2477
  ...(!planned.modelId && planned.model ? { modelNotice: result.modelNotice
2248
2478
  ?? 'Harness 模型 ID 未经此厂商 CLI 验证;本次使用厂商 CLI 已配置的默认模型。' } : {}),