@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.
Files changed (61) hide show
  1. package/.dsh-plugin/client.js +3 -3
  2. package/CHANGELOG.md +503 -0
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +6 -6
  5. package/README.zh.md +6 -6
  6. package/apps/integration-client/tasks-panel.jsx +2 -2
  7. package/docs/INSTALLATION_GUIDE.zh.md +150 -0
  8. package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +46 -0
  9. package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +153 -0
  10. package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +91 -0
  11. package/docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md +684 -0
  12. package/docs/ROUTING_LIVEBENCH_DATA_SEARCH.zh.md +74 -0
  13. package/docs/ROUTING_LIVEBENCH_VALIDATION.zh.md +154 -0
  14. package/docs/ROUTING_MATURITY.zh.md +76 -0
  15. package/docs/ROUTING_RESEARCH.zh.md +59 -0
  16. package/docs/ROUTING_SELFTEST_STEPS.zh.md +192 -0
  17. package/docs/WORKBENCH_USER_GUIDE.zh.md +293 -0
  18. package/docs/archive/MIGRATION.md +53 -0
  19. package/docs/archive/README.md +21 -0
  20. package/docs/assets/candidate-pruning.svg +80 -0
  21. package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
  22. package/docs/assets/router-only-0.12.0.png +0 -0
  23. package/docs/assets/routing-workflow.svg +96 -0
  24. package/docs/assets/workbench-budget-reservations.png +0 -0
  25. package/docs/assets/workbench-run-progress.png +0 -0
  26. package/docs/assets/workbench-run-timeout.png +0 -0
  27. package/docs/assets/workbench-subscription-usage.png +0 -0
  28. package/docs/assets/workbench-task-preview.jpg +0 -0
  29. package/docs/assets/workbench-usage.svg +119 -0
  30. package/docs/releases/0.20.0-validation.md +26 -0
  31. package/docs/releases/0.22.0-validation.md +28 -0
  32. package/docs/releases/0.23.0-validation.md +30 -0
  33. package/docs/releases/0.23.1-validation.md +23 -0
  34. package/docs/releases/0.24.0-validation.md +41 -0
  35. package/docs/releases/0.25.0-validation.md +41 -0
  36. package/docs/releases/0.25.1-validation.md +36 -0
  37. package/docs/releases/0.25.2-validation.md +48 -0
  38. package/docs/releases/0.25.3-validation.md +50 -0
  39. package/docs/releases/0.25.4-validation.md +41 -0
  40. package/docs/releases/0.25.5-validation.md +40 -0
  41. package/docs/releases/0.26.0-preview.0-validation.md +23 -0
  42. package/docs/releases/0.26.0-preview.1-f-difference.json +6845 -0
  43. package/docs/releases/0.26.0-preview.1-f-difference.md +85 -0
  44. package/docs/releases/0.26.0-preview.1-validation.md +103 -0
  45. package/docs/releases/0.63.0-validation.md +51 -0
  46. package/package.json +89 -6
  47. package/packages/capability-broker/src/authority.mjs +15 -1
  48. package/packages/model-router-plugin/README.md +3 -3
  49. package/packages/model-router-plugin/package.json +1 -1
  50. package/packages/orchestration/src/product-composition.mjs +48 -8
  51. package/packages/orchestration/src/sqlite-router-state.mjs +33 -8
  52. package/packages/orchestration/src/stop-proof.mjs +37 -0
  53. package/packages/orchestration/src/task-contract.mjs +42 -2
  54. package/packages/orchestration/src/task-journal.mjs +122 -39
  55. package/packages/orchestration/src/task-service.mjs +110 -18
  56. package/packages/orchestration/src/task-stage.mjs +36 -0
  57. package/packages/orchestration/src/write-retry.mjs +56 -0
  58. package/remote-computer-agent/dist/cli.mjs +11 -1
  59. package/remote-computer-agent/dist/cli.mjs.map +2 -2
  60. package/remote-computer-agent/dist/index.mjs +11 -1
  61. 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=true;clearInterval(heartbeat)
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
- lease=authority.issue(subject,{binding,tools:['task/execute',backend.capability],ttlMs:Math.max(1,Math.min(executionLeaseMs,task.deadlineAt-now()))})
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();journal.beginDispatch(identity,task.ownerBotId,task.taskId,task.attemptId,now());backendStarted=true
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){journal.block(identity,item.owner_bot,item.id,error instanceof PolicyError?error.code:'TASK_POLICY_FAILED',now());continue}
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||stopped)fail('TASK_RUNNER_UNAVAILABLE')
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
- heartbeat=setInterval(()=>{try{journal.renew(identity,runnerTtlMs,now());journal.expire(identity,now());schedule()}catch{stopRunner()}},Math.floor(runnerTtlMs/3))
157
- heartbeat.unref();schedule()
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());journal.close()})()
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
- constructor(code) {
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