@ljwei-stak/dsh-model-router 0.23.1 → 0.25.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.
- package/.dsh-plugin/client.js +2534 -4455
- package/.dsh-plugin/index.mjs +387 -79
- package/.dsh-plugin/shared/budget-reservations.mjs +273 -0
- package/.dsh-plugin/shared/execution-accounting.mjs +61 -0
- package/.dsh-plugin/shared/execution-lifecycle.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +42 -13
- package/.dsh-plugin/shared/official-tools-runtime.mjs +3 -2
- package/.dsh-plugin/shared/router-state.mjs +36 -8
- package/.dsh-plugin/shared/run-ledger.mjs +21 -2
- package/.dsh-plugin/shared/subscription-usage.mjs +210 -0
- package/.dsh-plugin/shared/task-executors.mjs +125 -23
- package/.dsh-plugin/shared/zcode-bundle.mjs +3 -3
- package/CHANGELOG.md +23 -0
- package/README.i18n.yaml +2 -2
- package/README.md +35 -22
- package/README.zh.md +32 -20
- package/docs/WORKBENCH_USER_GUIDE.zh.md +8 -6
- package/docs/assets/workbench-budget-reservations.png +0 -0
- package/docs/assets/workbench-subscription-usage.png +0 -0
- package/docs/releases/0.24.0-validation.md +41 -0
- package/docs/releases/0.25.0-validation.md +41 -0
- package/package.json +11 -3
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable execution holds. Every transition uses router-state's one file-lock
|
|
3
|
+
* transaction, so another Host cannot approve against an out-of-date balance.
|
|
4
|
+
* A hold is a remaining estimate, never an invoice or proof of a zero charge.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { budgetCheck, spending } from './run-ledger.mjs'
|
|
8
|
+
|
|
9
|
+
export const MAX_EXECUTION_RESERVATIONS = 100
|
|
10
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
12
|
+
const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/u.test(value)
|
|
13
|
+
const timestamp = value => amount(value) && Number.isFinite(new Date(value).getTime())
|
|
14
|
+
|
|
15
|
+
/** Unknown/corrupt holds make history incomplete; silently losing them is unsafe. */
|
|
16
|
+
export function sanitizeBudgetReservations(value) {
|
|
17
|
+
if (value === undefined) return { reservations: {}, invalid: false }
|
|
18
|
+
if (!record(value)) return { reservations: {}, invalid: true }
|
|
19
|
+
const reservations = {}
|
|
20
|
+
let invalid = false
|
|
21
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
22
|
+
if (!identifier(key) || !record(entry) || entry.id !== key || entry.runId !== key
|
|
23
|
+
|| !identifier(entry.ownerToken) || !Number.isSafeInteger(entry.ownerPid) || entry.ownerPid < 1
|
|
24
|
+
|| !['active', 'interrupted'].includes(entry.status) || !timestamp(entry.createdAt) || !timestamp(entry.updatedAt)
|
|
25
|
+
|| !Array.isArray(entry.packages) || !Array.isArray(entry.completedIds)
|
|
26
|
+
|| entry.completedIds.some(id => !identifier(id))
|
|
27
|
+
|| entry.packages.some(item => !record(item) || !identifier(item.id)
|
|
28
|
+
|| (item.billing !== undefined && !['api', 'subscription'].includes(item.billing))
|
|
29
|
+
|| (item.estimatedCostUsd !== null && !amount(item.estimatedCostUsd)) || typeof item.started !== 'boolean')
|
|
30
|
+
|| new Set(entry.packages.map(item => item.id)).size !== entry.packages.length
|
|
31
|
+
|| entry.packages.some(item => entry.completedIds.includes(item.id))) {
|
|
32
|
+
invalid = true
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
reservations[key] = {
|
|
36
|
+
id: key, runId: key, ownerToken: entry.ownerToken, ownerPid: entry.ownerPid,
|
|
37
|
+
status: entry.status, createdAt: entry.createdAt, updatedAt: entry.updatedAt,
|
|
38
|
+
...(timestamp(entry.interruptedAt) ? { interruptedAt: entry.interruptedAt } : {}),
|
|
39
|
+
packages: entry.packages.map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCostUsd, started: item.started, ...(item.billing ? { billing: item.billing } : {}) })),
|
|
40
|
+
completedIds: [...new Set(entry.completedIds)],
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { reservations, invalid }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Live and unreconciled interrupted work occupies today's and this month's room. */
|
|
47
|
+
export function reservationSpending(state, { excludeReservationId = null } = {}) {
|
|
48
|
+
let reservedUsd = 0
|
|
49
|
+
let unknownReservations = 0
|
|
50
|
+
for (const entry of Object.values(state?.budgetReservations ?? {})) {
|
|
51
|
+
if (!record(entry) || entry.id === excludeReservationId) continue
|
|
52
|
+
for (const item of Array.isArray(entry.packages) ? entry.packages : []) {
|
|
53
|
+
if (item.billing === 'subscription') continue
|
|
54
|
+
if (amount(item.estimatedCostUsd)) reservedUsd += item.estimatedCostUsd
|
|
55
|
+
if (!amount(item.estimatedCostUsd) || (entry.status === 'interrupted' && item.started)) unknownReservations += 1
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { today: reservedUsd, month: reservedUsd, unknownToday: unknownReservations,
|
|
59
|
+
unknownMonth: unknownReservations, reservedUsd, unknownReservations }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizedEstimates(value, run) {
|
|
63
|
+
const values = value ?? (run?.packages ?? []).map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCost ?? null }))
|
|
64
|
+
if (!Array.isArray(values) || values.some(item => !record(item) || !identifier(item.id))) {
|
|
65
|
+
throw new TypeError('工作包预占必须包含有效的工作包 ID。')
|
|
66
|
+
}
|
|
67
|
+
const seen = new Set()
|
|
68
|
+
return values.map(item => {
|
|
69
|
+
if (seen.has(item.id)) throw new TypeError('工作包预占 ID 不能重复。')
|
|
70
|
+
seen.add(item.id)
|
|
71
|
+
const estimatedCostUsd = Object.hasOwn(item, 'estimatedCostUsd') ? item.estimatedCostUsd : item.estimatedCost ?? null
|
|
72
|
+
if (estimatedCostUsd !== null && !amount(estimatedCostUsd)) throw new TypeError('工作包预估费用必须是非负有限数字或 null。')
|
|
73
|
+
if (item.billing !== undefined && !['api', 'subscription'].includes(item.billing)) throw new TypeError('预占计费通道无效。')
|
|
74
|
+
return { id: item.id, estimatedCostUsd: item.billing === 'subscription' ? 0 : estimatedCostUsd,
|
|
75
|
+
started: item.started === true, ...(item.billing ? { billing: item.billing } : {}) }
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function checkedRun(run, id = run?.id) {
|
|
80
|
+
if (!record(run) || !identifier(id) || run.id !== id || !Array.isArray(run.packages)) {
|
|
81
|
+
throw new TypeError('执行检查点必须包含对应的完整运行记录。')
|
|
82
|
+
}
|
|
83
|
+
return structuredClone(run)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function upsertRun(current, run) {
|
|
87
|
+
const index = current.runs.findIndex(item => item.id === run.id)
|
|
88
|
+
if (index < 0) current.runs.push(run)
|
|
89
|
+
else current.runs[index] = run
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ownedReservation(current, reservationId, ownerToken) {
|
|
93
|
+
const entry = current.budgetReservations?.[reservationId]
|
|
94
|
+
if (!entry || entry.ownerToken !== ownerToken || entry.status !== 'active') {
|
|
95
|
+
const error = new Error('执行预占不存在、已中断或属于其他执行;原记录未修改。')
|
|
96
|
+
error.code = 'ROUTER_RESERVATION_OWNERSHIP'
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
return entry
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function estimateTotal(packages) {
|
|
103
|
+
packages = packages.filter(item => item.billing !== 'subscription')
|
|
104
|
+
if (packages.some(item => item.estimatedCostUsd === null)) return null
|
|
105
|
+
const total = packages.reduce((sum, item) => sum + item.estimatedCostUsd, 0)
|
|
106
|
+
return Number.isFinite(total) ? total : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function currentBudget(current, at, packages, options, excludeReservationId = null) {
|
|
110
|
+
const recorded = spending(current.runs, at, current.archivedSpending)
|
|
111
|
+
const holds = reservationSpending(current, { excludeReservationId })
|
|
112
|
+
const spent = { ...recorded, today: recorded.today + holds.today, month: recorded.month + holds.month,
|
|
113
|
+
unknownToday: recorded.unknownToday + holds.unknownToday, unknownMonth: recorded.unknownMonth + holds.unknownMonth }
|
|
114
|
+
const budget = budgetCheck({ estimateUsd: estimateTotal(packages), spent,
|
|
115
|
+
dailyLimitUsd: options.dailyLimitUsd, monthlyLimitUsd: options.monthlyLimitUsd })
|
|
116
|
+
const apiExposure = packages.some(item => item.billing !== 'subscription')
|
|
117
|
+
budget.apiExposure = apiExposure
|
|
118
|
+
if (!apiExposure) { budget.exceeded = null; budget.message = '订阅按用量单独统计,不占用 API 金额预算。' }
|
|
119
|
+
const unknownBudget = apiExposure && budget.limited && (!budget.estimateKnown
|
|
120
|
+
|| (options.dailyLimitUsd > 0 && spent.unknownToday > 0)
|
|
121
|
+
|| (options.monthlyLimitUsd > 0 && spent.unknownMonth > 0))
|
|
122
|
+
if (unknownBudget && !budget.exceeded) budget.message = '存在未核对费用或缺少单价,无法保证本次执行符合预算;请先核对账单或明确确认继续。'
|
|
123
|
+
return { budget, spent, recorded, unknownBudget, ...holds }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** false means definitely dead; permission failures and PID reuse stay conservative. */
|
|
127
|
+
function processAlive(pid) {
|
|
128
|
+
try { process.kill(pid, 0); return true }
|
|
129
|
+
catch (error) { return error?.code === 'ESRCH' ? false : null }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createBudgetReservations({ state, now = Date.now, pid = process.pid, isProcessAlive = processAlive,
|
|
133
|
+
maxReservations = MAX_EXECUTION_RESERVATIONS } = {}) {
|
|
134
|
+
if (!state || typeof state.update !== 'function' || typeof state.read !== 'function') throw new TypeError('需要持久状态存储。')
|
|
135
|
+
if (!Number.isSafeInteger(pid) || pid < 1) throw new TypeError('需要有效的执行进程 ID。')
|
|
136
|
+
if (!Number.isInteger(maxReservations) || maxReservations < 1 || maxReservations > MAX_EXECUTION_RESERVATIONS) throw new RangeError('执行预占数量上限无效。')
|
|
137
|
+
const snapshot = async () => reservationSpending(await state.read())
|
|
138
|
+
const beginExecution = options => {
|
|
139
|
+
const run = checkedRun(options?.run)
|
|
140
|
+
const ownerToken = options.ownerToken ?? randomUUID()
|
|
141
|
+
if (!identifier(ownerToken)) throw new TypeError('需要有效的执行所有权标记。')
|
|
142
|
+
const packages = normalizedEstimates(options.packageEstimates, run)
|
|
143
|
+
return state.update(current => {
|
|
144
|
+
const checks = currentBudget(current, now(), packages, options)
|
|
145
|
+
if (current.historyIncomplete && checks.budget.apiExposure) return { accepted: false, reason: 'history-incomplete', ...checks }
|
|
146
|
+
current.budgetReservations ??= {}
|
|
147
|
+
if (current.budgetReservations[run.id]) return { accepted: false, reason: 'active-run', ...checks }
|
|
148
|
+
if (Object.keys(current.budgetReservations).length >= maxReservations) return { accepted: false, reason: 'reservation-capacity', ...checks }
|
|
149
|
+
if (checks.budget.exceeded && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget', ...checks }
|
|
150
|
+
if (checks.unknownBudget && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget-unknown', ...checks }
|
|
151
|
+
const at = now()
|
|
152
|
+
const entry = { id: run.id, runId: run.id, ownerToken, ownerPid: pid, status: 'active',
|
|
153
|
+
createdAt: at, updatedAt: at, packages, completedIds: [] }
|
|
154
|
+
current.budgetReservations[run.id] = entry
|
|
155
|
+
upsertRun(current, run)
|
|
156
|
+
return { accepted: true, reservationId: entry.id, ownerToken, run, ...checks }
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
const checkpointExecution = ({ reservationId, ownerToken, run: nextRun, completedIds = [], startedIds = [],
|
|
160
|
+
checkBudget = false, budgetRun: affordabilityRun, ...options }) => {
|
|
161
|
+
const run = checkedRun(nextRun, reservationId)
|
|
162
|
+
const budgetRun = affordabilityRun === undefined ? run : checkedRun(affordabilityRun, reservationId)
|
|
163
|
+
if (![completedIds, startedIds].every(ids => Array.isArray(ids) && ids.every(identifier))) throw new TypeError('检查点工作包 ID 无效。')
|
|
164
|
+
return state.update(current => {
|
|
165
|
+
const entry = ownedReservation(current, reservationId, ownerToken)
|
|
166
|
+
const completed = new Set([...entry.completedIds, ...completedIds])
|
|
167
|
+
const started = new Set(startedIds)
|
|
168
|
+
const known = new Set([...entry.completedIds, ...entry.packages.map(item => item.id)])
|
|
169
|
+
if ([...completed, ...started].some(id => !known.has(id))) throw new TypeError('检查点包含未预占的工作包。')
|
|
170
|
+
const remaining = entry.packages.filter(item => !completed.has(item.id)).map(item => ({ ...item, started: item.started || started.has(item.id) }))
|
|
171
|
+
if (checkBudget) {
|
|
172
|
+
// Receipts may exceed their original estimates, or another execution
|
|
173
|
+
// may have settled meanwhile. Recheck the full remaining occupancy at
|
|
174
|
+
// this dispatch boundary without double-counting our previous hold.
|
|
175
|
+
const prospective = { ...current, runs: current.runs.map(item => item.id === run.id ? budgetRun : item) }
|
|
176
|
+
const checks = currentBudget(prospective, now(), remaining, options, entry.id)
|
|
177
|
+
const starting = entry.packages.filter(item => started.has(item.id) && !item.started)
|
|
178
|
+
if (starting.length && starting.every(item => item.billing === 'subscription')) {
|
|
179
|
+
// A later API step retains its hold and will be checked at its own
|
|
180
|
+
// dispatch boundary; it cannot make this subscription consume money.
|
|
181
|
+
Object.assign(checks.budget, { apiExposure: false, exceeded: null, estimateUsd: 0, estimateKnown: true,
|
|
182
|
+
message: '当前订阅步骤单独统计用量,不占用 API 金额预算。' })
|
|
183
|
+
checks.unknownBudget = false
|
|
184
|
+
}
|
|
185
|
+
if (current.historyIncomplete && checks.budget.apiExposure) return { accepted: false, reason: 'history-incomplete', ...checks }
|
|
186
|
+
if (checks.budget.exceeded && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget', ...checks }
|
|
187
|
+
if (checks.unknownBudget && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget-unknown', ...checks }
|
|
188
|
+
}
|
|
189
|
+
entry.packages = remaining
|
|
190
|
+
entry.completedIds = [...completed]
|
|
191
|
+
entry.updatedAt = now()
|
|
192
|
+
upsertRun(current, run)
|
|
193
|
+
return { accepted: true, reservationId, run, ...reservationSpending(current) }
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
const resizeExecution = options => {
|
|
197
|
+
const packages = normalizedEstimates(options.packageEstimates)
|
|
198
|
+
return state.update(current => {
|
|
199
|
+
const entry = ownedReservation(current, options.reservationId, options.ownerToken)
|
|
200
|
+
const checks = currentBudget(current, now(), packages, options, entry.id)
|
|
201
|
+
if (current.historyIncomplete && checks.budget.apiExposure) return { accepted: false, reason: 'history-incomplete', ...checks }
|
|
202
|
+
if (checks.budget.exceeded && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget', ...checks }
|
|
203
|
+
if (checks.unknownBudget && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget-unknown', ...checks }
|
|
204
|
+
if (packages.some(item => entry.completedIds.includes(item.id))) throw new TypeError('已完成工作包不能重新加入剩余预占。')
|
|
205
|
+
// An already-started call may still incur charges; resizing cannot erase it.
|
|
206
|
+
for (const previous of entry.packages.filter(item => item.started)) {
|
|
207
|
+
const next = packages.find(item => item.id === previous.id)
|
|
208
|
+
if (!next) throw new TypeError('执行中的工作包必须先保存结果,才能释放其预占。')
|
|
209
|
+
if (previous.billing !== 'subscription' && next.billing === 'subscription') {
|
|
210
|
+
throw new TypeError('执行中的 API 调用不能通过改为订阅释放金额预占;请先保存收据。')
|
|
211
|
+
}
|
|
212
|
+
next.started = true
|
|
213
|
+
}
|
|
214
|
+
entry.packages = packages
|
|
215
|
+
entry.updatedAt = now()
|
|
216
|
+
return { accepted: true, reservationId: entry.id, ...checks }
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
const finishExecution = ({ reservationId, ownerToken, run: nextRun }) => {
|
|
220
|
+
const run = checkedRun(nextRun, reservationId)
|
|
221
|
+
return state.update(current => {
|
|
222
|
+
ownedReservation(current, reservationId, ownerToken)
|
|
223
|
+
upsertRun(current, run)
|
|
224
|
+
delete current.budgetReservations[reservationId]
|
|
225
|
+
return { reservationId, run, ...reservationSpending(current) }
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
const markInterrupted = (current, entry, run, at) => {
|
|
229
|
+
entry.status = 'interrupted'
|
|
230
|
+
entry.interruptedAt = at
|
|
231
|
+
entry.updatedAt = at
|
|
232
|
+
if (run) {
|
|
233
|
+
run.status = 'interrupted'
|
|
234
|
+
run.finishedAt = at
|
|
235
|
+
for (const pending of entry.packages.filter(item => item.started)) {
|
|
236
|
+
const item = [...run.packages, ...(run.reviews ?? [])].find(item => item.id === pending.id)
|
|
237
|
+
if (!item || (!item.inFlight && !['pending', 'running'].includes(item.status))
|
|
238
|
+
|| amount(item.costUsd) || amount(item.knownCostUsd) || Array.isArray(item.qualityAttempts)) continue
|
|
239
|
+
// We know a request began, but never observed its receipt. Keep that
|
|
240
|
+
// uncertainty in the ledger as well as the durable estimate hold.
|
|
241
|
+
item.status = 'interrupted'
|
|
242
|
+
item.inFlight = false
|
|
243
|
+
item.ran = true
|
|
244
|
+
item.costUsd = null
|
|
245
|
+
item.costSource = 'interrupted-usage-unknown'
|
|
246
|
+
item.billing = pending.billing === 'subscription' || item.billing === 'subscription' ? 'subscription' : 'api'
|
|
247
|
+
item.finishedAt = at
|
|
248
|
+
}
|
|
249
|
+
run.interruption = { at, unreconciled: true, pendingPackageIds: entry.packages.map(item => item.id),
|
|
250
|
+
startedPackageIds: entry.packages.filter(item => item.started).map(item => item.id) }
|
|
251
|
+
} else current.historyIncomplete = true
|
|
252
|
+
}
|
|
253
|
+
const interruptExecution = ({ reservationId, ownerToken, run: nextRun }) => {
|
|
254
|
+
const run = checkedRun(nextRun, reservationId)
|
|
255
|
+
return state.update(current => {
|
|
256
|
+
const entry = ownedReservation(current, reservationId, ownerToken)
|
|
257
|
+
markInterrupted(current, entry, run, now())
|
|
258
|
+
upsertRun(current, run)
|
|
259
|
+
return { reservationId, run, ...reservationSpending(current) }
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
const recoverInterruptedExecutions = () => state.update(async current => {
|
|
263
|
+
const recovered = []
|
|
264
|
+
for (const entry of Object.values(current.budgetReservations ?? {})) {
|
|
265
|
+
if (entry.status !== 'active' || await isProcessAlive(entry.ownerPid) !== false) continue
|
|
266
|
+
const run = current.runs.find(item => item.id === entry.runId)
|
|
267
|
+
markInterrupted(current, entry, run, now())
|
|
268
|
+
recovered.push({ reservationId: entry.id, runId: entry.runId, ...entry })
|
|
269
|
+
}
|
|
270
|
+
return { recovered, ...reservationSpending(current) }
|
|
271
|
+
})
|
|
272
|
+
return { beginExecution, checkpointExecution, resizeExecution, finishExecution, interruptExecution, recoverInterruptedExecutions, snapshot }
|
|
273
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** API money and subscription quantities are separate dimensions. */
|
|
2
|
+
import { plannedPackages } from './run-ledger.mjs'
|
|
3
|
+
import { billingPlan } from './subscription-billing.mjs'
|
|
4
|
+
import { toolForProvider } from './official-tool-registry.mjs'
|
|
5
|
+
import { estimateAssignedPackage } from './router.mjs'
|
|
6
|
+
|
|
7
|
+
export function initialFunding(route, { billing = null, officialCli = false, toolId = null,
|
|
8
|
+
loginBillingFor = () => 'unknown', apiKeyFor = () => false, forceApi = false, executionChannel = null } = {}) {
|
|
9
|
+
const id = toolId ?? toolForProvider(route?.provider)?.id
|
|
10
|
+
if (forceApi) return { billing: 'api', route }
|
|
11
|
+
if (officialCli) return { billing: !apiKeyFor(id) && loginBillingFor(id) === 'subscription' ? 'subscription' : 'api', route }
|
|
12
|
+
if (!billing) return { billing: 'api', route }
|
|
13
|
+
const policy = billingPlan(route, billing.routes ?? [])
|
|
14
|
+
if (policy.mode === 'subscription-only') return { billing: 'subscription', route }
|
|
15
|
+
if (policy.mode === 'api-only' || billing.choice === 'api') return { billing: 'api', route: policy.apiRoute ?? route }
|
|
16
|
+
const sub = policy.subscription
|
|
17
|
+
if (!sub) return { billing: 'api', route }
|
|
18
|
+
if (billing.quota?.status?.(sub.key)) return { billing: 'api', route: policy.apiRoute ?? route }
|
|
19
|
+
if (sub.kind === 'plan-key') return { billing: 'subscription', route }
|
|
20
|
+
if (billing.choice === 'subscription') return { billing: 'subscription', route }
|
|
21
|
+
const login = billing.loginBillingFor?.(sub.toolId) ?? loginBillingFor(sub.toolId)
|
|
22
|
+
return { billing: route?.execution === 'api' || executionChannel === 'harness-llm'
|
|
23
|
+
|| login !== 'subscription' ? 'api' : 'subscription', route }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function attachPlanAccounting(plan, task, routes = [], context = {}) {
|
|
27
|
+
const entries = plannedPackages(plan, task).map((item, index) => {
|
|
28
|
+
const selected = item.manualAssignment?.kind === 'api' ? item.manualAssignment
|
|
29
|
+
: { provider: item.recommendedProvider, model: item.recommendedModel }
|
|
30
|
+
const route = routes.find(route => route.provider === selected.provider && route.model === selected.model) ?? selected
|
|
31
|
+
const funding = initialFunding(route, { ...context, executionChannel: item.executionChannel ?? plan.executionChannel,
|
|
32
|
+
...(context.billingChoices?.[item.id] && context.billing ? { billing: { ...context.billing, choice: context.billingChoices[item.id] } } : {}),
|
|
33
|
+
...(item.manualAssignment?.kind === 'api' ? { forceApi: true } : {}),
|
|
34
|
+
...(item.manualAssignment?.kind === 'tool' ? { officialCli: true, toolId: item.manualAssignment.toolId } : {}) })
|
|
35
|
+
const resource = plan.costBreakdown?.[index] ?? estimateAssignedPackage(route, item, task, plan.complexity?.band ?? 'balanced')
|
|
36
|
+
const changed = funding.route?.provider !== route.provider || funding.route?.model !== route.model
|
|
37
|
+
const estimate = changed || plan.subscriptionPricedRoutes?.includes(`${route.provider}/${route.model}`)
|
|
38
|
+
? estimateAssignedPackage(funding.route, item, task, plan.complexity?.band ?? 'balanced').estimatedCost
|
|
39
|
+
: item.estimatedCost ?? null
|
|
40
|
+
return { id: item.id, billing: funding.billing,
|
|
41
|
+
apiEstimatedCostUsd: funding.billing === 'subscription' ? 0 : estimate,
|
|
42
|
+
estimatedUsage: { inputTokens: resource.inputTokens ?? null, outputTokens: resource.outputTokens ?? null } }
|
|
43
|
+
})
|
|
44
|
+
const api = entries.filter(item => item.billing === 'api')
|
|
45
|
+
plan.accounting = { packages: entries, apiExposure: api.length > 0,
|
|
46
|
+
apiEstimatedCostUsd: api.some(item => !Number.isFinite(item.apiEstimatedCostUsd)) ? null
|
|
47
|
+
: api.reduce((sum, item) => sum + item.apiEstimatedCostUsd, 0) }
|
|
48
|
+
return plan.accounting
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const accountingEstimates = plan => plan.accounting.packages.map(item => ({ id: item.id,
|
|
52
|
+
billing: item.billing, estimatedCostUsd: item.apiEstimatedCostUsd }))
|
|
53
|
+
|
|
54
|
+
/** Zero marginal API expense for subscription routes; never rewrite real prices. */
|
|
55
|
+
export function planningFundingPrices(routes, context, channelForRoute = () => null) {
|
|
56
|
+
const subscriptionPricedRoutes = routes.filter(route => initialFunding(route, {
|
|
57
|
+
...context, executionChannel: channelForRoute(route),
|
|
58
|
+
}).billing === 'subscription').map(route => `${route.provider}/${route.model}`)
|
|
59
|
+
return { subscriptionPricedRoutes, pricing: Object.fromEntries(subscriptionPricedRoutes.map(key => [key,
|
|
60
|
+
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }])) }
|
|
61
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Required durable execution state, independent of advisory UI observers. */
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { createBudgetReservations } from './budget-reservations.mjs'
|
|
4
|
+
|
|
5
|
+
const receiptTimestamp = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
6
|
+
&& Number.isFinite(new Date(value).getTime())
|
|
7
|
+
|
|
8
|
+
export async function beginExecutionLifecycle({ state, buildRun, packageEstimates, settings = {}, confirmOverBudget = false }) {
|
|
9
|
+
const ownerToken = randomUUID()
|
|
10
|
+
const manager = createBudgetReservations({ state })
|
|
11
|
+
await manager.recoverInterruptedExecutions()
|
|
12
|
+
const started = new Map(), results = new Map(), completed = new Set()
|
|
13
|
+
const estimates = new Map(packageEstimates.map(item => [item.id, { estimatedCostUsd: item.estimatedCostUsd ?? item.estimatedCost ?? null, billing: item.billing ?? 'api' }]))
|
|
14
|
+
let workspace = {}, poisoned = null, closed = false
|
|
15
|
+
const current = (status = 'running') => {
|
|
16
|
+
const receipts = [...started].map(([id, item]) => results.get(id) ?? {
|
|
17
|
+
...item, id, ok: false, notStarted: true, answer: '', channel: item.channel ?? null,
|
|
18
|
+
})
|
|
19
|
+
for (const [id, result] of results) if (!started.has(id)) receipts.push(result)
|
|
20
|
+
const run = buildRun({ status, results: receipts, workspace })
|
|
21
|
+
if (status === 'running' || status === 'interrupted') {
|
|
22
|
+
run.status = status
|
|
23
|
+
if (status === 'running') run.finishedAt = null
|
|
24
|
+
for (const item of run.packages ?? []) {
|
|
25
|
+
if (!started.has(item.id) || completed.has(item.id)) continue
|
|
26
|
+
item.status = 'running'; item.inFlight = true
|
|
27
|
+
if (!results.has(item.id) || results.get(item.id)?.notStarted) item.billing = estimates.get(item.id)?.billing ?? 'api'
|
|
28
|
+
if (!results.has(item.id)) Object.assign(item, { ran: false, costUsd: null, costSource: 'pending-receipt' })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
run.checkpoint = { at: Date.now(), completedIds: [...completed], startedIds: [...started.keys()] }
|
|
32
|
+
return run
|
|
33
|
+
}
|
|
34
|
+
const initial = current()
|
|
35
|
+
const accepted = await manager.beginExecution({ run: initial, packageEstimates, ownerToken, ...settings, confirmOverBudget })
|
|
36
|
+
if (!accepted.accepted) return accepted
|
|
37
|
+
const identity = { reservationId: accepted.reservationId, ownerToken }
|
|
38
|
+
const rejectBudget = changed => {
|
|
39
|
+
const error = new Error(changed.budget?.message || '费用或预占已变化,已暂停启动后续步骤,请重新核对预算。')
|
|
40
|
+
error.code = 'ROUTER_BUDGET_CHANGED'
|
|
41
|
+
error.budget = { ...changed.budget, paused: true, exceeded: changed.budget?.exceeded ?? 'unknown' }
|
|
42
|
+
error.runId = initial.id
|
|
43
|
+
error.reason = changed.reason
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
const required = async operation => {
|
|
47
|
+
if (poisoned) throw poisoned
|
|
48
|
+
try { return await operation() }
|
|
49
|
+
catch (cause) {
|
|
50
|
+
poisoned = new Error('执行检查点保存失败,已停止启动后续步骤;请核对历史记录与费用。', { cause })
|
|
51
|
+
poisoned.code = 'ROUTER_CHECKPOINT_FAILED'
|
|
52
|
+
throw poisoned
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const save = run => required(() => manager.checkpointExecution({ ...identity, run,
|
|
56
|
+
completedIds: [...completed], startedIds: [...started.keys()] }))
|
|
57
|
+
const preserveReceiptTimes = run => {
|
|
58
|
+
for (const item of run.packages ?? []) {
|
|
59
|
+
const receipt = results.get(item.id)
|
|
60
|
+
if (receiptTimestamp(receipt?.finishedAt)) item.finishedAt = receipt.finishedAt
|
|
61
|
+
}
|
|
62
|
+
return run
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
...accepted,
|
|
66
|
+
id: initial.id,
|
|
67
|
+
current,
|
|
68
|
+
results: () => [...results.values()],
|
|
69
|
+
reservationFor: id => estimates.get(id),
|
|
70
|
+
activePackageId: () => [...started.keys()].findLast(id => !completed.has(id)),
|
|
71
|
+
async onCheckpointStart(item) {
|
|
72
|
+
const previous = started.get(item.id)
|
|
73
|
+
const alreadyStarted = started.has(item.id)
|
|
74
|
+
started.set(item.id, item)
|
|
75
|
+
const changed = await required(() => manager.checkpointExecution({ ...identity, run: current(),
|
|
76
|
+
completedIds: [...completed], startedIds: [...started.keys()], checkBudget: true, ...settings, confirmOverBudget }))
|
|
77
|
+
if (changed.accepted === false) {
|
|
78
|
+
if (alreadyStarted) started.set(item.id, previous)
|
|
79
|
+
else started.delete(item.id)
|
|
80
|
+
rejectBudget(changed)
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async onCheckpointResult(result) {
|
|
84
|
+
// Mutate the executor's receipt so later aggregate/final builds use the
|
|
85
|
+
// same observed settlement time, even after another day or month starts.
|
|
86
|
+
if (!receiptTimestamp(result.finishedAt)) result.finishedAt = Date.now()
|
|
87
|
+
results.set(result.id, result); completed.add(result.id); await save(current())
|
|
88
|
+
},
|
|
89
|
+
async onWorkspace(value) { workspace = { ...workspace, ...value }; await save(current()) },
|
|
90
|
+
async partialResult(result, { checkBudget = false } = {}) {
|
|
91
|
+
// The next attempt's unknown receipt is durable dispatch evidence, not
|
|
92
|
+
// an already-observed unknown invoice. Authorize against the previous
|
|
93
|
+
// observed receipt plus its newly reserved estimate, then save both in
|
|
94
|
+
// the same locked transaction before starting that attempt.
|
|
95
|
+
const budgetRun = checkBudget ? current() : undefined
|
|
96
|
+
const hadResult = results.has(result.id), previous = results.get(result.id)
|
|
97
|
+
if (!receiptTimestamp(result.finishedAt)) result.finishedAt = Date.now()
|
|
98
|
+
results.set(result.id, result)
|
|
99
|
+
const changed = await required(() => manager.checkpointExecution({ ...identity, run: current(), budgetRun,
|
|
100
|
+
completedIds: [...completed], startedIds: [...started.keys()], checkBudget, ...settings, confirmOverBudget: false }))
|
|
101
|
+
if (changed.accepted === false) {
|
|
102
|
+
if (hadResult) results.set(result.id, previous)
|
|
103
|
+
else results.delete(result.id)
|
|
104
|
+
rejectBudget(changed)
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
async saveRun(run, { completedIds = [], startedIds = [] } = {}) {
|
|
108
|
+
const previousCompleted = new Set(completed), previousStarted = new Map(started)
|
|
109
|
+
for (const id of completedIds) completed.add(id)
|
|
110
|
+
for (const id of startedIds) if (!started.has(id)) started.set(id, { id })
|
|
111
|
+
const changed = await required(() => manager.checkpointExecution({ ...identity, run,
|
|
112
|
+
completedIds: [...completed], startedIds: [...started.keys()], checkBudget: startedIds.length > 0,
|
|
113
|
+
...settings, confirmOverBudget: false }))
|
|
114
|
+
if (changed.accepted === false) {
|
|
115
|
+
completed.clear(); for (const id of previousCompleted) completed.add(id)
|
|
116
|
+
started.clear(); for (const [id, item] of previousStarted) started.set(id, item)
|
|
117
|
+
rejectBudget(changed)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
async reserveExtra(id, estimatedCostUsd, { confirmOverBudget: extraConfirmation = false } = {}) {
|
|
121
|
+
return required(async () => {
|
|
122
|
+
const next = new Map(estimates); next.set(id, { estimatedCostUsd, billing: 'api' })
|
|
123
|
+
const changed = await manager.resizeExecution({ ...identity,
|
|
124
|
+
packageEstimates: [...next].filter(([key]) => !completed.has(key)).map(([key, value]) => ({ id: key, ...value })),
|
|
125
|
+
...settings, confirmOverBudget: extraConfirmation === true })
|
|
126
|
+
if (changed.accepted) { estimates.clear(); for (const [key, value] of next) estimates.set(key, value) }
|
|
127
|
+
return changed
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
async finish(run) {
|
|
131
|
+
if (poisoned) throw poisoned
|
|
132
|
+
const value = await required(() => manager.finishExecution({ ...identity, run: preserveReceiptTimes(run) }))
|
|
133
|
+
closed = true
|
|
134
|
+
return value
|
|
135
|
+
},
|
|
136
|
+
async interrupt(run) {
|
|
137
|
+
if (closed) return
|
|
138
|
+
// Keep the reservation when storage itself remains unavailable. It is
|
|
139
|
+
// never safe to turn an unconfirmed paid receipt into free capacity.
|
|
140
|
+
try { await manager.interruptExecution({ ...identity, run: preserveReceiptTimes(run) }) } catch { /* original hold stays durable */ }
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -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 { observeExecutionProgress, redactDiagnostic, verifiedDiagnostic } from './task-executors.mjs'
|
|
14
|
+
import { awaitExecutionCheckpoint, 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
|
|
@@ -313,8 +313,10 @@ function packagePrompt(task, item, completed) {
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
/** Preflight every assignment before any model request or workspace write. */
|
|
316
|
-
export function executableTeamPackages(plan, installedIds = [], mode = 'read-only', executionEnvironment = 'harness'
|
|
317
|
-
|
|
316
|
+
export function executableTeamPackages(plan, installedIds = [], mode = 'read-only', executionEnvironment = 'harness',
|
|
317
|
+
executionCapabilities = officialToolExecutionCapabilities({ executionEnvironment })) {
|
|
318
|
+
if (!Array.isArray(executionCapabilities)) throw new TypeError('runtime capabilities must be an array')
|
|
319
|
+
const capabilities = new Map(executionCapabilities.map(item => [item.id, item]))
|
|
318
320
|
const packages = Array.isArray(plan?.team?.workPackages) ? plan.team.workPackages : []
|
|
319
321
|
const blocking = []
|
|
320
322
|
const assigned = packages.map(item => {
|
|
@@ -415,7 +417,7 @@ async function seedWorktree(isolated, seedFrom, signal) {
|
|
|
415
417
|
}
|
|
416
418
|
|
|
417
419
|
/** Sequential execution preserves DAG dependencies and avoids edit conflicts. */
|
|
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 }) {
|
|
420
|
+
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, onCheckpointStart, onCheckpointResult, onWorkspace }) {
|
|
419
421
|
await observeExecutionProgress(onStage, 'preparing')
|
|
420
422
|
if (!['harness', 'cli-native'].includes(executionEnvironment)
|
|
421
423
|
|| (executionEnvironment === 'cli-native' && mode !== 'workspace-write')) throw new TypeError('invalid team CLI execution environment')
|
|
@@ -427,7 +429,10 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
427
429
|
if (retry && mode !== 'read-only' && !(seedFrom && typeof seedFrom.workspace === 'string' && typeof seedFrom.baseCommit === 'string')) {
|
|
428
430
|
throw new TypeError('an editable team retry needs the earlier isolated worktree (seedFrom)')
|
|
429
431
|
}
|
|
430
|
-
|
|
432
|
+
// An injected adapter can declare its own capabilities as well as readiness
|
|
433
|
+
// and execution. The Host supplies no runtime override and always checks the
|
|
434
|
+
// actual platform registry before launching any official CLI.
|
|
435
|
+
const selected = executableTeamPackages(plan, installedIds, mode, executionEnvironment, runtime?.capabilities)
|
|
431
436
|
const assigned = retry ? selected.assigned.filter(item => onlyIds.includes(item.id)) : selected.assigned
|
|
432
437
|
const blocking = retry ? selected.blocking.filter(item => onlyIds.includes(item.id)) : selected.blocking
|
|
433
438
|
if (assigned.length === 0) return { status: 'blocked', blocking: [{ reason: '计划没有工作包。' }], results: [] }
|
|
@@ -448,6 +453,11 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
448
453
|
if (signal?.aborted) return { status: 'cancelled', workspace: source, results: [] }
|
|
449
454
|
const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
|
|
450
455
|
const runWorkspace = isolated?.workspace ?? source
|
|
456
|
+
const completed = []
|
|
457
|
+
const partial = () => ({ status: signal?.aborted ? 'cancelled' : 'incomplete', workspace: runWorkspace,
|
|
458
|
+
results: [...completed], ...(isolated ? { baseCommit: isolated.baseCommit } : {}) })
|
|
459
|
+
await awaitExecutionCheckpoint(onWorkspace, { workspace: runWorkspace, sourceWorkspace: source,
|
|
460
|
+
isolatedWorkspace: isolated?.workspace ?? null, baseCommit: isolated?.baseCommit ?? null }, partial, 'workspace')
|
|
451
461
|
let seeded = null
|
|
452
462
|
if (isolated && retry) {
|
|
453
463
|
try { seeded = await seedWorktree(isolated, seedFrom, signal) }
|
|
@@ -456,7 +466,6 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
456
466
|
return { status: 'blocked', blocking: [{ reason: String(error?.message ?? error) }], results: [] }
|
|
457
467
|
}
|
|
458
468
|
}
|
|
459
|
-
const completed = []
|
|
460
469
|
const cancelledRun = () => ({ status: 'cancelled', workspace: runWorkspace, results: completed,
|
|
461
470
|
...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) })
|
|
462
471
|
const context = retry ? (Array.isArray(previous) ? previous : []).filter(item => item?.id && typeof item.finalText === 'string') : []
|
|
@@ -466,8 +475,11 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
466
475
|
const cliModel = teamCliModel(item, configuredCliModels)
|
|
467
476
|
const prompt = packagePrompt(task, item, [...context, ...completed])
|
|
468
477
|
const startedAt = Date.now()
|
|
469
|
-
|
|
470
|
-
model: item.recommendedModel, requestedCliModel: cliModel, startedAt }
|
|
478
|
+
const starting = { ...item, provider: item.recommendedProvider,
|
|
479
|
+
model: item.recommendedModel, requestedCliModel: cliModel, startedAt }
|
|
480
|
+
await observeExecutionProgress(onPackageStart, starting)
|
|
481
|
+
if (signal?.aborted) return cancelledRun()
|
|
482
|
+
await awaitExecutionCheckpoint(onCheckpointStart, starting, partial, 'start')
|
|
471
483
|
if (signal?.aborted) return cancelledRun()
|
|
472
484
|
let result
|
|
473
485
|
try {
|
|
@@ -503,6 +515,11 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
503
515
|
actualModel: reportedModel,
|
|
504
516
|
...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
|
|
505
517
|
...(result.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
518
|
+
...(result.notStarted === true || result.status === 'unsupported' ? { notStarted: true } : {}),
|
|
519
|
+
...(result.blocked === true ? { blocked: true } : {}),
|
|
520
|
+
...(['api', 'subscription'].includes(result.billing) ? { billing: result.billing } : {}),
|
|
521
|
+
...(['configured-api-key', 'process-environment', 'cli-session'].includes(result.credentialSource)
|
|
522
|
+
? { credentialSource: result.credentialSource } : {}),
|
|
506
523
|
...(typeof result.source === 'string' ? { source: result.source } : {}),
|
|
507
524
|
...(typeof result.sandboxEnforcement === 'string' ? { sandboxEnforcement: result.sandboxEnforcement } : {}),
|
|
508
525
|
executionEnvironment,
|
|
@@ -518,6 +535,7 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
518
535
|
error: modelMismatch ? '官方 CLI 实际模型与指定模型不一致。'
|
|
519
536
|
: (result.error || result.reason) ? executionDiagnostic(result.error || result.reason) : null,
|
|
520
537
|
outputTail: result.status === 'succeeded' ? null : executionDiagnostic(verifiedDiagnostic(result)) || null })
|
|
538
|
+
await awaitExecutionCheckpoint(onCheckpointResult, completed.at(-1), partial, 'result')
|
|
521
539
|
await observeExecutionProgress(onPackage, completed.at(-1))
|
|
522
540
|
if (signal?.aborted) return cancelledRun()
|
|
523
541
|
if (packageStatus !== 'succeeded') return { status: packageStatus === 'cancelled' ? 'cancelled' : 'incomplete', workspace: runWorkspace, results: completed,
|
|
@@ -544,7 +562,7 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
544
562
|
}
|
|
545
563
|
|
|
546
564
|
/** One editable CLI task also uses the same isolated workspace and merge gate. */
|
|
547
|
-
export async function runOfficialTask({ toolId, task, workspace, allowedRoot, modelId, mode = 'read-only', signal, sandbox, runtime }) {
|
|
565
|
+
export async function runOfficialTask({ toolId, task, workspace, allowedRoot, modelId, mode = 'read-only', signal, sandbox, runtime, onCheckpointStart, onCheckpointResult, onWorkspace }) {
|
|
548
566
|
const source = await approvedSource(workspace, allowedRoot)
|
|
549
567
|
if (signal?.aborted) return { toolId, status: 'cancelled', notStarted: true, finalText: '' }
|
|
550
568
|
if (typeof sandbox?.confine !== 'function') return { toolId, status: 'unsupported', reason: '官方 Harness 进程沙箱不可用。' }
|
|
@@ -555,18 +573,29 @@ export async function runOfficialTask({ toolId, task, workspace, allowedRoot, mo
|
|
|
555
573
|
reason: readiness.reason ?? readiness.modeReasons?.[mode] ?? `官方 CLI 尚未就绪,或不支持 ${mode} 模式。` }
|
|
556
574
|
if (signal?.aborted) return { toolId, status: 'cancelled', notStarted: true, finalText: '' }
|
|
557
575
|
const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
|
|
576
|
+
const runWorkspace = isolated?.workspace ?? source
|
|
577
|
+
const location = isolated ? { isolatedWorkspace: isolated.workspace, baseCommit: isolated.baseCommit } : {}
|
|
558
578
|
let result
|
|
579
|
+
const partial = () => ({ id: 'direct', toolId, status: signal?.aborted ? 'cancelled' : 'failed',
|
|
580
|
+
notStarted: !result, finalText: '', ...result, ...location })
|
|
581
|
+
await awaitExecutionCheckpoint(onWorkspace, { workspace: runWorkspace, sourceWorkspace: source,
|
|
582
|
+
isolatedWorkspace: isolated?.workspace ?? null, baseCommit: isolated?.baseCommit ?? null }, partial, 'workspace')
|
|
583
|
+
if (signal?.aborted) return { ...partial(), status: 'cancelled', notStarted: true }
|
|
584
|
+
await awaitExecutionCheckpoint(onCheckpointStart, { id: 'direct', toolId, model: modelId ?? null,
|
|
585
|
+
workspace: runWorkspace, startedAt: Date.now() }, partial, 'start')
|
|
586
|
+
if (signal?.aborted) return { ...partial(), status: 'cancelled', notStarted: true }
|
|
559
587
|
try {
|
|
560
|
-
result = await (runtime?.runTool ?? runOfficialTool)({ toolId, task, modelId, workspace:
|
|
588
|
+
result = await (runtime?.runTool ?? runOfficialTool)({ toolId, task, modelId, workspace: runWorkspace,
|
|
561
589
|
mode, isolatedRoot: isolated?.isolatedRoot, signal, sandbox })
|
|
562
590
|
if (!result || typeof result.status !== 'string') throw new TypeError('官方 CLI 执行器没有返回有效状态。')
|
|
563
591
|
} catch (error) {
|
|
564
592
|
result = executionException(error, signal)
|
|
565
593
|
}
|
|
566
594
|
result = withFailureDiagnostic(result)
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (
|
|
595
|
+
result = { ...result, ...location, ...(signal?.aborted ? { status: 'cancelled' } : {}) }
|
|
596
|
+
await awaitExecutionCheckpoint(onCheckpointResult, { ...result, id: 'direct' }, partial, 'result')
|
|
597
|
+
if (signal?.aborted) return { ...result, status: 'cancelled' }
|
|
598
|
+
if (!isolated || result.status !== 'succeeded') return result
|
|
570
599
|
try {
|
|
571
600
|
const integration = await integrateWorktree(source, isolated.workspace, isolated.baseCommit, signal)
|
|
572
601
|
return { ...result,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { spawn } from 'node:child_process'
|
|
15
15
|
import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
16
|
import { homedir, tmpdir } from 'node:os'
|
|
17
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
17
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path'
|
|
18
18
|
import {
|
|
19
19
|
DEFAULT_NPM_REGISTRY,
|
|
20
20
|
defaultInstallMethod,
|
|
@@ -1042,7 +1042,8 @@ let prefixPromise = null
|
|
|
1042
1042
|
/** Add one directory to this process's PATH unless it is already there. */
|
|
1043
1043
|
export function appendToProcessPath(directory, { prepend = false, env = process.env, platform = process.platform } = {}) {
|
|
1044
1044
|
const target = String(directory ?? '').trim()
|
|
1045
|
-
|
|
1045
|
+
const paths = platform === 'win32' ? win32 : posix
|
|
1046
|
+
if (!target || !paths.isAbsolute(target)) return
|
|
1046
1047
|
const separator = platform === 'win32' ? ';' : ':'
|
|
1047
1048
|
const key = Object.keys(env).find(name => name.toUpperCase() === 'PATH') ?? 'PATH'
|
|
1048
1049
|
const parts = String(env[key] ?? '').split(separator).filter(Boolean)
|