@ljwei-stak/dsh-model-route-bot 0.65.0 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh-plugin/client.js +3 -3
- 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/package.json +3 -1
- 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
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { PolicyError } from '../../capability-broker/src/authority.mjs'
|
|
3
|
-
import { canonicalTaskJson, taskRef, taskHash, parseTaskPlan, validateTaskPlan } from './task-contract.mjs'
|
|
3
|
+
import { canonicalTaskJson, taskRef, taskHash, parseTaskPlan, validateTaskPlan, planInitiator } from './task-contract.mjs'
|
|
4
4
|
import {createTaskAdmission} from './task-admission.mjs'
|
|
5
|
-
import {taskStage} from './task-stage.mjs'
|
|
5
|
+
import {taskStage, taskAttribution} from './task-stage.mjs'
|
|
6
|
+
import {readDatabaseClock, writeTransaction} from './write-retry.mjs'
|
|
7
|
+
import {requiresStopProof, stopProofEvidence} from './stop-proof.mjs'
|
|
6
8
|
|
|
7
9
|
const fail = code => { throw new PolicyError(code) }
|
|
8
10
|
const makeId = kind => `${kind}-${randomUUID()}`
|
|
9
11
|
const terminalRetry = new Set(['failed','cancelled','blocked','waiting-budget'])
|
|
10
12
|
|
|
11
13
|
/** SQLite journal for managed tasks and dependency plans. */
|
|
12
|
-
export function openTaskJournal(Database, path, {database=null,financialEpoch=null,onClose=()=>{}}={}) {
|
|
14
|
+
export function openTaskJournal(Database, path, {database=null,financialEpoch=null,leaseClock='app',writeDeadlineMs=10_000,onClose=()=>{}}={}) {
|
|
13
15
|
const db = database??new Database(path),borrowed=database!==null
|
|
14
16
|
const priorTimeout=db.pragma('busy_timeout',{simple:true})
|
|
15
17
|
try {
|
|
18
|
+
// Contention is the normal case, not an error: several Hosts share this
|
|
19
|
+
// file. Keep the built-in wait on for this journal's whole life and let
|
|
20
|
+
// writeTransaction bound it; restoring a borrowed handle to 0 here is what
|
|
21
|
+
// made a second Host fail at start-up instead of queueing.
|
|
16
22
|
db.pragma('foreign_keys = ON');db.pragma('busy_timeout = 5000')
|
|
17
23
|
if(db.pragma('journal_mode = WAL',{simple:true})!=='wal')fail('TASK_WAL_REQUIRED')
|
|
18
24
|
db.pragma('synchronous = FULL')
|
|
@@ -42,17 +48,22 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
42
48
|
PRAGMA user_version = 5;
|
|
43
49
|
`)
|
|
44
50
|
}).immediate()
|
|
45
|
-
|
|
46
|
-
} catch(error) { if(!borrowed)db.close();else db.pragma(`busy_timeout=${priorTimeout}`);throw error }
|
|
51
|
+
} catch(error) { db.pragma(`busy_timeout=${priorTimeout}`);if(!borrowed)db.close();throw error }
|
|
47
52
|
let closed = false
|
|
48
53
|
const stmt = sql => {if(closed)fail('TASK_JOURNAL_CLOSED');return db.prepare(sql)}
|
|
49
|
-
|
|
54
|
+
// Every write retries a locked file within a bound; a policy refusal inside
|
|
55
|
+
// `work` still surfaces on the first attempt.
|
|
56
|
+
const transact = work => writeTransaction(db,()=>{const result=work();refreshPlans();return result},{deadlineMs:writeDeadlineMs})
|
|
57
|
+
// `leaseClock:'db'` makes lease expiry and every fence read the database's
|
|
58
|
+
// clock, so a Host whose wall clock drifted cannot hand its own lease to
|
|
59
|
+
// another runner. Fixtures keep the injected clock ('app') to simulate time.
|
|
60
|
+
const leaseNow = leaseClock==='db' ? now => {time(now);return readDatabaseClock(db)} : now => now
|
|
50
61
|
const time = now => { if (!Number.isSafeInteger(now) || now < 0) fail('INVALID_TASK_TIME');return now }
|
|
51
62
|
const event = (taskId,attemptId,kind,now,detail={}) => stmt('INSERT INTO task_events(task_id,attempt_id,kind,at_ms,detail) VALUES(?,?,?,?,?)').run(taskId,attemptId,kind,now,canonicalTaskJson(detail))
|
|
52
63
|
const fence = (identity,now) => {
|
|
53
|
-
|
|
64
|
+
const at=leaseNow(now)
|
|
54
65
|
const current=stmt('SELECT * FROM task_runner WHERE scope=?').get('execution')
|
|
55
|
-
if (!current || current.owner!==identity?.owner || current.generation!==identity.generation || current.until_ms<=
|
|
66
|
+
if (!current || current.owner!==identity?.owner || current.generation!==identity.generation || current.until_ms<=at) fail('STALE_TASK_RUNNER')
|
|
56
67
|
}
|
|
57
68
|
const owned = (owner,id) => {
|
|
58
69
|
const task=stmt('SELECT * FROM tasks WHERE id=? AND owner_bot=?').get(taskRef(id),taskRef(owner))
|
|
@@ -62,35 +73,79 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
62
73
|
const latest = taskId => stmt('SELECT a.*,p.id AS package_id,o.id AS operation_id FROM packages p JOIN attempts a ON a.id=p.current_attempt JOIN operations o ON o.attempt_id=a.id WHERE p.task_id=?').get(taskId)
|
|
63
74
|
// An execution reference records intent before dispatch, not proof that
|
|
64
75
|
// dispatch did or did not happen. Older rows may lack a reference entirely.
|
|
65
|
-
|
|
76
|
+
/** The three rows a stop-proof verdict reads, for one attempt. */
|
|
77
|
+
const stopProofContext = attemptId => ({
|
|
78
|
+
operation:stmt('SELECT o.*,b.delivered_ms FROM operations o JOIN task_outbox b ON b.operation_id=o.id WHERE o.attempt_id=?').get(attemptId),
|
|
79
|
+
ref:stmt('SELECT reference,reference_hash FROM task_execution_refs WHERE attempt_id=?').get(attemptId),
|
|
80
|
+
dispatch:stmt('SELECT * FROM task_dispatch_fences WHERE attempt_id=?').get(attemptId),
|
|
81
|
+
})
|
|
82
|
+
// Pure: every row it needs arrives in `context`, so a list read can supply them
|
|
83
|
+
// for a whole page at once instead of paying three queries per task.
|
|
84
|
+
const needsStopProofWith = (task,attempt,result,context) => {
|
|
66
85
|
const backend=JSON.parse(task.payload).backend
|
|
67
|
-
if(!
|
|
68
|
-
const operation=
|
|
69
|
-
const ref=stmt('SELECT reference,reference_hash FROM task_execution_refs WHERE attempt_id=?').get(attempt.id)
|
|
70
|
-
const dispatch=stmt('SELECT * FROM task_dispatch_fences WHERE attempt_id=?').get(attempt.id)
|
|
86
|
+
if(!requiresStopProof(backend))return false
|
|
87
|
+
const {operation,ref,dispatch}=context
|
|
71
88
|
const queued=attempt.started_ms===null&&operation?.started_ms===null&&operation.delivered_ms===null&&!ref
|
|
72
89
|
if(queued)return false
|
|
73
90
|
// Only the Host's durable closed dispatch fence (not a result flag) proves
|
|
74
91
|
// a callback never entered. beginDispatch and assertDispatch reject it later.
|
|
75
92
|
if(operation&&dispatch?.operation_id===operation.id&&dispatch.state==='closed'&&!ref)return false
|
|
76
93
|
const proof=result?.summary?.stopProof
|
|
77
|
-
const evidence=backend
|
|
94
|
+
const evidence=stopProofEvidence(backend)??[]
|
|
78
95
|
if(!operation||!ref||!proof||proof.schemaVersion!==1||proof.taskId!==task.id||proof.attemptId!==attempt.id||proof.operationId!==operation.id||proof.targetHash!==JSON.parse(task.payload).targetHash||proof.referenceHash!==ref.reference_hash||!evidence.includes(proof.source)||proof.source!==result?.summary?.stopEvidence||!/^[a-f0-9]{64}$/.test(proof.evidenceHash??'')||result?.summary?.stopConfirmed!==true)return true
|
|
79
96
|
try{const reference=JSON.parse(ref.reference);validateReference(task,{...attempt,operation_id:operation.id},reference);return taskHash(reference)!==proof.referenceHash}catch{return true}
|
|
80
97
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
const needsStopProof = (task,attempt,result) => needsStopProofWith(task,attempt,result,stopProofContext(attempt.id))
|
|
99
|
+
// One page of task rows becomes one view object each, from a fixed number of
|
|
100
|
+
// queries rather than nine per task. A 512-row product list used to cost about
|
|
101
|
+
// 4,600 statements; that is long enough to starve the runner lease it reads for.
|
|
102
|
+
const viewsOf = (taskRows,command=null) => {
|
|
103
|
+
if(!taskRows.length)return []
|
|
104
|
+
const ids=taskRows.map(row=>row.id),json=JSON.stringify(ids)
|
|
105
|
+
const current=new Map(stmt(`SELECT p.task_id,a.*,p.id AS package_id,o.id AS operation_id,o.state AS operation_state,
|
|
106
|
+
b.delivered_ms,f.state AS dispatch_state
|
|
107
|
+
FROM packages p JOIN attempts a ON a.id=p.current_attempt JOIN operations o ON o.attempt_id=a.id
|
|
108
|
+
LEFT JOIN task_outbox b ON b.operation_id=o.id LEFT JOIN task_dispatch_fences f ON f.attempt_id=a.id
|
|
109
|
+
WHERE p.task_id IN (SELECT value FROM json_each(?))`).all(json).map(row=>[row.task_id,row]))
|
|
110
|
+
const membership=new Map(stmt('SELECT task_id,plan_id,logical_id FROM task_plan_packages WHERE task_id IN (SELECT value FROM json_each(?))').all(json).map(row=>[row.task_id,row]))
|
|
111
|
+
const refs=new Map(stmt('SELECT attempt_id,reference,reference_hash FROM task_execution_refs WHERE attempt_id IN (SELECT value FROM json_each(?))').all(JSON.stringify([...current.values()].map(row=>row.id))).map(row=>[row.attempt_id,row]))
|
|
112
|
+
const dispatches=new Map(stmt('SELECT attempt_id,operation_id,state FROM task_dispatch_fences WHERE attempt_id IN (SELECT value FROM json_each(?))').all(JSON.stringify([...current.values()].map(row=>row.id))).map(row=>[row.attempt_id,row]))
|
|
113
|
+
const reconciliations=new Map()
|
|
114
|
+
for(const row of stmt('SELECT * FROM task_reconciliations WHERE attempt_id IN (SELECT value FROM json_each(?)) ORDER BY id').all(JSON.stringify([...current.values()].map(row=>row.id))))reconciliations.set(row.attempt_id,row)
|
|
115
|
+
const legacy=new Map()
|
|
116
|
+
for(const row of stmt('SELECT task_id,run_id FROM legacy_task_runs WHERE task_id IN (SELECT value FROM json_each(?)) ORDER BY rowid').all(json)){
|
|
117
|
+
const list=legacy.get(row.task_id)??[];list.push(row.run_id);legacy.set(row.task_id,list)
|
|
118
|
+
}
|
|
119
|
+
const attemptRows=new Map()
|
|
120
|
+
for(const row of stmt(`SELECT a.package_id,a.id,a.number,a.parent_id,a.state,a.started_ms,a.finished_ms,o.id AS operation_id
|
|
121
|
+
FROM attempts a JOIN operations o ON o.attempt_id=a.id WHERE a.package_id IN (SELECT value FROM json_each(?)) ORDER BY a.number`).all(JSON.stringify([...current.values()].map(row=>row.package_id)))){
|
|
122
|
+
const list=attemptRows.get(row.package_id)??[];list.push(row);attemptRows.set(row.package_id,list)
|
|
123
|
+
}
|
|
124
|
+
return taskRows.map(task=>{
|
|
125
|
+
const currentAttempt=current.get(task.id)
|
|
126
|
+
if(!currentAttempt)fail('TASK_NOT_FOUND')
|
|
127
|
+
const membershipRow=membership.get(task.id),ref=refs.get(currentAttempt.id),reconciliation=reconciliations.get(currentAttempt.id)
|
|
128
|
+
const result=currentAttempt.result?JSON.parse(currentAttempt.result):null
|
|
129
|
+
const confirmed=result?.summary?needsStopProofWith(task,currentAttempt,result,{
|
|
130
|
+
operation:{...currentAttempt,id:currentAttempt.operation_id,state:currentAttempt.operation_state},
|
|
131
|
+
ref,dispatch:dispatches.get(currentAttempt.id),
|
|
132
|
+
}):false
|
|
133
|
+
const base={taskId:task.id,ownerBotId:task.owner_bot,state:task.state,stage:taskStage(task),packageId:currentAttempt.package_id,attemptId:currentAttempt.id,operationId:currentAttempt.operation_id,
|
|
134
|
+
...(membershipRow?{planId:membershipRow.plan_id,logicalPackageId:membershipRow.logical_id}:{}),
|
|
135
|
+
attemptNumber:currentAttempt.number,deadlineAt:task.deadline_ms,createdAt:task.created_ms,updatedAt:task.updated_ms,
|
|
136
|
+
input:JSON.parse(task.payload),
|
|
137
|
+
result:result?.summary&&confirmed?{...result,summary:{...result.summary,stopConfirmed:false}}:result,
|
|
138
|
+
executionRef:ref?JSON.parse(ref.reference):null,
|
|
139
|
+
reconciliation:reconciliation?{at:reconciliation.at_ms,outcome:reconciliation.outcome,evidence:JSON.parse(reconciliation.evidence)}:null,
|
|
140
|
+
legacyRunIds:legacy.get(task.id)??[],
|
|
141
|
+
attempts:attemptRows.get(currentAttempt.package_id)??[],
|
|
142
|
+
...(command?{replayed:true,acceptedAttemptId:command.attempt_id}:{replayed:false})}
|
|
143
|
+
// What the row says is not the whole answer: "failed" alone cannot tell a
|
|
144
|
+
// refusal from a run that broke, and those need different next steps.
|
|
145
|
+
return {...base,attribution:taskAttribution(base)}
|
|
146
|
+
})
|
|
93
147
|
}
|
|
148
|
+
const view = (owner,id,command=null) => viewsOf([owned(owner,id)],command)[0]
|
|
94
149
|
const replay = (owner,key,kind,hash) => {
|
|
95
150
|
const previous=stmt('SELECT * FROM task_commands WHERE owner_bot=? AND request_key=?').get(owner,key)
|
|
96
151
|
if (previous && (previous.kind!==kind || previous.request_hash!==hash)) fail('TASK_REQUEST_CONFLICT')
|
|
@@ -157,7 +212,15 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
157
212
|
blocked=stmt("SELECT t.id,MAX(parent_task.updated_ms) AS updated_ms FROM tasks t JOIN packages p ON p.task_id=t.id JOIN attempts a ON a.id=p.current_attempt JOIN task_package_deps d ON d.package_id=p.id JOIN packages parent ON parent.id=d.depends_on JOIN tasks parent_task ON parent_task.id=parent.task_id WHERE t.state='ready' AND a.started_ms IS NULL AND parent.state IN ('failed','cancelled','blocked','skipped') GROUP BY t.id").all()
|
|
158
213
|
for(const row of blocked){const a=latest(row.id);updateAll(row.id,a,'blocked',row.updated_ms,{code:'DEPENDENCY_FAILED',notStarted:true});event(row.id,a.id,'dependency-blocked',row.updated_ms,{code:'DEPENDENCY_FAILED'})}
|
|
159
214
|
}while(blocked.length)
|
|
160
|
-
|
|
215
|
+
// A plan that is at rest cannot change on this write: its state is derived
|
|
216
|
+
// from its members' states and from its own cancel flag, and every member
|
|
217
|
+
// write bumps `updated_ms`. Selecting the candidates once keeps a Host with
|
|
218
|
+
// many finished plans from paying for all of them on every journal write.
|
|
219
|
+
const candidates=stmt(`SELECT p.id,p.state,p.cancel_requested,p.updated_ms FROM task_plans p
|
|
220
|
+
WHERE p.state NOT IN ('succeeded','cancelled','failed')
|
|
221
|
+
OR (p.cancel_requested=1 AND p.state NOT IN ('cancelling','cancelled'))
|
|
222
|
+
OR EXISTS(SELECT 1 FROM task_plan_packages m JOIN tasks t ON t.id=m.task_id WHERE m.plan_id=p.id AND t.updated_ms>p.updated_ms)`).all()
|
|
223
|
+
for(const plan of candidates){
|
|
161
224
|
const rows=stmt('SELECT t.state,t.updated_ms FROM task_plan_packages m JOIN tasks t ON t.id=m.task_id WHERE m.plan_id=?').all(plan.id)
|
|
162
225
|
const states=rows.map(row=>row.state),pending=states.some(state=>['ready','admitting','running','cancelling','waiting-budget','needs-reconciliation'].includes(state))
|
|
163
226
|
const state=plan.cancel_requested?(pending?'cancelling':'cancelled'):
|
|
@@ -173,7 +236,11 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
173
236
|
const packages=rows.map(row=>{const current=latest(row.id);return{id:row.logical_id,taskId:row.id,packageId:row.package_id,targetBotId:row.owner_bot,state:row.state,attemptId:current.id,operationId:current.operation_id,attemptNumber:current.number,
|
|
174
237
|
result:view(row.owner_bot,row.id).result,dependsOn:stmt('SELECT parent.logical_id FROM task_package_deps d JOIN task_plan_packages parent ON parent.package_id=d.depends_on WHERE d.package_id=? ORDER BY parent.rowid').all(row.package_id).map(value=>value.logical_id)}})
|
|
175
238
|
const result={planId:plan.id,ownerBotId:plan.owner_bot,state:plan.state,cancelRequested:Boolean(plan.cancel_requested),needsReconciliation:packages.some(row=>row.state==='needs-reconciliation'),
|
|
176
|
-
createdAt:plan.created_ms,updatedAt:plan.updated_ms,deadlineAt:plan.deadline_ms,plan:JSON.parse(plan.payload),
|
|
239
|
+
createdAt:plan.created_ms,updatedAt:plan.updated_ms,deadlineAt:plan.deadline_ms,plan:JSON.parse(plan.payload),
|
|
240
|
+
// What started it, beside whose authority it carries: a schedule or a
|
|
241
|
+
// delegation runs as a person while nobody is watching.
|
|
242
|
+
initiator:(()=>{try{return planInitiator(JSON.parse(plan.payload))}catch{return {kind:'person'}}})(),
|
|
243
|
+
packages,replayed}
|
|
177
244
|
return{...result,stage:taskStage(result)}
|
|
178
245
|
}
|
|
179
246
|
const cancelledPlan=taskId=>Boolean(stmt('SELECT 1 FROM task_plan_packages m JOIN task_plans p ON p.id=m.plan_id WHERE m.task_id=? AND p.cancel_requested=1').get(taskId))
|
|
@@ -202,20 +269,20 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
202
269
|
taskRef(owner);time(now)
|
|
203
270
|
if (!Number.isSafeInteger(ttlMs)||ttlMs<50||ttlMs>60_000) fail('INVALID_RUNNER_TTL')
|
|
204
271
|
return transact(()=>{
|
|
272
|
+
const at=leaseNow(now)
|
|
205
273
|
const old=stmt('SELECT * FROM task_runner WHERE scope=?').get('execution')
|
|
206
|
-
if(old&&old.until_ms>
|
|
274
|
+
if(old&&old.until_ms>at)fail('TASK_RUNNER_BUSY')
|
|
207
275
|
const generation=(old?.generation??0)+1
|
|
208
|
-
stmt('INSERT INTO task_runner VALUES(?,?,?,?) ON CONFLICT(scope) DO UPDATE SET owner=excluded.owner,generation=excluded.generation,until_ms=excluded.until_ms').run('execution',owner,generation,
|
|
276
|
+
stmt('INSERT INTO task_runner VALUES(?,?,?,?) ON CONFLICT(scope) DO UPDATE SET owner=excluded.owner,generation=excluded.generation,until_ms=excluded.until_ms').run('execution',owner,generation,at+ttlMs)
|
|
209
277
|
return Object.freeze({owner,generation})
|
|
210
278
|
})
|
|
211
279
|
},
|
|
212
280
|
renew(identity,ttlMs,now) {
|
|
213
281
|
if (!Number.isSafeInteger(ttlMs)||ttlMs<50||ttlMs>60_000) fail('INVALID_RUNNER_TTL')
|
|
214
|
-
return transact(()=>{fence(identity,now);stmt('UPDATE task_runner SET until_ms=? WHERE scope=?').run(now+ttlMs,'execution')})
|
|
282
|
+
return transact(()=>{fence(identity,now);stmt('UPDATE task_runner SET until_ms=? WHERE scope=?').run(leaseNow(now)+ttlMs,'execution')})
|
|
215
283
|
},
|
|
216
284
|
release(identity,now) {
|
|
217
|
-
|
|
218
|
-
stmt('UPDATE task_runner SET until_ms=? WHERE scope=? AND owner=? AND generation=?').run(now,'execution',identity.owner,identity.generation)
|
|
285
|
+
return transact(()=>{const at=leaseNow(now);stmt('UPDATE task_runner SET until_ms=? WHERE scope=? AND owner=? AND generation=?').run(at,'execution',identity.owner,identity.generation)})
|
|
219
286
|
},
|
|
220
287
|
recover(identity,now) {
|
|
221
288
|
return transact(()=>{
|
|
@@ -318,6 +385,19 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
318
385
|
if(task.deadline_ms<=now)fail('TASK_DEADLINE')
|
|
319
386
|
})
|
|
320
387
|
},
|
|
388
|
+
/**
|
|
389
|
+
* Record one decision about a running attempt. A gate row says what was
|
|
390
|
+
* checked and what it decided; it never changes the task's state, so a
|
|
391
|
+
* refusal that ends the attempt still lands through `finish`/`block`.
|
|
392
|
+
*/
|
|
393
|
+
note(identity,owner,id,attemptId,kind,now,detail={}) {
|
|
394
|
+
taskRef(kind)
|
|
395
|
+
return transact(()=>{fence(identity,now);const task=owned(owner,id),current=latest(id)
|
|
396
|
+
if(current.id!==attemptId)fail('STALE_TASK_ATTEMPT')
|
|
397
|
+
event(id,attemptId,kind,now,detail)
|
|
398
|
+
return detail
|
|
399
|
+
})
|
|
400
|
+
},
|
|
321
401
|
beginDispatch(identity,owner,id,attemptId,now){
|
|
322
402
|
return transact(()=>{fence(identity,now);const task=owned(owner,id),current=latest(id)
|
|
323
403
|
if(current.id!==attemptId||current.generation!==identity.generation||!['running','admitting'].includes(task.state))fail('STALE_TASK_ATTEMPT')
|
|
@@ -403,9 +483,12 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
403
483
|
proveNotDispatched:()=>{const task=owned(owner,id),current=latest(id);return current.id===attemptId&&['admitting','cancelling'].includes(task.state)&¬Dispatched(id,current)},
|
|
404
484
|
})
|
|
405
485
|
},
|
|
406
|
-
block(identity,owner,id,code,now) {
|
|
486
|
+
block(identity,owner,id,code,now,detail={}) {
|
|
407
487
|
return transact(()=>{fence(identity,now);const task=owned(owner,id),current=latest(id);if(task.state!=='ready')return
|
|
408
|
-
|
|
488
|
+
// The gate and the rule travel with the state, so the record answers
|
|
489
|
+
// "which check refused this" without a second lookup.
|
|
490
|
+
const facts={code,...(typeof detail?.gate==='string'&&detail.gate?{gate:detail.gate}:{}),...(typeof detail?.rule==='string'&&detail.rule?{rule:detail.rule}:{})}
|
|
491
|
+
updateAll(id,current,'blocked',now,{...facts,notStarted:true});event(id,current.id,'blocked',now,facts)})
|
|
409
492
|
},
|
|
410
493
|
// A plan has a total deadline: unattended budget waiting ends at it instead of parking forever.
|
|
411
494
|
expire(identity,now) {
|
|
@@ -558,14 +641,14 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
558
641
|
const total=stmt('SELECT owner_bot,COUNT(*) AS n FROM tasks GROUP BY owner_bot').all()
|
|
559
642
|
.reduce((sum,row)=>sum+(visible.has(row.owner_bot)?row.n:0),0)
|
|
560
643
|
// Filter owners before the limit, including when old/deleted Bots still have records.
|
|
561
|
-
const items=stmt(`SELECT
|
|
644
|
+
const items=viewsOf(stmt(`SELECT * FROM tasks WHERE owner_bot IN (SELECT value FROM json_each(?))
|
|
562
645
|
ORDER BY CASE WHEN state IN ('needs-reconciliation','waiting-approval','waiting-budget','blocked') THEN 0
|
|
563
646
|
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)
|
|
647
|
+
updated_ms DESC,id LIMIT ?`).all(JSON.stringify(ids),limit))
|
|
565
648
|
return {items,total,truncated:total>items.length}
|
|
566
649
|
},
|
|
567
650
|
// One Bot's durable tasks, oldest first; the read model never invents a task.
|
|
568
|
-
list(owner) {taskRef(owner);return stmt('SELECT
|
|
569
|
-
close() {if(!closed){closed=true;if(
|
|
651
|
+
list(owner) {taskRef(owner);return viewsOf(stmt('SELECT * FROM tasks WHERE owner_bot=? ORDER BY created_ms,id LIMIT 128').all(owner))},
|
|
652
|
+
close() {if(!closed){closed=true;if(borrowed){try{db.pragma(`busy_timeout=${priorTimeout}`)}catch{}}else db.close();onClose()}},
|
|
570
653
|
})
|
|
571
654
|
}
|
|
@@ -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
|
|