@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.
- package/.dsh-plugin/client.js +334 -15
- package/.dsh-plugin/index.mjs +114 -29
- package/.dsh-plugin/official-tools-remote-service.mjs +2 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +27 -4
- package/.dsh-plugin/shared/official-tools-remote.mjs +14 -2
- package/.dsh-plugin/shared/router-state.mjs +6 -1
- package/.dsh-plugin/shared/run-jobs.mjs +169 -0
- package/.dsh-plugin/shared/task-executors.mjs +28 -6
- package/CHANGELOG.md +15 -0
- package/README.i18n.yaml +2 -2
- package/README.md +49 -5
- package/README.zh.md +49 -5
- package/docs/WORKBENCH_USER_GUIDE.zh.md +7 -1
- package/docs/assets/workbench-run-progress.png +0 -0
- package/docs/assets/workbench-run-timeout.png +0 -0
- package/docs/releases/0.23.0-validation.md +30 -0
- package/docs/releases/0.23.1-validation.md +23 -0
- package/package.json +8 -3
package/.dsh-plugin/index.mjs
CHANGED
|
@@ -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)
|
|
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 => {
|
|
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
|
|
1018
|
-
|
|
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,
|
|
1025
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
1780
|
+
const confirmed = new Set(request.confirmedReasons ?? [])
|
|
1726
1781
|
const missing = preview.reasons.filter(item => !confirmed.has(item.code))
|
|
1727
|
-
|
|
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,
|
|
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 =>
|
|
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
|
|
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)
|
|
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)
|