@ljwei-stak/dsh-model-route-bot 0.65.0 → 0.66.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 +3 -3
- package/CHANGELOG.md +503 -0
- package/README.i18n.yaml +2 -2
- package/README.md +6 -6
- package/README.zh.md +6 -6
- package/apps/integration-client/tasks-panel.jsx +2 -2
- package/docs/INSTALLATION_GUIDE.zh.md +150 -0
- package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +46 -0
- package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +153 -0
- package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +91 -0
- package/docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md +684 -0
- package/docs/ROUTING_LIVEBENCH_DATA_SEARCH.zh.md +74 -0
- package/docs/ROUTING_LIVEBENCH_VALIDATION.zh.md +154 -0
- package/docs/ROUTING_MATURITY.zh.md +76 -0
- package/docs/ROUTING_RESEARCH.zh.md +59 -0
- package/docs/ROUTING_SELFTEST_STEPS.zh.md +192 -0
- package/docs/WORKBENCH_USER_GUIDE.zh.md +293 -0
- package/docs/archive/MIGRATION.md +53 -0
- package/docs/archive/README.md +21 -0
- package/docs/assets/candidate-pruning.svg +80 -0
- 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 +96 -0
- 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 +119 -0
- package/docs/releases/0.20.0-validation.md +26 -0
- package/docs/releases/0.22.0-validation.md +28 -0
- package/docs/releases/0.23.0-validation.md +30 -0
- package/docs/releases/0.23.1-validation.md +23 -0
- package/docs/releases/0.24.0-validation.md +41 -0
- package/docs/releases/0.25.0-validation.md +41 -0
- package/docs/releases/0.25.1-validation.md +36 -0
- package/docs/releases/0.25.2-validation.md +48 -0
- package/docs/releases/0.25.3-validation.md +50 -0
- package/docs/releases/0.25.4-validation.md +41 -0
- package/docs/releases/0.25.5-validation.md +40 -0
- package/docs/releases/0.26.0-preview.0-validation.md +23 -0
- package/docs/releases/0.26.0-preview.1-f-difference.json +6845 -0
- package/docs/releases/0.26.0-preview.1-f-difference.md +85 -0
- package/docs/releases/0.26.0-preview.1-validation.md +103 -0
- package/docs/releases/0.63.0-validation.md +51 -0
- package/package.json +89 -6
- package/packages/capability-broker/src/authority.mjs +15 -1
- package/packages/model-router-plugin/README.md +3 -3
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/product-composition.mjs +48 -8
- package/packages/orchestration/src/sqlite-router-state.mjs +33 -8
- package/packages/orchestration/src/stop-proof.mjs +37 -0
- package/packages/orchestration/src/task-contract.mjs +42 -2
- package/packages/orchestration/src/task-journal.mjs +122 -39
- package/packages/orchestration/src/task-service.mjs +110 -18
- package/packages/orchestration/src/task-stage.mjs +36 -0
- package/packages/orchestration/src/write-retry.mjs +56 -0
- package/remote-computer-agent/dist/cli.mjs +11 -1
- package/remote-computer-agent/dist/cli.mjs.map +2 -2
- package/remote-computer-agent/dist/index.mjs +11 -1
- package/remote-computer-agent/dist/index.mjs.map +2 -2
|
@@ -2,11 +2,12 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { PolicyError } from '../../capability-broker/src/authority.mjs'
|
|
3
3
|
import { canonicalTaskJson, taskHash, taskRef, parseTaskSubmit, parseTaskReference, parseTaskRetry, parseTaskReconcile, parseLegacyTaskRetry, parseTaskPlan, validateTaskPlan } from './task-contract.mjs'
|
|
4
4
|
import {withinTaskAdmission} from './task-admission.mjs'
|
|
5
|
+
import {hasExternalEffect, requiresStopProof} from './stop-proof.mjs'
|
|
5
6
|
|
|
6
7
|
const fail=code=>{throw new PolicyError(code)}
|
|
7
8
|
|
|
8
9
|
/** A single runner with registered effect-bound backends; disconnect never owns the task signal. */
|
|
9
|
-
export function createTaskService({journal,authority,directory,backends,within=(subject,signal,work)=>work(),now=Date.now,runnerTtlMs=15_000,taskTtlMs=120_000,maxActive=4,executionLeaseMs=30_000,concurrencyFor=()=>1,planTargetsFor=subject=>[authority.describe(subject).botId]}) {
|
|
10
|
+
export function createTaskService({journal,authority,directory,backends,within=(subject,signal,work)=>work(),now=Date.now,runnerTtlMs=15_000,taskTtlMs=120_000,maxActive=4,executionLeaseMs=30_000,concurrencyFor=()=>1,planTargetsFor=subject=>[authority.describe(subject).botId],onRunnerEvent=()=>{},requireStopProof=false}) {
|
|
10
11
|
if(!Number.isSafeInteger(runnerTtlMs)||runnerTtlMs<500||runnerTtlMs>60_000||!Number.isSafeInteger(taskTtlMs)||taskTtlMs<1||taskTtlMs>7_200_000||!Number.isSafeInteger(maxActive)||maxActive<1||maxActive>4||!Number.isSafeInteger(executionLeaseMs)||executionLeaseMs<100||executionLeaseMs>300_000||typeof authority.renew!=='function'||typeof concurrencyFor!=='function')fail('INVALID_TASK_SERVICE_LIMIT')
|
|
11
12
|
const registry=new Map()
|
|
12
13
|
for(const backend of backends) {
|
|
@@ -17,6 +18,11 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
17
18
|
if(registry.has(backend.id)||!['read','workspace-write'].includes(backend.effect)||!['local','remote'].includes(backend.location)||typeof backend.parse!=='function'||typeof backend.run!=='function'||backend.targetFor!==undefined&&typeof backend.targetFor!=='function')fail('INVALID_TASK_BACKEND')
|
|
18
19
|
const botTurn=backend.id==='bot-turn-write'&&backend.writeContract==='tracked-bot-turn-v1'
|
|
19
20
|
if(backend.effect==='workspace-write'&&((!botTurn&&!['scoped-remote-file-v1','scoped-remote-command-v1'].includes(backend.writeContract))||!botTurn&&backend.location!=='remote'||typeof backend.targetFor!=='function'||typeof backend.canObserve!=='function'||typeof backend.reconcile!=='function'))fail('INVALID_TASK_BACKEND')
|
|
21
|
+
// A backend whose effects can land outside this process must be able to
|
|
22
|
+
// prove a stop, or a failed attempt is retried on a guess. A deployment
|
|
23
|
+
// turns this on so a new writer is refused at registration instead of
|
|
24
|
+
// discovered at the first retry; unit fixtures register synthetic ids.
|
|
25
|
+
if(requireStopProof&&hasExternalEffect(backend)&&!requiresStopProof(backend.id))fail('STOP_PROOF_BACKEND_UNREGISTERED')
|
|
20
26
|
registry.set(backend.id,backend)
|
|
21
27
|
}
|
|
22
28
|
const target=backend=>({backend:backend.id,version:backend.version,effect:backend.effect,machineId:backend.machineId,workspaceId:backend.workspaceId,location:backend.location,capability:backend.capability,retryCapability:backend.retryCapability,model:backend.model??null,...(backend.reconcileCapability?{reconcileCapability:backend.reconcileCapability}:{})})
|
|
@@ -39,31 +45,63 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
39
45
|
const active=new Map()
|
|
40
46
|
const observations=new Map()
|
|
41
47
|
let identity,heartbeat,stopped=false,pumping=false,scheduled=false,closing
|
|
48
|
+
// A runner that dies silently is the worst state this service can reach: the
|
|
49
|
+
// Host keeps accepting work nobody will claim. Every stop therefore carries a
|
|
50
|
+
// reason, and the reason survives until the runner is rebuilt.
|
|
51
|
+
let stopState=null,restarts=0,lastRenewAt=null
|
|
52
|
+
const runnerEvents=[]
|
|
53
|
+
const record=(event,detail={})=>{
|
|
54
|
+
const entry=Object.freeze({at:now(),event,...detail})
|
|
55
|
+
runnerEvents.push(entry)
|
|
56
|
+
while(runnerEvents.length>32)runnerEvents.shift()
|
|
57
|
+
try{onRunnerEvent(entry)}catch{}
|
|
58
|
+
return entry
|
|
59
|
+
}
|
|
42
60
|
const writable=()=>{if(stopped||!identity)fail('TASK_RUNNER_UNAVAILABLE')}
|
|
61
|
+
// A refusal has to say which check refused it. `gate` is the checkpoint name,
|
|
62
|
+
// so a blocked task's record answers "not allowed" and "not allowed by what".
|
|
63
|
+
const gate=(name,work)=>{try{return work()}catch(error){if(error instanceof PolicyError&&!error.gate)error.gate=name;throw error}}
|
|
43
64
|
const admit=(subject,payload,retry=false)=>{
|
|
44
|
-
authority.assertTool(subject,'task/execute')
|
|
65
|
+
gate('task/execute',()=>authority.assertTool(subject,'task/execute'))
|
|
45
66
|
const backend=registry.get(payload.backend)
|
|
46
67
|
if(!backend||taskHash(resolvedTarget(backend,subject,payload.input))!==payload.targetHash)fail('TASK_BACKEND_CHANGED')
|
|
47
|
-
authority.assertTool(subject,backend.capability)
|
|
48
|
-
if(retry)authority.assertTool(subject,backend.retryCapability)
|
|
49
|
-
if(backend.model)authority.assertModel(subject,backend.model.provider,backend.model.model)
|
|
50
|
-
backend.parse(JSON.parse(canonicalTaskJson(payload.input)))
|
|
68
|
+
gate('backend/capability',()=>authority.assertTool(subject,backend.capability))
|
|
69
|
+
if(retry)gate('backend/retry-capability',()=>authority.assertTool(subject,backend.retryCapability))
|
|
70
|
+
if(backend.model)gate('backend/model-route',()=>authority.assertModel(subject,backend.model.provider,backend.model.model))
|
|
71
|
+
gate('backend/input',()=>backend.parse(JSON.parse(canonicalTaskJson(payload.input))))
|
|
51
72
|
return backend
|
|
52
73
|
}
|
|
53
|
-
const stopRunner=()=>{
|
|
54
|
-
stopped
|
|
74
|
+
const stopRunner=(reason='TASK_RUNNER_STOPPED',detail={})=>{
|
|
75
|
+
if(stopped){stopState??=Object.freeze({code:reason,at:now(),...detail});return}
|
|
76
|
+
stopped=true;stopState=Object.freeze({code:reason,at:now(),generation:identity?.generation??null,...detail})
|
|
77
|
+
clearInterval(heartbeat);heartbeat=undefined
|
|
55
78
|
for(const entry of active.values())entry.controller.abort(new PolicyError('STALE_TASK_RUNNER'))
|
|
56
79
|
for(const entry of observations.values())entry.controller.abort(new PolicyError('STALE_TASK_RUNNER'))
|
|
80
|
+
record('runner.stopped',stopState)
|
|
57
81
|
}
|
|
58
82
|
const execute=async(subject,task,backend,controller)=>{
|
|
59
83
|
const binding={taskId:task.taskId,attemptId:task.attemptId,machineId:task.input.target.machineId,workspaceId:task.input.target.workspaceId,location:task.input.target.location}
|
|
60
84
|
let lease,result,outcome,observedResult,renewal,admission,executionSignal,backendStarted=false,backendClosed=false
|
|
85
|
+
// Every checkpoint writes its verdict before the work continues, so "was it
|
|
86
|
+
// allowed" and "did it run" stay two separate answers. A refusal that ends
|
|
87
|
+
// the attempt is still finished through `finish`; this only records it.
|
|
88
|
+
const gates=[]
|
|
89
|
+
const decide=(gateName,allowed,detail={})=>{
|
|
90
|
+
const verdict=Object.freeze({gate:gateName,allowed,...detail})
|
|
91
|
+
gates.push(verdict)
|
|
92
|
+
try{journal.note(identity,task.ownerBotId,task.taskId,task.attemptId,'gate',now(),verdict)}catch{}
|
|
93
|
+
return verdict
|
|
94
|
+
}
|
|
61
95
|
const timer=setTimeout(()=>controller.abort(new PolicyError('TASK_DEADLINE')),Math.max(1,task.deadlineAt-now()))
|
|
62
96
|
try {
|
|
63
97
|
if(backend.atomicAdmission===true)admission=journal.createAdmission(identity,task.ownerBotId,task.taskId,task.attemptId,now,()=>{
|
|
64
98
|
controller.signal.throwIfAborted();executionSignal?.throwIfAborted();admit(subject,task.input)
|
|
65
99
|
})
|
|
66
|
-
|
|
100
|
+
decide('authorize',true,{effect:backend.effect,location:backend.location})
|
|
101
|
+
try{
|
|
102
|
+
lease=gate('execution/lease',()=>authority.issue(subject,{binding,tools:['task/execute',backend.capability],ttlMs:Math.max(1,Math.min(executionLeaseMs,task.deadlineAt-now()))}))
|
|
103
|
+
}catch(error){decide('execution/lease',false,{code:error?.code??'LEASE_FAILED'});throw error}
|
|
104
|
+
decide('execution/lease',true)
|
|
67
105
|
renewal=setInterval(()=>{
|
|
68
106
|
try{if(task.deadlineAt<=now())throw new PolicyError('TASK_DEADLINE');authority.renew(lease.token,binding,{ttlMs:Math.max(1,Math.min(executionLeaseMs,task.deadlineAt-now()))})}
|
|
69
107
|
catch(error){controller.abort(error)}
|
|
@@ -74,7 +112,9 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
74
112
|
// A delayed within/admission callback cannot dispatch after this
|
|
75
113
|
// invocation has settled or after its authority was cancelled.
|
|
76
114
|
if(backendClosed||backendStarted)fail('TASK_DISPATCH_CLOSED')
|
|
77
|
-
signal.throwIfAborted()
|
|
115
|
+
signal.throwIfAborted()
|
|
116
|
+
try{journal.beginDispatch(identity,task.ownerBotId,task.taskId,task.attemptId,now())}catch(error){decide('dispatch',false,{code:error?.code??'DISPATCH_REFUSED'});throw error}
|
|
117
|
+
backendStarted=true;decide('dispatch',true)
|
|
78
118
|
return backend.run({
|
|
79
119
|
input:JSON.parse(canonicalTaskJson(task.input.input)),target:task.input.target,signal,binding,attemptNumber:task.attemptNumber,
|
|
80
120
|
previous:task.attemptNumber>1?journal.get(task.ownerBotId,task.taskId).attempts.at(-2):null,
|
|
@@ -99,6 +139,8 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
99
139
|
const hasReference=!!journal.get(task.ownerBotId,task.taskId).executionRef
|
|
100
140
|
if(observedResult?.outcome==='unknown'||!observedResult&&hasReference)outcome='unknown'
|
|
101
141
|
result={code:error instanceof PolicyError?error.code:'TASK_BACKEND_FAILED',
|
|
142
|
+
...(error?.gate?{gate:error.gate}:{}),
|
|
143
|
+
...(error?.rule?{rule:error.rule}:{}),
|
|
102
144
|
...(!backendStarted&&!hasReference?{notStarted:true}:{}),
|
|
103
145
|
...(observedResult?.legacyRunId?{legacyRunId:observedResult.legacyRunId}:{}),
|
|
104
146
|
...(observedResult?.summary?{summary:observedResult.summary}:{})}
|
|
@@ -110,8 +152,10 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
110
152
|
else if(['unknown','succeeded'].includes(outcome))outcome=controller.signal.aborted?'cancelled':'failed'
|
|
111
153
|
result={...result,admissionCommitted:false,notDispatched:proven}
|
|
112
154
|
}
|
|
155
|
+
decide('settle',true,{outcome,carriedOut:backendStarted})
|
|
156
|
+
result={...result,authorization:Object.freeze({gates:Object.freeze(gates),carriedOut:backendStarted})}
|
|
113
157
|
try {journal.finish(identity,task.ownerBotId,task.taskId,task.attemptId,outcome,result,now())}
|
|
114
|
-
catch(error) {if(error.code==='STALE_TASK_RUNNER')stopRunner();else{stopRunner();throw error}}
|
|
158
|
+
catch(error) {if(error.code==='STALE_TASK_RUNNER')stopRunner('STALE_TASK_RUNNER',{taskId:task.taskId,attemptId:task.attemptId});else{stopRunner(error?.code??'TASK_FINISH_FAILED',{taskId:task.taskId,attemptId:task.attemptId});throw error}}
|
|
115
159
|
}
|
|
116
160
|
const pump=()=>{
|
|
117
161
|
if(stopped||pumping)return
|
|
@@ -131,7 +175,10 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
131
175
|
if(journal.ownerBlocked(item.owner_bot))fail('TASK_RECONCILIATION_REQUIRED')
|
|
132
176
|
subject=directory.forBot(item.owner_bot)
|
|
133
177
|
backend=admit(subject,item.payload)
|
|
134
|
-
}catch(error){
|
|
178
|
+
}catch(error){
|
|
179
|
+
journal.block(identity,item.owner_bot,item.id,error instanceof PolicyError?error.code:'TASK_POLICY_FAILED',now(),
|
|
180
|
+
{gate:error?.gate,rule:error?.rule});continue
|
|
181
|
+
}
|
|
135
182
|
const task=journal.claim(identity,item.owner_bot,item.id,now(),{atomic:backend.atomicAdmission===true})
|
|
136
183
|
if(!task)continue
|
|
137
184
|
const controller=new AbortController()
|
|
@@ -139,7 +186,7 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
139
186
|
active.set(task.taskId,{owner:item.owner_bot,controller,work})
|
|
140
187
|
work.catch(()=>{}).finally(()=>{active.delete(task.taskId);schedule()})
|
|
141
188
|
}
|
|
142
|
-
}catch(error){stopRunner()}
|
|
189
|
+
}catch(error){stopRunner(error?.code??'TASK_PUMP_FAILED')}
|
|
143
190
|
finally{pumping=false}
|
|
144
191
|
}
|
|
145
192
|
const schedule=()=>{
|
|
@@ -149,12 +196,56 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
149
196
|
}
|
|
150
197
|
const service=Object.freeze({
|
|
151
198
|
start() {
|
|
152
|
-
if(identity
|
|
199
|
+
if(identity&&!stopped)fail('TASK_RUNNER_UNAVAILABLE')
|
|
200
|
+
if(stopped){
|
|
201
|
+
// A stopped runner is rebuilt, not resurrected: the previous lease is
|
|
202
|
+
// released so `claimRunner` can hand out the next generation, and the
|
|
203
|
+
// old work was already aborted by stopRunner.
|
|
204
|
+
if(identity){try{journal.release(identity,now())}catch{}}
|
|
205
|
+
identity=undefined;stopped=false
|
|
206
|
+
}
|
|
153
207
|
identity=journal.claimRunner(`runner-${randomUUID()}`,runnerTtlMs,now())
|
|
154
208
|
journal.recover(identity,now())
|
|
155
209
|
journal.expire(identity,now())
|
|
156
|
-
|
|
157
|
-
heartbeat.
|
|
210
|
+
lastRenewAt=now()
|
|
211
|
+
heartbeat=setInterval(()=>{try{journal.renew(identity,runnerTtlMs,now());lastRenewAt=now();journal.expire(identity,now());schedule()}catch(error){stopRunner(error?.code??'TASK_RUNNER_RENEW_FAILED')}},Math.floor(runnerTtlMs/3))
|
|
212
|
+
stopState=null
|
|
213
|
+
heartbeat.unref();record('runner.started',{generation:identity.generation});schedule()
|
|
214
|
+
},
|
|
215
|
+
/**
|
|
216
|
+
* Rebuild the runner after a stop. A lease this process cannot take — another
|
|
217
|
+
* Host holds it — is reported, never thrown: the caller retries later, and
|
|
218
|
+
* the reason the runner stopped is kept until it actually starts again.
|
|
219
|
+
*/
|
|
220
|
+
restart({reason='TASK_RUNNER_RESTART_REQUESTED'}={}) {
|
|
221
|
+
if(closing)return Object.freeze({accepted:false,reason:'TASK_SERVICE_CLOSING',code:'TASK_SERVICE_CLOSING'})
|
|
222
|
+
if(!stopped)return Object.freeze({accepted:true,reason:'ALREADY_RUNNING',generation:identity?.generation??null})
|
|
223
|
+
const previous=stopState
|
|
224
|
+
try{
|
|
225
|
+
service.start()
|
|
226
|
+
}catch(error){
|
|
227
|
+
stopped=true;stopState=previous
|
|
228
|
+
return Object.freeze({accepted:false,code:error?.code??'TASK_RUNNER_UNAVAILABLE',reason,retryAfterMs:Math.max(250,Math.floor(runnerTtlMs/3))})
|
|
229
|
+
}
|
|
230
|
+
restarts++
|
|
231
|
+
record('runner.restarted',{reason,restarts,generation:identity.generation,previous:previous?.code??null})
|
|
232
|
+
return Object.freeze({accepted:true,reason,restarts,generation:identity.generation})
|
|
233
|
+
},
|
|
234
|
+
/** Read-only view for attention surfaces and the watchdog. */
|
|
235
|
+
state() {
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
running:!stopped&&!!identity,
|
|
238
|
+
stopped:!!stopped,
|
|
239
|
+
closed:!!closing,
|
|
240
|
+
generation:identity?.generation??null,
|
|
241
|
+
stopReason:stopState?.code??null,
|
|
242
|
+
stoppedAt:stopState?.at??null,
|
|
243
|
+
restarts,
|
|
244
|
+
lastRenewAt,
|
|
245
|
+
active:active.size,
|
|
246
|
+
observations:observations.size,
|
|
247
|
+
events:Object.freeze([...runnerEvents]),
|
|
248
|
+
})
|
|
158
249
|
},
|
|
159
250
|
submit(subject,request) {
|
|
160
251
|
writable();const id=owner(subject,'task/submit'),parsed=parseTaskSubmit(request)
|
|
@@ -255,10 +346,11 @@ export function createTaskService({journal,authority,directory,backends,within=(
|
|
|
255
346
|
},
|
|
256
347
|
async close() {
|
|
257
348
|
if(closing)return closing
|
|
258
|
-
stopped=true;clearInterval(heartbeat)
|
|
349
|
+
stopped=true;clearInterval(heartbeat);heartbeat=undefined
|
|
350
|
+
stopState??=Object.freeze({code:'TASK_HOST_STOPPED',at:now(),generation:identity?.generation??null})
|
|
259
351
|
for(const entry of active.values())entry.controller.abort(new PolicyError('TASK_HOST_STOPPED'))
|
|
260
352
|
for(const entry of observations.values())entry.controller.abort(new PolicyError('TASK_HOST_STOPPED'))
|
|
261
|
-
closing=(async()=>{await Promise.allSettled([...active.values(),...observations.values()].map(entry=>entry.work));if(identity)journal.release(identity,now())
|
|
353
|
+
closing=(async()=>{await Promise.allSettled([...active.values(),...observations.values()].map(entry=>entry.work));if(identity){try{journal.release(identity,now())}catch{}}journal.close()})()
|
|
262
354
|
return closing
|
|
263
355
|
},
|
|
264
356
|
})
|
|
@@ -12,3 +12,39 @@ export function taskStage({ state, needsReconciliation = false }) {
|
|
|
12
12
|
if (state === 'cancelled' || state === 'skipped') return 'idle'
|
|
13
13
|
throw new PolicyError('TASK_STAGE_INVALID')
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How far a task actually got, in words a person can act on.
|
|
18
|
+
*
|
|
19
|
+
* A row that says "failed" answers the wrong question: the useful one is whether
|
|
20
|
+
* anything ran. Three cases look identical in the state column and are completely
|
|
21
|
+
* different to a user — refused before dispatch, dispatched with no confirmation,
|
|
22
|
+
* and dispatched and failed. `carriedOut` comes from the checkpoint trail; where
|
|
23
|
+
* that is absent (older rows, a cancelled attempt) the absence of `notStarted` is
|
|
24
|
+
* not proof that something ran, so the answer stays unconfirmed.
|
|
25
|
+
*/
|
|
26
|
+
export function taskAttribution(view = {}) {
|
|
27
|
+
const state = view.state
|
|
28
|
+
const result = view.result ?? null
|
|
29
|
+
const carriedOut = result?.authorization?.carriedOut
|
|
30
|
+
const notStarted = result?.notStarted === true
|
|
31
|
+
if (state === 'needs-reconciliation') return {code: 'unconfirmed', label: '已执行,结果未确认'}
|
|
32
|
+
if (state === 'succeeded') return {code: 'succeeded', label: '已完成'}
|
|
33
|
+
if (state === 'cancelled' || state === 'skipped') {
|
|
34
|
+
return carriedOut === true
|
|
35
|
+
? {code: 'cancelled-after-dispatch', label: '已取消,执行已开始'}
|
|
36
|
+
: {code: 'cancelled', label: '已取消,未执行'}
|
|
37
|
+
}
|
|
38
|
+
if (state === 'blocked') {
|
|
39
|
+
return notStarted || carriedOut === false
|
|
40
|
+
? {code: 'not-executed', label: '未执行:检查未通过'}
|
|
41
|
+
: {code: 'blocked', label: '被拦下'}
|
|
42
|
+
}
|
|
43
|
+
if (state === 'failed') {
|
|
44
|
+
if (notStarted || carriedOut === false) return {code: 'not-executed', label: '未执行:执行前失败'}
|
|
45
|
+
if (carriedOut === true) return {code: 'failed-after-dispatch', label: '已执行,执行后失败'}
|
|
46
|
+
return {code: 'unconfirmed', label: '失败,是否执行过未确认'}
|
|
47
|
+
}
|
|
48
|
+
if (state === 'waiting-budget') return {code: 'waiting', label: '等待预算'}
|
|
49
|
+
return {code: 'pending', label: '进行中'}
|
|
50
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded retry for SQLite write contention, and the database's own clock.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the router ledger opens the shared connection with
|
|
5
|
+
* `busy_timeout=0` on purpose (its CAS commits must not block an await), so a
|
|
6
|
+
* borrowed journal connection inherits a non-blocking handle. A journal write
|
|
7
|
+
* that collides with another Host then fails with a bare `SQLITE_BUSY` instead
|
|
8
|
+
* of waiting, and the second Host cannot start. Read-modify-write contention on
|
|
9
|
+
* this file is expected — several Hosts, one ledger — so the failure must be a
|
|
10
|
+
* bounded wait, not a crash.
|
|
11
|
+
*
|
|
12
|
+
* Retry is synchronous on purpose: journal writes run inside a synchronous
|
|
13
|
+
* transaction and must not yield the event loop while holding a lock.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BUSY = Object.freeze(new Set(['SQLITE_BUSY', 'SQLITE_BUSY_SNAPSHOT', 'SQLITE_LOCKED']))
|
|
17
|
+
|
|
18
|
+
/** Sleep without yielding: safe inside a held write transaction. */
|
|
19
|
+
export function sleepSync(ms) {
|
|
20
|
+
if (!(ms > 0)) return
|
|
21
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(ms, 1000))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The database's own wall clock in milliseconds, independent of the caller's. */
|
|
25
|
+
export function readDatabaseClock(db) {
|
|
26
|
+
const row = db.prepare("SELECT CAST(round(julianday('now')*86400000) AS INTEGER) AS ms").get()
|
|
27
|
+
if (!row || !Number.isSafeInteger(row.ms) || row.ms <= 0) throw Object.assign(new Error('SQLITE_CLOCK_UNAVAILABLE'), { code: 'SQLITE_CLOCK_UNAVAILABLE' })
|
|
28
|
+
return row.ms
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run `work` inside an immediate transaction, retrying while the file is
|
|
33
|
+
* write-locked by another connection. Any other error, including a policy
|
|
34
|
+
* refusal thrown by `work`, propagates on the first attempt.
|
|
35
|
+
*/
|
|
36
|
+
export function writeTransaction(db, work, { deadlineMs = 10_000, sleepMs = 10 } = {}) {
|
|
37
|
+
if (typeof work !== 'function') throw new TypeError('writeTransaction requires work')
|
|
38
|
+
const until = Date.now() + Math.max(1, deadlineMs)
|
|
39
|
+
let attempt = 0
|
|
40
|
+
for (;;) {
|
|
41
|
+
try {
|
|
42
|
+
return db.transaction(work).immediate()
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!BUSY.has(error?.code) || Date.now() >= until) {
|
|
45
|
+
if (BUSY.has(error?.code)) {
|
|
46
|
+
throw Object.assign(new Error(`SQLITE_BUSY after ${deadlineMs}ms of retries`), {
|
|
47
|
+
code: 'SQLITE_BUSY', cause: error, sqliteCode: error?.code, deadlineMs, attempts: attempt + 1,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
attempt++
|
|
53
|
+
sleepSync(Math.min(sleepMs * attempt, 100))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -2705,10 +2705,20 @@ import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
|
2705
2705
|
|
|
2706
2706
|
// ../packages/capability-broker/src/authority.mjs
|
|
2707
2707
|
var PolicyError = class extends Error {
|
|
2708
|
-
|
|
2708
|
+
/**
|
|
2709
|
+
* `detail.gate` names the checkpoint that refused, and `detail.rule` the rule
|
|
2710
|
+
* text when one matched. A refusal that only says "TOOL_DENIED" leaves the
|
|
2711
|
+
* reader to guess which of several gates stopped it, and support then runs on
|
|
2712
|
+
* pasted logs.
|
|
2713
|
+
*/
|
|
2714
|
+
constructor(code, detail = {}) {
|
|
2709
2715
|
super(`Capability refused: ${code}`);
|
|
2710
2716
|
this.name = "PolicyError";
|
|
2711
2717
|
this.code = code;
|
|
2718
|
+
if (detail && typeof detail === "object") {
|
|
2719
|
+
if (typeof detail.gate === "string" && detail.gate) this.gate = detail.gate;
|
|
2720
|
+
if (typeof detail.rule === "string" && detail.rule) this.rule = detail.rule;
|
|
2721
|
+
}
|
|
2712
2722
|
}
|
|
2713
2723
|
};
|
|
2714
2724
|
|