@ljwei-stak/dsh-model-route-bot 0.64.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 (34) hide show
  1. package/.dsh-plugin/client.js +694 -407
  2. package/CHANGELOG.md +47 -0
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +209 -147
  5. package/README.zh.md +208 -146
  6. package/apps/integration-client/bot-attention.mjs +2 -2
  7. package/apps/integration-client/panels.jsx +239 -134
  8. package/apps/integration-client/product.css +36 -0
  9. package/apps/integration-client/routing-submissions.mjs +68 -0
  10. package/apps/integration-client/task-progress.mjs +46 -0
  11. package/apps/integration-client/tasks-panel.jsx +147 -0
  12. package/modules/bot-runtime/read-model.mjs +2 -2
  13. package/package.json +3 -1
  14. package/packages/capability-broker/src/authority.mjs +15 -1
  15. package/packages/model-router-plugin/README.md +39 -15
  16. package/packages/model-router-plugin/README.zh.md +40 -16
  17. package/packages/model-router-plugin/package.json +1 -1
  18. package/packages/orchestration/src/bot-routing-candidates.mjs +6 -6
  19. package/packages/orchestration/src/bot-routing-service.mjs +30 -5
  20. package/packages/orchestration/src/bot-routing.mjs +6 -4
  21. package/packages/orchestration/src/product-composition.mjs +65 -31
  22. package/packages/orchestration/src/sqlite-router-state.mjs +33 -8
  23. package/packages/orchestration/src/stop-proof.mjs +37 -0
  24. package/packages/orchestration/src/task-contract.mjs +42 -2
  25. package/packages/orchestration/src/task-journal.mjs +143 -37
  26. package/packages/orchestration/src/task-product.mjs +49 -0
  27. package/packages/orchestration/src/task-service.mjs +110 -18
  28. package/packages/orchestration/src/task-stage.mjs +36 -0
  29. package/packages/orchestration/src/write-retry.mjs +56 -0
  30. package/remote-computer-agent/build.mjs +48 -13
  31. package/remote-computer-agent/dist/cli.mjs +11 -1
  32. package/remote-computer-agent/dist/cli.mjs.map +2 -2
  33. package/remote-computer-agent/dist/index.mjs +11 -1
  34. 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
- if(borrowed)db.pragma(`busy_timeout=${priorTimeout}`)
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
- const transact = work => db.transaction(()=>{const result=work();refreshPlans();return result}).immediate()
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
- time(now)
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<=now) fail('STALE_TASK_RUNNER')
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
- const needsStopProof = (task,attempt,result) => {
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(!['codex-read','remote-write','bot-turn-read','bot-turn-write'].includes(backend))return false
68
- const 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(attempt.id)
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.startsWith('bot-turn-')?['bot-turn-ended','bot-turn-not-sent']:backend==='codex-read'?['native-tree','cli-no-dispatch']:['agent-stop-receipt','agent-write-completed','remote-dispatch-not-sent']
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 view = (owner,id,command=null) => {
82
- const task=owned(owner,id),current=latest(id)
83
- const membership=stmt('SELECT plan_id,logical_id FROM task_plan_packages WHERE task_id=?').get(id)
84
- return {taskId:task.id,ownerBotId:task.owner_bot,state:task.state,stage:taskStage(task),packageId:current.package_id,attemptId:current.id,operationId:current.operation_id,
85
- ...(membership?{planId:membership.plan_id,logicalPackageId:membership.logical_id}:{}),
86
- attemptNumber:current.number,deadlineAt:task.deadline_ms,createdAt:task.created_ms,updatedAt:task.updated_ms,
87
- input:JSON.parse(task.payload),result:(()=>{const result=current.result?JSON.parse(current.result):null;if(result?.summary&&needsStopProof(task,current,result))return {...result,summary:{...result.summary,stopConfirmed:false}};return result})(),
88
- executionRef:(()=>{const row=stmt('SELECT reference FROM task_execution_refs WHERE attempt_id=?').get(current.id);return row?JSON.parse(row.reference):null})(),
89
- reconciliation:(()=>{const row=stmt('SELECT * FROM task_reconciliations WHERE attempt_id=? ORDER BY id DESC LIMIT 1').get(current.id);return row?{at:row.at_ms,outcome:row.outcome,evidence:JSON.parse(row.evidence)}:null})(),
90
- legacyRunIds:stmt('SELECT run_id FROM legacy_task_runs WHERE task_id=? ORDER BY rowid').all(id).map(row=>row.run_id),
91
- attempts:stmt('SELECT a.id,a.number,a.parent_id,a.state,a.started_ms,a.finished_ms,o.id AS operation_id FROM attempts a JOIN operations o ON o.attempt_id=a.id WHERE a.package_id=? ORDER BY a.number').all(current.package_id),
92
- ...(command?{replayed:true,acceptedAttemptId:command.attempt_id}:{replayed:false})}
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
- for(const plan of stmt('SELECT * FROM task_plans').all()){
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),packages,replayed}
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>now)fail('TASK_RUNNER_BUSY')
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,now+ttlMs)
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
- time(now)
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)&&notDispatched(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
- updateAll(id,current,'blocked',now,{code,notStarted:true});event(id,current.id,'blocked',now,{code})})
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) {
@@ -541,8 +624,31 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
541
624
  })
542
625
  },
543
626
  events(owner,id) {owned(owner,id);return stmt('SELECT * FROM task_events WHERE task_id=? ORDER BY seq').all(id)},
627
+ timeline(owner,id,{limit=80}={}) {
628
+ owned(owner,id)
629
+ if(!Number.isSafeInteger(limit)||limit<1||limit>200)fail('INVALID_TASK_HISTORY_LIMIT')
630
+ const total=stmt('SELECT COUNT(*) AS n FROM task_events WHERE task_id=?').get(id).n
631
+ const items=stmt('SELECT * FROM task_events WHERE task_id=? ORDER BY seq DESC LIMIT ?').all(id,limit).reverse()
632
+ return {items,total,truncated:total>items.length}
633
+ },
634
+ // Product lists must not permanently hide new tasks behind a Bot's first 128 records.
635
+ // This read is global across the Host's known Bots; it never changes runner queue ordering.
636
+ productList(owners,{limit=512}={}) {
637
+ if(!Array.isArray(owners)||!Number.isSafeInteger(limit)||limit<1||limit>512)fail('INVALID_TASK_LIST')
638
+ const ids=[...new Set(owners.map(taskRef))]
639
+ if(!ids.length)return {items:[],total:0,truncated:false}
640
+ const visible=new Set(ids)
641
+ const total=stmt('SELECT owner_bot,COUNT(*) AS n FROM tasks GROUP BY owner_bot').all()
642
+ .reduce((sum,row)=>sum+(visible.has(row.owner_bot)?row.n:0),0)
643
+ // Filter owners before the limit, including when old/deleted Bots still have records.
644
+ const items=viewsOf(stmt(`SELECT * FROM tasks WHERE owner_bot IN (SELECT value FROM json_each(?))
645
+ ORDER BY CASE WHEN state IN ('needs-reconciliation','waiting-approval','waiting-budget','blocked') THEN 0
646
+ WHEN state IN ('ready','queued','running','cancelling','admitting') THEN 1 WHEN state='failed' THEN 2 ELSE 3 END,
647
+ updated_ms DESC,id LIMIT ?`).all(JSON.stringify(ids),limit))
648
+ return {items,total,truncated:total>items.length}
649
+ },
544
650
  // One Bot's durable tasks, oldest first; the read model never invents a task.
545
- list(owner) {taskRef(owner);return stmt('SELECT id FROM tasks WHERE owner_bot=? ORDER BY created_ms,id LIMIT 128').all(owner).map(row=>view(owner,row.id))},
546
- close() {if(!closed){closed=true;if(!borrowed)db.close();onClose()}},
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()}},
547
653
  })
548
654
  }
@@ -0,0 +1,49 @@
1
+ // Public task views contain execution identities and status evidence, never backend inputs,
2
+ // execution references, raw event details or reconciliation proofs.
3
+ const short = (value, limit = 160) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, limit) : ''
4
+ const code = value => typeof value === 'string' && /^[A-Z0-9_:-]{1,128}$/.test(value) ? value : null
5
+
6
+ export function projectProductTask(task) {
7
+ const input = task.input?.input ?? {}
8
+ const title = short(input.task || input.text || input.prompt) || (input.relativePath ? short(`文件操作:${input.relativePath}`) : '')
9
+ const remote = task.result?.summary
10
+ return {
11
+ botId: task.ownerBotId, taskId: task.taskId, packageId: task.packageId, attemptId: task.attemptId,
12
+ state: task.state, title, backend: task.input?.backend,
13
+ ...(task.planId ? { planId: task.planId, logicalPackageId: task.logicalPackageId } : {}),
14
+ location: task.input?.target?.location, machineId: task.input?.target?.machineId, workspaceId: task.input?.target?.workspaceId,
15
+ createdAt: task.createdAt, updatedAt: task.updatedAt, deadlineAt: task.deadlineAt,
16
+ attemptNumber: task.attemptNumber, operationId: task.operationId, attempts: task.attempts.length,
17
+ legacyRunIds: task.legacyRunIds,
18
+ result: task.result ? { code: code(task.result.code ?? remote?.code),
19
+ ...(task.executionRef?.kind === 'remote-operation' ? { remote: {
20
+ status: ['succeeded', 'failed', 'cancelled', 'admitted', 'running', 'cancel_pending', 'unknown'].includes(remote?.status) ? remote.status : 'unknown',
21
+ stopConfirmed: remote?.stopConfirmed === true, possiblyApplied: remote?.possiblyApplied !== false,
22
+ ...(/^[a-f0-9]{64}$/.test(remote?.sha256 ?? '') ? { sha256: remote.sha256 } : {}),
23
+ ...(Number.isSafeInteger(remote?.bytes) ? { bytes: remote.bytes } : {}),
24
+ ...(Number.isSafeInteger(remote?.exitCode) ? { exitCode: remote.exitCode } : {}),
25
+ ...(remote?.notStarted === true ? { notStarted: true } : {}),
26
+ } } : {}),
27
+ } : null,
28
+ reconciliation: task.reconciliation ? { at: task.reconciliation.at, outcome: task.reconciliation.outcome } : null,
29
+ }
30
+ }
31
+
32
+ /** The service checks the Bot's committed task/read grant and the task's owner first. */
33
+ export function inspectProductTask({ service, journal, subject, taskId }) {
34
+ const task = service.get(subject, { taskId })
35
+ const history = journal.timeline(task.ownerBotId, task.taskId)
36
+ const attempts = task.attempts.map(row => ({ attemptId: row.id, number: row.number, state: row.state,
37
+ operationId: row.operation_id, startedAt: row.started_ms, finishedAt: row.finished_ms }))
38
+ const byId = new Map(attempts.map(row => [row.attemptId, row]))
39
+ return { task: projectProductTask(task), attempts, timeline: {
40
+ total: history.total, truncated: history.truncated,
41
+ items: history.items.map(row => {
42
+ const detail = JSON.parse(row.detail), attempt = byId.get(row.attempt_id)
43
+ return { seq: row.seq, kind: row.kind, at: row.at_ms, attemptId: row.attempt_id,
44
+ attemptNumber: attempt?.number ?? null, operationId: attempt?.operationId ?? null,
45
+ outcome: ['ready', 'running', 'succeeded', 'failed', 'cancelled', 'blocked', 'skipped', 'waiting-budget', 'needs-reconciliation', 'unknown'].includes(detail.outcome) ? detail.outcome : null,
46
+ code: code(detail.code) }
47
+ }),
48
+ } }
49
+ }
@@ -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
+ }