@ljwei-stak/dsh-model-route-bot 0.64.0 → 0.65.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 +694 -407
- package/README.i18n.yaml +2 -2
- package/README.md +209 -147
- package/README.zh.md +208 -146
- package/apps/integration-client/bot-attention.mjs +2 -2
- package/apps/integration-client/panels.jsx +239 -134
- package/apps/integration-client/product.css +36 -0
- package/apps/integration-client/routing-submissions.mjs +68 -0
- package/apps/integration-client/task-progress.mjs +46 -0
- package/apps/integration-client/tasks-panel.jsx +147 -0
- package/modules/bot-runtime/read-model.mjs +2 -2
- package/package.json +6 -87
- package/packages/model-router-plugin/README.md +39 -15
- package/packages/model-router-plugin/README.zh.md +40 -16
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/bot-routing-candidates.mjs +6 -6
- package/packages/orchestration/src/bot-routing-service.mjs +30 -5
- package/packages/orchestration/src/bot-routing.mjs +6 -4
- package/packages/orchestration/src/product-composition.mjs +18 -24
- package/packages/orchestration/src/task-journal.mjs +23 -0
- package/packages/orchestration/src/task-product.mjs +49 -0
- package/remote-computer-agent/build.mjs +48 -13
- package/CHANGELOG.md +0 -456
- package/docs/INSTALLATION_GUIDE.zh.md +0 -150
- package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +0 -46
- package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +0 -153
- package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +0 -91
- package/docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md +0 -684
- package/docs/ROUTING_LIVEBENCH_DATA_SEARCH.zh.md +0 -74
- package/docs/ROUTING_LIVEBENCH_VALIDATION.zh.md +0 -154
- package/docs/ROUTING_MATURITY.zh.md +0 -76
- package/docs/ROUTING_RESEARCH.zh.md +0 -59
- package/docs/ROUTING_SELFTEST_STEPS.zh.md +0 -192
- package/docs/WORKBENCH_USER_GUIDE.zh.md +0 -293
- package/docs/archive/MIGRATION.md +0 -53
- package/docs/archive/README.md +0 -21
- package/docs/assets/candidate-pruning.svg +0 -80
- package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
- package/docs/assets/router-only-0.12.0.png +0 -0
- package/docs/assets/routing-workflow.svg +0 -96
- package/docs/assets/workbench-budget-reservations.png +0 -0
- package/docs/assets/workbench-run-progress.png +0 -0
- package/docs/assets/workbench-run-timeout.png +0 -0
- package/docs/assets/workbench-subscription-usage.png +0 -0
- package/docs/assets/workbench-task-preview.jpg +0 -0
- package/docs/assets/workbench-usage.svg +0 -119
- package/docs/releases/0.20.0-validation.md +0 -26
- package/docs/releases/0.22.0-validation.md +0 -28
- package/docs/releases/0.23.0-validation.md +0 -30
- package/docs/releases/0.23.1-validation.md +0 -23
- package/docs/releases/0.24.0-validation.md +0 -41
- package/docs/releases/0.25.0-validation.md +0 -41
- package/docs/releases/0.25.1-validation.md +0 -36
- package/docs/releases/0.25.2-validation.md +0 -48
- package/docs/releases/0.25.3-validation.md +0 -50
- package/docs/releases/0.25.4-validation.md +0 -41
- package/docs/releases/0.25.5-validation.md +0 -40
- package/docs/releases/0.26.0-preview.0-validation.md +0 -23
- package/docs/releases/0.26.0-preview.1-f-difference.json +0 -6845
- package/docs/releases/0.26.0-preview.1-f-difference.md +0 -85
- package/docs/releases/0.26.0-preview.1-validation.md +0 -103
- package/docs/releases/0.63.0-validation.md +0 -51
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
* machine: `candidateMachineGrant` is the only way a caller may read one, and an unnamed machine
|
|
10
10
|
* resolves to the Bot's own first grant rather than to a guess.
|
|
11
11
|
*
|
|
12
|
-
* Everything is injected — bots, their saved settings,
|
|
13
|
-
* in the Host and in a test without a database or a live machine.
|
|
12
|
+
* Quality is a fraction between 0 and 1. Everything is injected — bots, their saved settings,
|
|
13
|
+
* machine health — so the same function runs in the Host and in a test without a database or a live machine.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { workerBinding } from './bot-routing-mode.mjs'
|
|
17
17
|
|
|
18
|
-
const finite = value => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null)
|
|
18
|
+
const finite = (value, max = Infinity) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max ? value : null)
|
|
19
19
|
const list = value => (Array.isArray(value) ? value : [])
|
|
20
20
|
|
|
21
21
|
/** Tools a Bot may actually use, from its compiled grant rather than from its own claims. */
|
|
@@ -96,7 +96,7 @@ export function candidateMachineGrant(candidate, machineId = '', rootId = '') {
|
|
|
96
96
|
* settingsFor: (botId: string) => { enabled?: boolean, model?: object, grant?: { tools?: string[] }, capabilities?: string[], machineGrants?: Array<object> } | null,
|
|
97
97
|
* machineHealth?: (machineId: string) => { online?: boolean } | null,
|
|
98
98
|
* qualityFor?: (botId: string) => number | null,
|
|
99
|
-
* priceFor?: (route: { provider: string, model: string }) => number | null,
|
|
99
|
+
* priceFor?: (route: { provider: string, model: string }, botId: string) => number | null,
|
|
100
100
|
* loadFor?: (botId: string) => number | null,
|
|
101
101
|
* busyFor?: (botId: string) => boolean,
|
|
102
102
|
* }} source
|
|
@@ -110,7 +110,7 @@ export function botRoutingCandidates({ bots = [], settingsFor, machineHealth = (
|
|
|
110
110
|
// A Bot that is off is not a candidate; it stays out rather than scoring last.
|
|
111
111
|
if (!settings || settings.enabled === false || settings.status && settings.status !== 'available') continue
|
|
112
112
|
const model = settings.model ?? bot.model ?? null
|
|
113
|
-
const price = model ? priceFor(model) : null
|
|
113
|
+
const price = model ? priceFor(model, bot.id) : null
|
|
114
114
|
// Machine grants name the machine and the tools that machine offers this Bot. Reachability is
|
|
115
115
|
// asked per machine, so a Bot with two grants reports the one the task is about.
|
|
116
116
|
const machineGrants = machineGrantsOf(settings)
|
|
@@ -125,7 +125,7 @@ export function botRoutingCandidates({ bots = [], settingsFor, machineHealth = (
|
|
|
125
125
|
// Unknown price and unknown quality stay null: the router treats them as unknown, not as
|
|
126
126
|
// zero or best, and never as "free".
|
|
127
127
|
pricePerMTokUsd: finite(price),
|
|
128
|
-
quality: finite(qualityFor(bot.id)),
|
|
128
|
+
quality: finite(qualityFor(bot.id), 1),
|
|
129
129
|
load: finite(loadFor(bot.id)) ?? 0,
|
|
130
130
|
busy: busyFor(bot.id) === true,
|
|
131
131
|
tools: toolsOf(settings),
|
|
@@ -17,16 +17,18 @@
|
|
|
17
17
|
import { botRoutingCandidates, candidateMachineGrant, withMachineFact } from './bot-routing-candidates.mjs'
|
|
18
18
|
import { routeBot } from './bot-routing.mjs'
|
|
19
19
|
import { normalizeBotRouting, workerBinding } from './bot-routing-mode.mjs'
|
|
20
|
-
import { dispatchRoutedTask, pinnedMachineRefusal, workerGrantFact, routedEffectClass } from './bot-routing-dispatch.mjs'
|
|
20
|
+
import { assertPlanAccepted, dispatchRoutedTask, pinnedMachineRefusal, workerGrantFact, routedEffectClass } from './bot-routing-dispatch.mjs'
|
|
21
|
+
import { canonicalTaskJson, taskRef } from './task-contract.mjs'
|
|
21
22
|
import { createHash } from 'node:crypto'
|
|
22
23
|
const fail = code => { const error = new Error(code); error.code = code; throw error }
|
|
24
|
+
const submittedPlanId = (originBotId, requestKey) => `route_${createHash('sha256').update(`${originBotId}:${requestKey}`).digest('hex').slice(0, 48)}`
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* @param {{
|
|
26
28
|
* bots: () => Array<object>,
|
|
27
29
|
* settingsFor: (botId: string) => object | null,
|
|
28
30
|
* machineHealth?: (machineId: string) => { online?: boolean } | null,
|
|
29
|
-
* priceFor?: (route: object) => number | null,
|
|
31
|
+
* priceFor?: (route: object, botId: string) => number | null,
|
|
30
32
|
* qualityFor?: (botId: string) => number | null,
|
|
31
33
|
* loadFor?: (botId: string) => number | null,
|
|
32
34
|
* busyFor?: (botId: string) => boolean,
|
|
@@ -36,9 +38,10 @@ const fail = code => { const error = new Error(code); error.code = code; throw e
|
|
|
36
38
|
* subjectFor?: (botId: string) => unknown,
|
|
37
39
|
* bindWorker?: (binding: object) => Promise<unknown> | unknown,
|
|
38
40
|
* newTaskId?: () => string,
|
|
41
|
+
* readSubmittedPlan?: (originBotId: string, planId: string) => object | Promise<object>,
|
|
39
42
|
* }} source
|
|
40
43
|
*/
|
|
41
|
-
export function createBotRoutingService({ bots, settingsFor, machineHealth, priceFor, qualityFor, loadFor, busyFor, routing, mainBotIds = [], submitPlan = null, subjectFor = null, bindWorker = null, newTaskId = null, executionBackendFor = null, settle = null, deliver = null, onAccepted = null }) {
|
|
44
|
+
export function createBotRoutingService({ bots, settingsFor, machineHealth, priceFor, qualityFor, loadFor, busyFor, routing, mainBotIds = [], submitPlan = null, subjectFor = null, bindWorker = null, newTaskId = null, readSubmittedPlan = null, executionBackendFor = null, settle = null, deliver = null, onAccepted = null }) {
|
|
42
45
|
if (typeof bots !== 'function') fail('BOT_ROUTING_REQUIRES_BOTS')
|
|
43
46
|
if (typeof settingsFor !== 'function') fail('BOT_ROUTING_REQUIRES_SETTINGS')
|
|
44
47
|
|
|
@@ -81,7 +84,7 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
|
|
|
81
84
|
quality: candidate.quality,
|
|
82
85
|
busy: candidate.busy,
|
|
83
86
|
}))
|
|
84
|
-
return { enabled: settings.enabled, preset: settings.preset, autoGrant: settings.autoGrant, candidates: rows }
|
|
87
|
+
return { enabled: settings.enabled, preset: settings.preset, autoGrant: settings.autoGrant, requesterBotId: readMainBotIds()[0] ?? null, candidates: rows }
|
|
85
88
|
},
|
|
86
89
|
|
|
87
90
|
/**
|
|
@@ -132,6 +135,27 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
|
|
|
132
135
|
}
|
|
133
136
|
},
|
|
134
137
|
|
|
138
|
+
/** Owner-only observation: no candidate reads, grant changes, runner or routing switch needed. */
|
|
139
|
+
async submissionStatus(payload, context = {}) {
|
|
140
|
+
if (context?.kind !== 'owner') fail('REMOTE_OWNER_REQUIRED')
|
|
141
|
+
const value = JSON.parse(canonicalTaskJson(payload))
|
|
142
|
+
if (!value || Array.isArray(value) || Object.keys(value).some(key => !['requestKey', 'requesterBotId'].includes(key))) fail('INVALID_TASK_REQUEST')
|
|
143
|
+
const requestKey = taskRef(value.requestKey), originBotId = taskRef(value.requesterBotId)
|
|
144
|
+
if (typeof readSubmittedPlan !== 'function') fail('BOT_ROUTING_STATUS_UNAVAILABLE')
|
|
145
|
+
const planId = submittedPlanId(originBotId, requestKey)
|
|
146
|
+
let plan
|
|
147
|
+
try { plan = await readSubmittedPlan(originBotId, planId) }
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error?.code !== 'TASK_PLAN_NOT_FOUND') throw error
|
|
150
|
+
// This is a point-in-time read. An original request may still commit later.
|
|
151
|
+
return { found: false, planId, originBotId, planState: null, packages: [] }
|
|
152
|
+
}
|
|
153
|
+
if (plan?.planId !== planId || plan?.ownerBotId !== originBotId) fail('BOT_ROUTING_SUBMISSION_IDENTITY_MISMATCH')
|
|
154
|
+
const accepted = assertPlanAccepted(plan)
|
|
155
|
+
return { found: true, accepted: true, planId, originBotId, planState: accepted.state,
|
|
156
|
+
packages: accepted.packages.map(({ id, targetBotId, taskId, attemptId, operationId, state }) => ({ id, targetBotId, taskId, attemptId, operationId, state })) }
|
|
157
|
+
},
|
|
158
|
+
|
|
135
159
|
/**
|
|
136
160
|
* Dispatch for real. The Host injects plan submission and the subject directory; without them
|
|
137
161
|
* this refuses, because a dispatch that cannot create a task must not look like one.
|
|
@@ -153,6 +177,7 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
|
|
|
153
177
|
if (!subjectFor(originBotId)) fail('ROUTING_SUBJECT_UNKNOWN')
|
|
154
178
|
if (!owner && !readMainBotIds().includes(requesterBotId)) fail(`BOT_ROUTING_DISPATCH_DENIED:${requesterBotId}`)
|
|
155
179
|
if (!owner && (typeof context.sessionId !== 'string' || context.sessionId === '')) fail('BOT_ROUTING_SESSION_REQUIRED')
|
|
180
|
+
if (payload.requestKey !== undefined && payload.requestKey !== '') taskRef(payload.requestKey)
|
|
156
181
|
const needs = routingNeeds(payload)
|
|
157
182
|
const decision = service.decide({ ...payload, needs, requesterBotId })
|
|
158
183
|
if (!decision.chosen) fail(`BOT_ROUTING_NO_ELIGIBLE:${decision.rejected.map(row => row.reason).join(',') || 'none'}`)
|
|
@@ -184,7 +209,7 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
|
|
|
184
209
|
settle: typeof settle === 'function' ? report => settle(report, context) : null,
|
|
185
210
|
deliver: typeof deliver === 'function' ? report => deliver(report, context) : null,
|
|
186
211
|
newTaskId: typeof payload.requestKey === 'string' && payload.requestKey !== ''
|
|
187
|
-
? () =>
|
|
212
|
+
? () => submittedPlanId(originBotId, payload.requestKey)
|
|
188
213
|
: typeof newTaskId === 'function' ? newTaskId : undefined,
|
|
189
214
|
})
|
|
190
215
|
if (typeof onAccepted === 'function') await onAccepted(report, context)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
export const BOT_ROUTING_PRESETS = Object.freeze(['economy', 'balanced', 'quality'])
|
|
17
17
|
|
|
18
18
|
const fail = code => { const error = new Error(code); error.code = code; throw error }
|
|
19
|
-
const finite = value => (typeof value === 'number' && Number.isFinite(value) ? value : null)
|
|
19
|
+
const finite = (value, max = Infinity) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max ? value : null)
|
|
20
20
|
const list = value => (Array.isArray(value) ? value : [])
|
|
21
21
|
|
|
22
22
|
/** Hard requirements a Bot must satisfy before it is even scored. */
|
|
@@ -53,7 +53,7 @@ export const botRejection = (candidate, task = {}) => rejection(candidate, task)
|
|
|
53
53
|
/** Lower is better. Unknown cost sorts after known cost but is never silently free. */
|
|
54
54
|
function score(candidate, preset, maxPrice) {
|
|
55
55
|
const price = finite(candidate.pricePerMTokUsd)
|
|
56
|
-
const quality = finite(candidate.quality)
|
|
56
|
+
const quality = finite(candidate.quality, 1)
|
|
57
57
|
const load = finite(candidate.load) ?? 0
|
|
58
58
|
// Cost is normalised against the most expensive eligible Bot, so one very expensive route
|
|
59
59
|
// cannot swamp quality evidence: what matters is the ratio within this decision, not dollars.
|
|
@@ -91,9 +91,11 @@ export function routeBot({ task = {}, candidates = [], preset = 'balanced', limi
|
|
|
91
91
|
const scored = []
|
|
92
92
|
for (const candidate of eligible) {
|
|
93
93
|
scored.push({ botId: candidate.botId, score: score(candidate, preset, maxPrice), model: candidate.model,
|
|
94
|
-
pricePerMTokUsd: finite(candidate.pricePerMTokUsd), quality: finite(candidate.quality) })
|
|
94
|
+
pricePerMTokUsd: finite(candidate.pricePerMTokUsd), quality: finite(candidate.quality, 1) })
|
|
95
95
|
}
|
|
96
|
-
scored.sort((left, right) => left.score - right.score
|
|
96
|
+
scored.sort((left, right) => left.score - right.score
|
|
97
|
+
|| Number(left.pricePerMTokUsd === null) - Number(right.pricePerMTokUsd === null)
|
|
98
|
+
|| String(left.botId).localeCompare(String(right.botId)))
|
|
97
99
|
const ranked = scored.slice(0, Math.max(1, limit))
|
|
98
100
|
return {
|
|
99
101
|
preset,
|
|
@@ -27,6 +27,7 @@ import { openBotSettings } from './bot-settings.mjs'
|
|
|
27
27
|
import { openFundingAccounts } from './funding-accounts.mjs'
|
|
28
28
|
import { createModelAccounting } from './model-accounting.mjs'
|
|
29
29
|
import { createTaskService } from './task-service.mjs'
|
|
30
|
+
import { inspectProductTask, projectProductTask } from './task-product.mjs'
|
|
30
31
|
import { createDesktopActions } from './desktop-actions.mjs'
|
|
31
32
|
import { registerTaskTools } from './task-tools.mjs'
|
|
32
33
|
import { createMrReadBackend } from './mr-read-backend.mjs'
|
|
@@ -343,19 +344,22 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
343
344
|
if (!taskService) throw new Error('TASK_SERVICE_UNAVAILABLE')
|
|
344
345
|
return taskService.submitPlan(subject, request, caller)
|
|
345
346
|
},
|
|
347
|
+
readSubmittedPlan: (originBotId, planId) => {
|
|
348
|
+
if (!journal) throw Object.assign(new Error('BOT_ROUTING_STATUS_UNAVAILABLE'), { code: 'BOT_ROUTING_STATUS_UNAVAILABLE' })
|
|
349
|
+
// Authenticated owner observation reads durable identities even if the Bot was paused.
|
|
350
|
+
return journal.getPlan(originBotId, planId)
|
|
351
|
+
},
|
|
346
352
|
bindWorker: (binding, context) => commitWorkerBinding(binding, context),
|
|
347
353
|
executionBackendFor: effect => effect === 'read' ? 'bot-turn-read' : 'bot-turn-write',
|
|
348
354
|
onAccepted: report => turnBackends?.watchPlan(report),
|
|
349
355
|
busyFor: botId => product?.rt.live?.get(product.rt.chatOf(botId))?.turn !== undefined,
|
|
350
|
-
priceFor: route => {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
return null
|
|
356
|
+
priceFor: (route, botId) => {
|
|
357
|
+
// Funding prices belong to the Bot binding, even when another Bot uses the same model.
|
|
358
|
+
const settings = routingSettingsFor(botId)
|
|
359
|
+
if (settings?.model?.provider !== route.provider || settings.model.model !== route.model) return null
|
|
360
|
+
const price = settings.funding?.pricing
|
|
361
|
+
if (!Number.isFinite(price?.input) || price.input < 0 || !Number.isFinite(price?.output) || price.output < 0) return null
|
|
362
|
+
return (price.input + price.output) / 2
|
|
359
363
|
},
|
|
360
364
|
})
|
|
361
365
|
ctx.provide('botRouting', botRouting)
|
|
@@ -729,22 +733,10 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
729
733
|
// same single authority; a section without a runner stays blocked.
|
|
730
734
|
...(taskService ? { tasks: async ({ runtime, signal }) => {
|
|
731
735
|
signal?.throwIfAborted(); await runtime.load()
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
for (const task of journal.list(bot.id)) {
|
|
735
|
-
if (items.length >= TASK_READ_LIMIT) break
|
|
736
|
-
items.push({ botId: task.ownerBotId, taskId: task.taskId, state: task.state, backend: task.input.backend,
|
|
737
|
-
...(task.planId?{planId:task.planId,logicalPackageId:task.logicalPackageId}:{}),
|
|
738
|
-
location: task.input.target.location, machineId: task.input.target.machineId, workspaceId: task.input.target.workspaceId,
|
|
739
|
-
createdAt: task.createdAt, updatedAt: task.updatedAt, deadlineAt: task.deadlineAt,
|
|
740
|
-
attemptNumber: task.attemptNumber, operationId: task.operationId, attempts: task.attempts.length,
|
|
741
|
-
legacyRunIds: task.legacyRunIds, result: task.result ? { code: task.result.code ?? task.result.summary?.code ?? null,
|
|
742
|
-
...(task.executionRef?.kind==='remote-operation'?{remote:{status:['succeeded','failed','cancelled','admitted','running','cancel_pending','unknown'].includes(task.result.summary?.status)?task.result.summary.status:'unknown',stopConfirmed:task.result.summary?.stopConfirmed===true,possiblyApplied:task.result.summary?.possiblyApplied!==false,...(/^[a-f0-9]{64}$/.test(task.result.summary?.sha256??'')?{sha256:task.result.summary.sha256}:{}),...(Number.isSafeInteger(task.result.summary?.bytes)?{bytes:task.result.summary.bytes}:{}),...(Number.isSafeInteger(task.result.summary?.exitCode)?{exitCode:task.result.summary.exitCode}:{}),...(task.result.summary?.notStarted===true?{notStarted:true}:{})}}:{}) } : null,
|
|
743
|
-
reconciliation: task.reconciliation ? { at: task.reconciliation.at, outcome: task.reconciliation.outcome } : null })
|
|
744
|
-
}
|
|
745
|
-
}
|
|
736
|
+
const loaded = journal.productList(Object.values(runtime.state?.bots ?? {}).map(bot => bot.id), { limit: TASK_READ_LIMIT })
|
|
737
|
+
const items = loaded.items.map(projectProductTask)
|
|
746
738
|
const plans=Object.values(runtime.state?.bots??{}).flatMap(bot=>journal.listPlans(bot.id))
|
|
747
|
-
return { status: 'available', items, plans, backends: taskBackends.catalog() }
|
|
739
|
+
return { status: 'available', items, total: loaded.total, truncated: loaded.truncated, plans, backends: taskBackends.catalog() }
|
|
748
740
|
} } : {}),
|
|
749
741
|
// N07: the usage projection reads the durable source receipts and the
|
|
750
742
|
// funding totals from the single authority; an unknown amount stays
|
|
@@ -837,6 +829,7 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
837
829
|
// action. Registering it here is what makes the routing card work at runtime; the owner
|
|
838
830
|
// RPC methods are a second door onto the same service.
|
|
839
831
|
'botRoutingInspect': async () => ({ ...botRouting.inspect(), settingsRevision: routingSettings.get().revision }),
|
|
832
|
+
'botRouting.status': (payload, context) => botRouting.submissionStatus(payload, context),
|
|
840
833
|
'botRouting.save': async (payload, context) => {
|
|
841
834
|
const saved = routingSettings.save(payload, context)
|
|
842
835
|
return { ...botRouting.inspect(), settingsRevision: saved.revision }
|
|
@@ -873,6 +866,7 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
873
866
|
// N09: owner task management runs on the owning Bot's own subject, so
|
|
874
867
|
// the Bot's committed grant authorizes every cancel/retry/reconcile.
|
|
875
868
|
...(taskService ? {
|
|
869
|
+
'task.inspect': payload => inspectProductTask({ service: taskService, journal, subject: taskOwner(payload), taskId: payload?.taskId }),
|
|
876
870
|
// Bot routing turns a preview into a real package: the decision names the Bot, the
|
|
877
871
|
// worker binding is committed first, and only then is the plan submitted. A failure in
|
|
878
872
|
// either step leaves no half-dispatched task behind.
|
|
@@ -541,6 +541,29 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
541
541
|
})
|
|
542
542
|
},
|
|
543
543
|
events(owner,id) {owned(owner,id);return stmt('SELECT * FROM task_events WHERE task_id=? ORDER BY seq').all(id)},
|
|
544
|
+
timeline(owner,id,{limit=80}={}) {
|
|
545
|
+
owned(owner,id)
|
|
546
|
+
if(!Number.isSafeInteger(limit)||limit<1||limit>200)fail('INVALID_TASK_HISTORY_LIMIT')
|
|
547
|
+
const total=stmt('SELECT COUNT(*) AS n FROM task_events WHERE task_id=?').get(id).n
|
|
548
|
+
const items=stmt('SELECT * FROM task_events WHERE task_id=? ORDER BY seq DESC LIMIT ?').all(id,limit).reverse()
|
|
549
|
+
return {items,total,truncated:total>items.length}
|
|
550
|
+
},
|
|
551
|
+
// Product lists must not permanently hide new tasks behind a Bot's first 128 records.
|
|
552
|
+
// This read is global across the Host's known Bots; it never changes runner queue ordering.
|
|
553
|
+
productList(owners,{limit=512}={}) {
|
|
554
|
+
if(!Array.isArray(owners)||!Number.isSafeInteger(limit)||limit<1||limit>512)fail('INVALID_TASK_LIST')
|
|
555
|
+
const ids=[...new Set(owners.map(taskRef))]
|
|
556
|
+
if(!ids.length)return {items:[],total:0,truncated:false}
|
|
557
|
+
const visible=new Set(ids)
|
|
558
|
+
const total=stmt('SELECT owner_bot,COUNT(*) AS n FROM tasks GROUP BY owner_bot').all()
|
|
559
|
+
.reduce((sum,row)=>sum+(visible.has(row.owner_bot)?row.n:0),0)
|
|
560
|
+
// Filter owners before the limit, including when old/deleted Bots still have records.
|
|
561
|
+
const items=stmt(`SELECT id,owner_bot FROM tasks WHERE owner_bot IN (SELECT value FROM json_each(?))
|
|
562
|
+
ORDER BY CASE WHEN state IN ('needs-reconciliation','waiting-approval','waiting-budget','blocked') THEN 0
|
|
563
|
+
WHEN state IN ('ready','queued','running','cancelling','admitting') THEN 1 WHEN state='failed' THEN 2 ELSE 3 END,
|
|
564
|
+
updated_ms DESC,id LIMIT ?`).all(JSON.stringify(ids),limit).map(row=>view(row.owner_bot,row.id))
|
|
565
|
+
return {items,total,truncated:total>items.length}
|
|
566
|
+
},
|
|
544
567
|
// One Bot's durable tasks, oldest first; the read model never invents a task.
|
|
545
568
|
list(owner) {taskRef(owner);return stmt('SELECT id FROM tasks WHERE owner_bot=? ORDER BY created_ms,id LIMIT 128').all(owner).map(row=>view(owner,row.id))},
|
|
546
569
|
close() {if(!closed){closed=true;if(!borrowed)db.close();onClose()}},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Public task views contain execution identities and status evidence, never backend inputs,
|
|
2
|
+
// execution references, raw event details or reconciliation proofs.
|
|
3
|
+
const short = (value, limit = 160) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, limit) : ''
|
|
4
|
+
const code = value => typeof value === 'string' && /^[A-Z0-9_:-]{1,128}$/.test(value) ? value : null
|
|
5
|
+
|
|
6
|
+
export function projectProductTask(task) {
|
|
7
|
+
const input = task.input?.input ?? {}
|
|
8
|
+
const title = short(input.task || input.text || input.prompt) || (input.relativePath ? short(`文件操作:${input.relativePath}`) : '')
|
|
9
|
+
const remote = task.result?.summary
|
|
10
|
+
return {
|
|
11
|
+
botId: task.ownerBotId, taskId: task.taskId, packageId: task.packageId, attemptId: task.attemptId,
|
|
12
|
+
state: task.state, title, backend: task.input?.backend,
|
|
13
|
+
...(task.planId ? { planId: task.planId, logicalPackageId: task.logicalPackageId } : {}),
|
|
14
|
+
location: task.input?.target?.location, machineId: task.input?.target?.machineId, workspaceId: task.input?.target?.workspaceId,
|
|
15
|
+
createdAt: task.createdAt, updatedAt: task.updatedAt, deadlineAt: task.deadlineAt,
|
|
16
|
+
attemptNumber: task.attemptNumber, operationId: task.operationId, attempts: task.attempts.length,
|
|
17
|
+
legacyRunIds: task.legacyRunIds,
|
|
18
|
+
result: task.result ? { code: code(task.result.code ?? remote?.code),
|
|
19
|
+
...(task.executionRef?.kind === 'remote-operation' ? { remote: {
|
|
20
|
+
status: ['succeeded', 'failed', 'cancelled', 'admitted', 'running', 'cancel_pending', 'unknown'].includes(remote?.status) ? remote.status : 'unknown',
|
|
21
|
+
stopConfirmed: remote?.stopConfirmed === true, possiblyApplied: remote?.possiblyApplied !== false,
|
|
22
|
+
...(/^[a-f0-9]{64}$/.test(remote?.sha256 ?? '') ? { sha256: remote.sha256 } : {}),
|
|
23
|
+
...(Number.isSafeInteger(remote?.bytes) ? { bytes: remote.bytes } : {}),
|
|
24
|
+
...(Number.isSafeInteger(remote?.exitCode) ? { exitCode: remote.exitCode } : {}),
|
|
25
|
+
...(remote?.notStarted === true ? { notStarted: true } : {}),
|
|
26
|
+
} } : {}),
|
|
27
|
+
} : null,
|
|
28
|
+
reconciliation: task.reconciliation ? { at: task.reconciliation.at, outcome: task.reconciliation.outcome } : null,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The service checks the Bot's committed task/read grant and the task's owner first. */
|
|
33
|
+
export function inspectProductTask({ service, journal, subject, taskId }) {
|
|
34
|
+
const task = service.get(subject, { taskId })
|
|
35
|
+
const history = journal.timeline(task.ownerBotId, task.taskId)
|
|
36
|
+
const attempts = task.attempts.map(row => ({ attemptId: row.id, number: row.number, state: row.state,
|
|
37
|
+
operationId: row.operation_id, startedAt: row.started_ms, finishedAt: row.finished_ms }))
|
|
38
|
+
const byId = new Map(attempts.map(row => [row.attemptId, row]))
|
|
39
|
+
return { task: projectProductTask(task), attempts, timeline: {
|
|
40
|
+
total: history.total, truncated: history.truncated,
|
|
41
|
+
items: history.items.map(row => {
|
|
42
|
+
const detail = JSON.parse(row.detail), attempt = byId.get(row.attempt_id)
|
|
43
|
+
return { seq: row.seq, kind: row.kind, at: row.at_ms, attemptId: row.attempt_id,
|
|
44
|
+
attemptNumber: attempt?.number ?? null, operationId: attempt?.operationId ?? null,
|
|
45
|
+
outcome: ['ready', 'running', 'succeeded', 'failed', 'cancelled', 'blocked', 'skipped', 'waiting-budget', 'needs-reconciliation', 'unknown'].includes(detail.outcome) ? detail.outcome : null,
|
|
46
|
+
code: code(detail.code) }
|
|
47
|
+
}),
|
|
48
|
+
} }
|
|
49
|
+
}
|
|
@@ -1,16 +1,51 @@
|
|
|
1
1
|
import { build } from 'esbuild'
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { mkdir, lstat, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
|
-
import { resolve, relative } from 'node:path'
|
|
5
|
+
import { basename, dirname, join, resolve, relative } from 'node:path'
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
5
7
|
import assert from 'node:assert/strict'
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
await
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
await
|
|
16
|
-
|
|
8
|
+
|
|
9
|
+
const root = fileURLToPath(new URL('.', import.meta.url))
|
|
10
|
+
const output = resolve(root, 'dist')
|
|
11
|
+
assert.equal(relative(resolve(root), output), 'dist', 'Build output must remain in the Agent dist directory')
|
|
12
|
+
async function checkOutput() {
|
|
13
|
+
const previous = await lstat(output).catch(error => { if (error.code === 'ENOENT') return null; throw error })
|
|
14
|
+
assert.ok(!previous?.isSymbolicLink(), 'Refusing to replace a linked build output')
|
|
15
|
+
assert.ok(!previous || previous.isDirectory(), 'Agent build output must be a directory')
|
|
16
|
+
}
|
|
17
|
+
await checkOutput()
|
|
18
|
+
|
|
19
|
+
// Compile everything before touching the published build. Native tests run separate builders
|
|
20
|
+
// while other processes import or launch these entry points; deleting dist creates an ENOENT race.
|
|
21
|
+
const options = { absWorkingDir: root, bundle: true, packages: 'external', platform: 'node', format: 'esm', target: 'node22', sourcemap: true, write: false }
|
|
22
|
+
const main = await build({ ...options, entryPoints: ['src/index.ts', 'src/cli.ts'], outbase: 'src', outdir: 'dist', outExtension: { '.js': '.mjs' } })
|
|
23
|
+
const bridge = await build({ ...options, entryPoints: ['../packages/capability-broker/src/cli-stdio-bridge.mjs'], outfile: 'dist/cli-stdio-bridge.mjs' })
|
|
24
|
+
const files = [...main.outputFiles, ...bridge.outputFiles]
|
|
25
|
+
for (const helper of ['native-peer.ps1', 'native-peer.py']) {
|
|
26
|
+
files.push({ path: join(output, helper), contents: await readFile(resolve(root, '../packages/cli-runtime/src', helper)) })
|
|
27
|
+
}
|
|
28
|
+
for (const file of files) assert.equal(dirname(resolve(file.path)), output, 'Agent build files must remain directly inside dist')
|
|
29
|
+
await checkOutput()
|
|
30
|
+
await mkdir(output, { recursive: true })
|
|
31
|
+
const previousFiles = await readdir(output, { withFileTypes: true })
|
|
32
|
+
assert.ok(previousFiles.every(file => !file.isDirectory()), 'Unexpected directory in flat Agent build output')
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const bytes = Buffer.from(file.contents)
|
|
35
|
+
const previous = await readFile(file.path).catch(error => { if (error.code === 'ENOENT') return null; throw error })
|
|
36
|
+
if (previous?.equals(bytes)) continue
|
|
37
|
+
// Temporary files stay outside dist so another builder cannot prune or package them.
|
|
38
|
+
const temporary = join(root, `.agent-build-${randomUUID()}.tmp`)
|
|
39
|
+
try {
|
|
40
|
+
await writeFile(temporary, bytes, { flag: 'wx', mode: bytes.subarray(0, 2).toString() === '#!' ? 0o755 : 0o644 })
|
|
41
|
+
for (let attempt = 0; ; attempt++) {
|
|
42
|
+
try { await rename(temporary, file.path); break }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (attempt >= 5 || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error
|
|
45
|
+
await sleep(50)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} finally { await rm(temporary, { force: true }) }
|
|
49
|
+
}
|
|
50
|
+
const names = new Set(files.map(file => basename(file.path)))
|
|
51
|
+
for (const file of previousFiles) if (!names.has(file.name)) await rm(join(output, file.name), { force: true })
|