@yeaft/webchat-agent 1.0.531 → 1.0.532

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.
@@ -162,6 +162,15 @@ function sumExecutionStats(values) {
162
162
  }, emptyExecutionStats());
163
163
  }
164
164
 
165
+ function combinedExecutionStats(detail) {
166
+ const stats = Array.isArray(detail.runs) ? sumExecutionStats(detail.runs) : executionStats(detail.executionStats);
167
+ if (detail.executionControl?.usage) {
168
+ const usage = executionStats(detail.executionControl.usage);
169
+ for (const key of Object.keys(usage)) if (!['loopCount', 'toolCount'].includes(key)) stats[key] = usage[key];
170
+ }
171
+ return stats;
172
+ }
173
+
165
174
  function actionGeneration(value) {
166
175
  return Math.max(1, count(value) || 1);
167
176
  }
@@ -765,6 +774,7 @@ function enforceWorkItemBrowserDtoBudget(value, options = {}) {
765
774
  status: truncateUtf8(workItem.status, 256),
766
775
  currentActionId: truncateUtf8(workItem.currentActionId, 4 * 1024) || null,
767
776
  executionStats: workItem.executionStats,
777
+ executionControl: workItem.executionControl,
768
778
  actionCount: count(workItem.actionCount),
769
779
  actions: Array.isArray(workItem.actions) ? [] : undefined,
770
780
  actionStats: Array.isArray(workItem.actionStats) ? [] : undefined,
@@ -896,6 +906,39 @@ function workItemFailureReason(detail) {
896
906
  * Authenticated browser detail DTO. Raw execution records stay Agent-local;
897
907
  * the browser receives only aggregate execution stats plus the explicit user-facing response.
898
908
  */
909
+ function projectGoalProgress(progress) {
910
+ if (!progress || !Array.isArray(progress.criteria)) return null;
911
+ const ids = value => Array.isArray(value) ? value.slice(0, 64).map(id => truncateUtf8(String(id), 256)) : [];
912
+ const prioritized = [...progress.criteria.filter(item => item.status !== 'passed'),
913
+ ...progress.criteria.filter(item => item.status === 'passed')];
914
+ const criteria = prioritized.slice(0, 100).map(item => ({
915
+ criterion: truncateUtf8(item.criterion || '', 1024),
916
+ status: ['passed', 'failed'].includes(item.status) ? item.status : 'unmet',
917
+ evidenceRunIds: ids(item.evidenceRunIds),
918
+ ...(item.conflictingRunIds ? { conflictingRunIds: ids(item.conflictingRunIds) } : {}),
919
+ }));
920
+ return {
921
+ contractRevision: count(progress.contractRevision), criteria,
922
+ completedCriteriaCount: count(progress.completedCriteriaCount),
923
+ totalCriteriaCount: count(progress.totalCriteriaCount),
924
+ remainingCriteriaCount: Math.max(0, count(progress.totalCriteriaCount) - count(progress.completedCriteriaCount)),
925
+ omittedCriteriaCount: Math.max(0, progress.criteria.length - criteria.length),
926
+ remainingCriteria: criteria.filter(item => item.status !== 'passed').map(item => item.criterion),
927
+ evidenceRunIds: ids(progress.evidenceRunIds),
928
+ blockers: (progress.blockers || []).slice(0, 64).map(blocker => ({
929
+ actionId: truncateUtf8(blocker.actionId || '', 256),
930
+ status: blocker.status === 'failed' ? 'failed' : 'waiting',
931
+ reason: sanitizeDiagnosticText(blocker.reason || '', MAX_ACTION_DIAGNOSTIC_CHARS),
932
+ })),
933
+ delivery: {
934
+ target: ['response', 'workspace_files', 'pull_request', 'merge'].includes(progress.delivery?.target)
935
+ ? progress.delivery.target : null,
936
+ status: progress.delivery?.status === 'passed' ? 'passed' : 'unmet',
937
+ evidenceRunIds: ids(progress.delivery?.evidenceRunIds),
938
+ },
939
+ };
940
+ }
941
+
899
942
  export function projectWorkItemDetail(detail, options = {}) {
900
943
  if (!detail) return null;
901
944
  const liveActionId = bodyActionId(detail);
@@ -949,6 +992,12 @@ export function projectWorkItemDetail(detail, options = {}) {
949
992
  runId: truncateUtf8(rawOutput?.runId || '', 256) || null,
950
993
  };
951
994
  }).filter(output => output?.kind && output.label && output.ref) : [],
995
+ responses: Array.isArray(detail.finalResult.responses)
996
+ ? detail.finalResult.responses.slice(0, 24).map(response => ({
997
+ runId: truncateUtf8(response?.runId || '', 256),
998
+ summary: truncateUtf8(response?.summary || '', 8 * 1024),
999
+ evidence: projectCanonicalEvidence(response?.evidence),
1000
+ })) : [],
952
1001
  residualRisks: Array.isArray(detail.finalResult.residualRisks)
953
1002
  ? detail.finalResult.residualRisks
954
1003
  .map(risk => truncateUtf8(risk, MAX_ACTION_MESSAGE_CHARS)).slice(0, 24) : [],
@@ -956,6 +1005,7 @@ export function projectWorkItemDetail(detail, options = {}) {
956
1005
  title: detail.title,
957
1006
  goal: detail.goal,
958
1007
  acceptanceCriteria: Array.isArray(detail.acceptanceCriteria) ? detail.acceptanceCriteria : [],
1008
+ goalProgress: projectGoalProgress(detail.goalProgress),
959
1009
  workflowTemplate: detail.workflowTemplate,
960
1010
  workItemType: detail.workflowSnapshot?.workItemType || detail.workItemType || null,
961
1011
  planningMode: detail.workflowSnapshot?.planningMode || detail.planningMode || 'static',
@@ -967,11 +1017,10 @@ export function projectWorkItemDetail(detail, options = {}) {
967
1017
  attentionActionIds: Array.isArray(detail.attentionActionIds) ? detail.attentionActionIds : undefined,
968
1018
  mainline,
969
1019
  currentActionId: detail.currentActionId || null,
970
- executionStats: Array.isArray(detail.runs)
971
- ? sumExecutionStats(detail.runs)
972
- : executionStats(detail.executionStats),
1020
+ executionControl: detail.executionControl,
1021
+ executionStats: combinedExecutionStats(detail),
973
1022
  reuseMemory: detail.reuseMemory !== false,
974
- deliveryTarget: ['workspace_files', 'pull_request', 'merge'].includes(detail.deliveryTarget)
1023
+ deliveryTarget: ['response', 'workspace_files', 'pull_request', 'merge'].includes(detail.deliveryTarget)
975
1024
  ? detail.deliveryTarget : null,
976
1025
  waitingReason: sanitizeDiagnosticText(waitingReason(detail), MAX_ACTION_DIAGNOSTIC_CHARS),
977
1026
  failureReason: workItemFailureReason(detail),
@@ -1059,7 +1108,8 @@ export function projectWorkItemSummary(detail) {
1059
1108
  ? detail.actionStats.map(action => ({ ...action })) : [],
1060
1109
  actionCount: count(detail.actionCount),
1061
1110
  completedActionCount: count(detail.completedActionCount),
1062
- executionStats: executionStats(detail.executionStats),
1111
+ executionStats: combinedExecutionStats(detail),
1112
+ executionControl: detail.executionControl,
1063
1113
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
1064
1114
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
1065
1115
  attachmentCount: Array.isArray(detail.attachments) ? detail.attachments.length : 0,
@@ -1094,9 +1144,8 @@ export function projectWorkItemSummary(detail) {
1094
1144
  currentActionId: detail.currentActionId || null,
1095
1145
  actionCount: detail.actions.filter(item => !['superseded', 'cancelled'].includes(item?.status)).length,
1096
1146
  completedActionCount: detail.actions.filter(item => item?.status === 'completed').length,
1097
- executionStats: Array.isArray(detail.runs)
1098
- ? sumExecutionStats(detail.runs)
1099
- : executionStats(detail.executionStats),
1147
+ executionControl: detail.executionControl,
1148
+ executionStats: combinedExecutionStats(detail),
1100
1149
  failureReason: workItemFailureReason(detail),
1101
1150
 
1102
1151
  currentAction: projectCurrentActionSummary(action, projectedAction),
@@ -0,0 +1,460 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { LLMAdapter } from '../llm/adapter.js';
3
+ import { normalizeTokenUsage } from '../llm/usage-accounting.js';
4
+
5
+ // Work Center-local admission limits, not Engine or Session limits. Tokens are
6
+ // estimated before dispatch; this is not a guarantee about provider billing.
7
+ export const DEFAULT_EXECUTION_LIMITS = Object.freeze({
8
+ maxRequests: 200,
9
+ maxTokens: 2_000_000,
10
+ maxRunRequests: 40,
11
+ maxActionAttempts: 3,
12
+ maxCoordinatorFailures: 3,
13
+ });
14
+ const TOKEN_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens'];
15
+ const UNKNOWN_REQUEST_TOKENS = 16_384;
16
+ const parse = (value, fallback) => { try { return JSON.parse(value); } catch { return fallback; } };
17
+ const count = value => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
18
+ const emptyUsage = () => ({ llmRequestCount: 0, ...normalizeTokenUsage(), reservedTokens: 0,
19
+ chargedTokens: 0, unknownRequests: 0, inFlightRequests: 0 });
20
+
21
+ export function estimateRequestTokens(request = {}) {
22
+ // UTF-8 bytes / 3 is deliberately conservative for typical code/text, but is
23
+ // still an estimate (images, tokenizer and reasoning behavior vary by model).
24
+ const input = Math.ceil(Buffer.byteLength(JSON.stringify({ system: request.system,
25
+ messages: request.messages, tools: request.tools }), 'utf8') / 3);
26
+ return input + Math.max(1, count(request.maxTokens) || UNKNOWN_REQUEST_TOKENS);
27
+ }
28
+
29
+ export class WorkCenterResourceStopError extends Error {
30
+ constructor(reason) {
31
+ super(`Work Center execution stopped: ${reason?.code || 'needs_attention'}. Explicit user resume or budget extension required.`);
32
+ this.name = 'WorkCenterResourceStopError';
33
+ this.retryable = false;
34
+ this.workItemFailureKind = 'resource_limit';
35
+ this.workItemFailureCode = reason?.code || 'execution_stopped';
36
+ }
37
+ }
38
+
39
+ /** SQLite owns admission and settlement. Callers may already hold BEGIN IMMEDIATE. */
40
+ export class WorkCenterResourceControl {
41
+ constructor(store) {
42
+ this.store = store;
43
+ this.db = store.db;
44
+ this.db.exec(`
45
+ CREATE TABLE IF NOT EXISTS work_item_execution_controls (
46
+ work_item_id TEXT PRIMARY KEY REFERENCES work_items(id) ON DELETE CASCADE,
47
+ limits_json TEXT NOT NULL, stop_reason TEXT, coordinator_failures INTEGER NOT NULL DEFAULT 0,
48
+ retry_after INTEGER NOT NULL DEFAULT 0,
49
+ revision INTEGER NOT NULL DEFAULT 1, action_attempts_extension INTEGER NOT NULL DEFAULT 0,
50
+ data_revision INTEGER NOT NULL DEFAULT 1, projection_hash TEXT
51
+ );
52
+ CREATE TABLE IF NOT EXISTS work_item_action_attempt_limits (
53
+ action_id TEXT PRIMARY KEY REFERENCES actions(id) ON DELETE CASCADE,
54
+ original_max_attempts INTEGER NOT NULL
55
+ );
56
+ CREATE TRIGGER IF NOT EXISTS work_item_action_attempt_limit_insert
57
+ AFTER INSERT ON actions BEGIN
58
+ INSERT OR IGNORE INTO work_item_action_attempt_limits (action_id, original_max_attempts)
59
+ VALUES (NEW.id, MAX(0, NEW.max_attempts));
60
+ END;
61
+ CREATE TABLE IF NOT EXISTS work_item_resource_requests (
62
+ id TEXT PRIMARY KEY, work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
63
+ kind TEXT NOT NULL, run_id TEXT, request_count INTEGER NOT NULL DEFAULT 1,
64
+ estimated_tokens INTEGER NOT NULL, charged_tokens INTEGER NOT NULL,
65
+ usage_json TEXT, status TEXT NOT NULL, created_at INTEGER NOT NULL
66
+ );
67
+ CREATE INDEX IF NOT EXISTS resource_requests_work_item ON work_item_resource_requests(work_item_id, run_id);
68
+ CREATE TRIGGER IF NOT EXISTS work_item_execution_stop_status
69
+ AFTER UPDATE OF status ON work_items
70
+ WHEN NEW.status NOT IN ('needs_attention', 'cancelled') AND EXISTS (
71
+ SELECT 1 FROM work_item_execution_controls c WHERE c.work_item_id = NEW.id AND c.stop_reason IS NOT NULL
72
+ ) BEGIN UPDATE work_items SET status = 'needs_attention' WHERE id = NEW.id; END;
73
+ `);
74
+ // Additive migration from the initial ledger schema; never change the goal
75
+ // contract revision to represent resource administration.
76
+ this.atomic(() => {
77
+ const columns = this.db.prepare('PRAGMA table_info(work_item_execution_controls)').all().map(row => row.name);
78
+ if (!columns.includes('revision')) this.db.exec('ALTER TABLE work_item_execution_controls ADD COLUMN revision INTEGER NOT NULL DEFAULT 1');
79
+ if (!columns.includes('action_attempts_extension')) this.db.exec('ALTER TABLE work_item_execution_controls ADD COLUMN action_attempts_extension INTEGER NOT NULL DEFAULT 0');
80
+ if (!columns.includes('data_revision')) this.db.exec('ALTER TABLE work_item_execution_controls ADD COLUMN data_revision INTEGER NOT NULL DEFAULT 1');
81
+ if (!columns.includes('projection_hash')) this.db.exec('ALTER TABLE work_item_execution_controls ADD COLUMN projection_hash TEXT');
82
+ this.db.exec(`INSERT OR IGNORE INTO work_item_action_attempt_limits (action_id, original_max_attempts)
83
+ SELECT id, MAX(0, max_attempts) FROM actions`);
84
+ // Snapshot pre-ledger data once. Reopening never imports a Run twice.
85
+ for (const item of this.db.prepare(`SELECT id FROM work_items WHERE id NOT IN
86
+ (SELECT work_item_id FROM work_item_execution_controls)`).all()) this.ensure(item.id);
87
+ });
88
+ }
89
+
90
+ atomic(fn) {
91
+ if (this.db.isTransaction) return fn();
92
+ this.db.exec('BEGIN IMMEDIATE');
93
+ try { const result = fn(); this.db.exec('COMMIT'); return result; }
94
+ catch (error) { this.db.exec('ROLLBACK'); throw error; }
95
+ }
96
+
97
+ ensure(id) {
98
+ const existing = this.db.prepare('SELECT * FROM work_item_execution_controls WHERE work_item_id = ?').get(id);
99
+ if (existing) return existing;
100
+ this.db.prepare('INSERT INTO work_item_execution_controls (work_item_id, limits_json) VALUES (?, ?)')
101
+ .run(id, JSON.stringify(DEFAULT_EXECUTION_LIMITS));
102
+ for (const run of this.db.prepare('SELECT * FROM runs WHERE work_item_id = ?').all(id)) {
103
+ const turns = this.db.prepare(`SELECT COUNT(*) AS n,
104
+ MAX(status IN ('dispatching', 'unknown')) AS unknown FROM engine_turns
105
+ WHERE run_id = ? AND status != 'prepared'`).get(run.id);
106
+ // Aggregate Run usage may only cover earlier responses. A later ambiguous
107
+ // dispatch must retain its estimate even when the Run is already terminal.
108
+ const unknown = ['running', 'dispatch_unknown'].includes(run.status) || !!turns.unknown;
109
+ const requests = Math.max(count(run.llm_request_count), count(turns.n), unknown ? 1 : 0);
110
+ if (!requests && !run.total_tokens) continue;
111
+ const usage = normalizeTokenUsage({ inputTokens: run.input_tokens, outputTokens: run.output_tokens,
112
+ cacheReadTokens: run.cache_read_tokens, cacheWriteTokens: run.cache_write_tokens, totalTokens: run.total_tokens });
113
+ const estimate = unknown ? Math.max(usage.totalTokens, requests * UNKNOWN_REQUEST_TOKENS)
114
+ : usage.totalTokens || requests * UNKNOWN_REQUEST_TOKENS;
115
+ this.insert({ id: `legacy-run:${run.id}`, workItemId: id, kind: 'action', runId: run.id,
116
+ requests, estimate, usage, status: usage.totalTokens && !unknown ? 'reported' : 'unknown' });
117
+ }
118
+ for (const turn of this.db.prepare(`SELECT * FROM coordinator_provider_turns WHERE work_item_id = ?
119
+ AND status != 'prepared'`).all(id)) {
120
+ const response = parse(turn.response, {});
121
+ const usage = response?.usage ? normalizeTokenUsage(response.usage) : null;
122
+ this.insert({ id: turn.id, workItemId: id, kind: 'coordinator',
123
+ estimate: estimateRequestTokens(parse(turn.request_body, {})), usage,
124
+ status: usage ? 'reported' : 'unknown' });
125
+ }
126
+ return this.db.prepare('SELECT * FROM work_item_execution_controls WHERE work_item_id = ?').get(id);
127
+ }
128
+
129
+ insert({ id, workItemId, kind, runId = null, requests = 1, estimate, usage = null, status = 'reserved' }) {
130
+ this.db.prepare(`INSERT INTO work_item_resource_requests
131
+ (id, work_item_id, kind, run_id, request_count, estimated_tokens, charged_tokens, usage_json, status, created_at)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, workItemId, kind, runId, requests, estimate,
133
+ status === 'reported' ? usage.totalTokens : Math.max(estimate, usage?.totalTokens || 0),
134
+ usage ? JSON.stringify(usage) : null, status, this.store.now());
135
+ this.advanceDataRevision(workItemId);
136
+ }
137
+
138
+ advanceDataRevision(id) {
139
+ // Independent of the user-command CAS and goal revision. Null invalidates
140
+ // the projection fingerprint: this mutation already advanced its version.
141
+ this.db.prepare(`UPDATE work_item_execution_controls SET data_revision = data_revision + 1,
142
+ projection_hash = NULL WHERE work_item_id = ?`).run(id);
143
+ }
144
+
145
+ snapshot(id) {
146
+ return this.atomic(() => this.projectSnapshot(id));
147
+ }
148
+
149
+ projectSnapshot(id) {
150
+ const control = this.ensure(id);
151
+ const now = this.store.now();
152
+ const breakdown = { coordinator: emptyUsage(), actions: emptyUsage() };
153
+ for (const row of this.db.prepare('SELECT * FROM work_item_resource_requests WHERE work_item_id = ?').all(id)) {
154
+ const target = row.kind === 'coordinator' ? breakdown.coordinator : breakdown.actions;
155
+ const usage = parse(row.usage_json, {}) || {};
156
+ target.llmRequestCount += row.request_count;
157
+ for (const key of TOKEN_KEYS) target[key] += count(usage[key]);
158
+ target.chargedTokens += row.charged_tokens;
159
+ if (row.status !== 'reported') target.reservedTokens += row.charged_tokens;
160
+ if (row.status === 'unknown') target.unknownRequests += row.request_count;
161
+ if (row.status === 'reserved') {
162
+ const live = row.run_id
163
+ ? this.db.prepare("SELECT 1 FROM runs WHERE id = ? AND status = 'running' AND expires_at > ?").get(row.run_id, now)
164
+ : this.db.prepare("SELECT 1 FROM coordinator_provider_turns WHERE id = ? AND status = 'dispatching'").get(row.id.split(':retry:')[0]);
165
+ if (live) target.inFlightRequests += row.request_count;
166
+ else target.unknownRequests += row.request_count;
167
+ }
168
+ }
169
+ const usage = emptyUsage();
170
+ for (const part of Object.values(breakdown)) for (const key of Object.keys(usage)) usage[key] += part[key];
171
+ const limits = parse(control.limits_json, DEFAULT_EXECUTION_LIMITS);
172
+ const actionAttempts = this.db.prepare(`SELECT a.id, l.original_max_attempts,
173
+ (SELECT COUNT(*) FROM runs r WHERE r.action_id = a.id) AS attempts
174
+ FROM actions a JOIN work_item_action_attempt_limits l ON l.action_id = a.id
175
+ WHERE a.work_item_id = ? ORDER BY a.sequence, a.id`).all(id).map(row => ({
176
+ actionId: row.id, attempts: row.attempts, originalMaxAttempts: row.original_max_attempts,
177
+ effectiveMaxAttempts: Math.min(row.original_max_attempts + control.action_attempts_extension, limits.maxActionAttempts),
178
+ }));
179
+ const snapshot = { revision: control.revision, limits, usage,
180
+ actionAttemptsExtension: control.action_attempts_extension, actionAttempts,
181
+ stopReason: parse(control.stop_reason, null), breakdown,
182
+ coordinatorFailures: control.coordinator_failures, retryAfter: control.retry_after,
183
+ tokenAccounting: 'estimated_admission_reported_usage_unknown_retained' };
184
+ // Attempts and in-flight/unknown classification are derived from other
185
+ // ledgers (and lease expiry). Version each observed change durably under the
186
+ // same write transaction, including changes without a reserve or settle.
187
+ const hash = createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
188
+ const dataRevision = control.data_revision + (control.projection_hash != null && control.projection_hash !== hash ? 1 : 0);
189
+ if (control.projection_hash !== hash) {
190
+ this.db.prepare('UPDATE work_item_execution_controls SET data_revision = ?, projection_hash = ? WHERE work_item_id = ?')
191
+ .run(dataRevision, hash, id);
192
+ }
193
+ return { ...snapshot, dataRevision };
194
+ }
195
+
196
+ stopped(id) {
197
+ return !!this.db.prepare('SELECT stop_reason FROM work_item_execution_controls WHERE work_item_id = ?').get(id)?.stop_reason;
198
+ }
199
+
200
+ stop(id, code, details = {}) {
201
+ return this.atomic(() => {
202
+ this.ensure(id);
203
+ const reason = { code, at: this.store.now(), ...details };
204
+ const changed = this.db.prepare(`UPDATE work_item_execution_controls SET stop_reason = ?, revision = revision + 1
205
+ WHERE work_item_id = ? AND stop_reason IS NULL`).run(JSON.stringify(reason), id);
206
+ if (changed.changes) {
207
+ this.advanceDataRevision(id);
208
+ this.db.prepare(`UPDATE work_items SET status = CASE WHEN status = 'cancelled' THEN status ELSE 'needs_attention' END,
209
+ updated_at = ? WHERE id = ?`).run(this.store.now(), id);
210
+ this.store.appendEvent(id, 'work_item.execution_stopped', reason);
211
+ }
212
+ return this.snapshot(id).stopReason;
213
+ });
214
+ }
215
+
216
+ reserve({ id = randomUUID(), workItemId, kind, runId = null, request = {} }) {
217
+ // Return denial rather than throwing inside the transaction: the stop must
218
+ // commit even though the caller must throw before touching the provider.
219
+ return this.atomic(() => {
220
+ const snapshot = this.snapshot(workItemId);
221
+ const previous = this.db.prepare('SELECT * FROM work_item_resource_requests WHERE id = ?').get(id);
222
+ if (previous) return { allowed: false, reason: { code: 'request_already_reserved' } };
223
+ if (snapshot.stopReason) return { allowed: false, reason: snapshot.stopReason };
224
+ const item = this.db.prepare('SELECT status FROM work_items WHERE id = ?').get(workItemId);
225
+ if (['done', 'cancelled'].includes(item?.status)) return { allowed: false, reason: { code: 'work_item_inactive' } };
226
+ const estimate = estimateRequestTokens(request);
227
+ const runRequests = runId ? this.db.prepare(`SELECT COALESCE(SUM(request_count), 0) AS n
228
+ FROM work_item_resource_requests WHERE run_id = ?`).get(runId).n : 0;
229
+ const code = runId && runRequests >= snapshot.limits.maxRunRequests ? 'run_requests_exhausted'
230
+ : snapshot.usage.llmRequestCount >= snapshot.limits.maxRequests ? 'work_item_requests_exhausted'
231
+ : snapshot.usage.chargedTokens + estimate > snapshot.limits.maxTokens ? 'work_item_tokens_exhausted' : null;
232
+ if (code) return { allowed: false, reason: this.stop(workItemId, code, { runId, estimatedNextTokens: estimate }) };
233
+ this.insert({ id, workItemId, kind, runId, estimate });
234
+ return { allowed: true, id };
235
+ });
236
+ }
237
+
238
+ settle(id, rawUsage, complete = true) {
239
+ return this.atomic(() => {
240
+ const row = this.db.prepare('SELECT * FROM work_item_resource_requests WHERE id = ?').get(id);
241
+ if (!row || row.status === 'reported' || (row.status === 'unknown' && !rawUsage)) return false;
242
+ const usage = rawUsage ? normalizeTokenUsage(rawUsage) : null;
243
+ const reported = complete && usage && usage.totalTokens > 0;
244
+ const charged = reported ? usage.totalTokens : Math.max(row.estimated_tokens, usage?.totalTokens || 0);
245
+ this.db.prepare(`UPDATE work_item_resource_requests SET usage_json = ?, charged_tokens = ?, status = ? WHERE id = ?`)
246
+ .run(usage ? JSON.stringify(usage) : row.usage_json, charged, reported ? 'reported' : 'unknown', id);
247
+ this.advanceDataRevision(row.work_item_id);
248
+ const snapshot = this.snapshot(row.work_item_id);
249
+ if (snapshot.usage.chargedTokens > snapshot.limits.maxTokens) this.stop(row.work_item_id, 'work_item_tokens_exhausted');
250
+ return true;
251
+ });
252
+ }
253
+
254
+ coordinatorRequestIds(turnId) {
255
+ return this.db.prepare(`SELECT id FROM work_item_resource_requests WHERE id = ? OR id LIKE ? ORDER BY rowid DESC`)
256
+ .all(turnId, `${turnId}:retry:%`).map(row => row.id);
257
+ }
258
+
259
+ settleCoordinator(turnId, usage, complete = true) {
260
+ const [latest, ...earlier] = this.coordinatorRequestIds(turnId);
261
+ for (const id of earlier) this.settle(id, null, false);
262
+ return latest ? this.settle(latest, usage, complete) : false;
263
+ }
264
+
265
+ canAttempt(action) {
266
+ const entry = this.snapshot(action.workItemId).actionAttempts.find(row => row.actionId === action.id);
267
+ if (entry && entry.attempts < entry.effectiveMaxAttempts) return true;
268
+ this.stop(action.workItemId, 'action_attempts_exhausted', entry || { actionId: action.id });
269
+ return false;
270
+ }
271
+
272
+ coordinatorFailed(id, turnId) {
273
+ return this.atomic(() => {
274
+ const snapshot = this.snapshot(id);
275
+ const failures = snapshot.coordinatorFailures + 1;
276
+ this.db.prepare(`UPDATE work_item_execution_controls SET coordinator_failures = ?, retry_after = ? WHERE work_item_id = ?`)
277
+ .run(failures, this.store.now() + Math.min(1_000 * (2 ** Math.min(failures - 1, 8)), 300_000), id);
278
+ this.advanceDataRevision(id);
279
+ if (failures >= snapshot.limits.maxCoordinatorFailures) {
280
+ this.stop(id, 'coordinator_failures_exhausted', { turnId, failures });
281
+ }
282
+ });
283
+ }
284
+
285
+ /** Only an authenticated user command may call this; no model/tool path. */
286
+ extend(id, revision, additions = {}) {
287
+ return this.atomic(() => {
288
+ if (!this.store.getWorkItem(id)) throw new Error(`WorkItem not found: ${id}`);
289
+ this.assertRevision(id, revision);
290
+ if (!additions || typeof additions !== 'object' || Array.isArray(additions)
291
+ || Object.keys(additions).length === 0) throw new Error('Invalid execution budget additions');
292
+ const snapshot = this.snapshot(id);
293
+ const limits = { ...snapshot.limits };
294
+ for (const [key, value] of Object.entries(additions)) {
295
+ if (!Object.hasOwn(limits, key) || !Number.isSafeInteger(value) || value <= 0
296
+ || !Number.isSafeInteger(limits[key] + value)) throw new Error(`Invalid execution budget addition: ${key}`);
297
+ limits[key] += value;
298
+ }
299
+ this.db.prepare(`UPDATE work_item_execution_controls SET limits_json = ?, revision = revision + 1,
300
+ action_attempts_extension = action_attempts_extension + ? WHERE work_item_id = ? AND revision = ?`)
301
+ .run(JSON.stringify(limits), additions.maxActionAttempts || 0, id, revision);
302
+ this.advanceDataRevision(id);
303
+ this.db.prepare('UPDATE work_items SET updated_at = ? WHERE id = ?').run(this.store.now(), id);
304
+ this.store.appendEvent(id, 'work_item.execution_budget_extended', { additions, limits });
305
+ return this.store.getWorkItemDetail(id);
306
+ });
307
+ }
308
+
309
+ assertRevision(id, revision) {
310
+ if (!Number.isSafeInteger(revision) || this.ensure(id).revision !== revision) {
311
+ throw new Error('Execution control changed; refresh before changing execution budget or resuming');
312
+ }
313
+ }
314
+
315
+ resume(id, revision) {
316
+ if (revision !== undefined || this.stopped(id)) this.assertRevision(id, revision);
317
+ const snapshot = this.snapshot(id);
318
+ if (snapshot.usage.llmRequestCount >= snapshot.limits.maxRequests
319
+ || snapshot.usage.chargedTokens >= snapshot.limits.maxTokens
320
+ || snapshot.coordinatorFailures >= snapshot.limits.maxCoordinatorFailures) {
321
+ throw new Error('Extend the exhausted WorkItem execution budget before resuming');
322
+ }
323
+ if (snapshot.stopReason?.code === 'action_attempts_exhausted') {
324
+ const action = this.store.getAction(snapshot.stopReason.actionId);
325
+ if (action && !this.canAttempt(action)) throw new Error('Extend maxActionAttempts before resuming');
326
+ }
327
+ this.db.prepare(`UPDATE work_item_execution_controls SET stop_reason = NULL, retry_after = 0,
328
+ revision = revision + 1, data_revision = data_revision + 1, projection_hash = NULL WHERE work_item_id = ?`).run(id);
329
+ }
330
+ }
331
+
332
+ /** Fail-closed, Work Center-only adapter. Every native callback runs immediately
333
+ * before fetch; plain legacy adapters reserve at invocation/iteration instead.
334
+ * Reservations survive crash/abort/missing usage and never become free retries.
335
+ */
336
+ export class WorkCenterResourceAdapter extends LLMAdapter {
337
+ constructor(adapter, store, workItemId, runId) {
338
+ super(adapter?.config || {});
339
+ this.adapter = adapter;
340
+ this.store = store;
341
+ this.workItemId = workItemId;
342
+ this.runId = runId;
343
+ }
344
+
345
+ captureRequest() {
346
+ const captured = this.adapter.captureRequest?.();
347
+ const capture = captured?.captureStream || this.adapter.captureStream?.bind(this.adapter)
348
+ || this.adapter.stream.bind(this.adapter);
349
+ const native = this.adapter instanceof LLMAdapter;
350
+ return { captureStream: params => this.accountStream(capture, params, native) };
351
+ }
352
+ captureStream(params) { return this.captureRequest().captureStream(params); }
353
+ stream(params) { return this.captureStream(params); }
354
+
355
+ request(params) {
356
+ let reservation = null;
357
+ let usage = null;
358
+ let suppressFirstCallback = false;
359
+ const settle = (reportedUsage, complete) => {
360
+ if (reservation?.allowed) this.store.settleWorkItemRequest(reservation.id, reportedUsage ?? usage, complete);
361
+ };
362
+ const start = () => {
363
+ settle(usage, false);
364
+ reservation = null;
365
+ usage = null;
366
+ // Admission precedes Engine dispatch marking: a denied request must not
367
+ // become a dispatch-unknown Run. If the subsequent lease fence fails the
368
+ // reservation remains conservatively charged (no free ambiguous retry).
369
+ if (this.store.isExecutionStopped(this.workItemId)) {
370
+ throw new WorkCenterResourceStopError(this.store.getExecutionControl(this.workItemId).stopReason);
371
+ }
372
+ reservation = this.store.reserveWorkItemRequest({ workItemId: this.workItemId,
373
+ kind: 'action', runId: this.runId, request: params });
374
+ if (!reservation.allowed) throw new WorkCenterResourceStopError(reservation.reason);
375
+ params.onRequestStart?.();
376
+ };
377
+ return { params: { ...params, onRequestStart: () => {
378
+ if (suppressFirstCallback) {
379
+ suppressFirstCallback = false;
380
+ if (this.store.isExecutionStopped(this.workItemId)) {
381
+ throw new WorkCenterResourceStopError(this.store.getExecutionControl(this.workItemId).stopReason);
382
+ }
383
+ // Early legacy admission is not the last dispatch boundary. Recheck the
384
+ // original EngineTurn/Run lease without reserving or counting it again.
385
+ params.onRequestStart?.();
386
+ return;
387
+ }
388
+ start();
389
+ } }, start: () => { start(); suppressFirstCallback = true; },
390
+ addUsage: event => {
391
+ usage ||= normalizeTokenUsage();
392
+ const next = normalizeTokenUsage(event);
393
+ for (const key of TOKEN_KEYS) usage[key] += next[key];
394
+ }, settle };
395
+ }
396
+
397
+ async *accountStream(capture, params, native) {
398
+ const request = this.request(params);
399
+ let complete = false;
400
+ try {
401
+ if (!native) request.start();
402
+ for await (const event of capture(request.params)) {
403
+ if (event?.type === 'usage') request.addUsage(event);
404
+ yield event;
405
+ }
406
+ complete = true;
407
+ } finally { request.settle(null, complete); }
408
+ }
409
+ async call(params) {
410
+ const request = this.request(params);
411
+ let usage = null;
412
+ let complete = false;
413
+ try {
414
+ // Side calls also count, including any future Engine helper calls.
415
+ if (!(this.adapter instanceof LLMAdapter)) request.start();
416
+ const result = await this.adapter.call(request.params);
417
+ usage = result?.usage;
418
+ complete = true;
419
+ return result;
420
+ } finally { request.settle(usage, complete); }
421
+ }
422
+ getProviderForModel(model) { return this.adapter.getProviderForModel?.(model) || null; }
423
+ listAvailableModels() { return this.adapter.listAvailableModels?.() || []; }
424
+ }
425
+
426
+ /** Native adapters own the last pre-fetch callback; plain legacy adapters are
427
+ * conservatively admitted before invocation even if they omit the callback. */
428
+ export async function callCoordinatorWithResourceControl(adapter, store, turn, claim, params) {
429
+ const native = adapter instanceof LLMAdapter;
430
+ let suppressFirstCallback = !native;
431
+ const start = (revalidate = false) => {
432
+ const active = revalidate ? store.isActiveCoordinatorProviderTurn(turn.id, claim)
433
+ : store.dispatchCoordinatorProviderTurn(turn.id, claim);
434
+ if (!active) {
435
+ const error = new Error('Coordinator provider turn lost its dispatch fence or execution budget');
436
+ error.retryable = false;
437
+ throw error;
438
+ }
439
+ };
440
+ try {
441
+ if (!native) start();
442
+ const response = await adapter.call({ ...params, onRequestStart: () => {
443
+ if (suppressFirstCallback) {
444
+ suppressFirstCallback = false;
445
+ // A resumed WorkItem may be active while this pre-stop claim is stale.
446
+ // Revalidate it without treating the first callback as a paid retry.
447
+ start(true);
448
+ return;
449
+ }
450
+ start();
451
+ } });
452
+ // Even a late response after cancellation is real consumption. Response CAS
453
+ // still fences its decision, while idempotent settlement keeps its usage.
454
+ store.settleCoordinatorRequest(turn.id, response?.usage);
455
+ return response;
456
+ } catch (error) {
457
+ store.settleCoordinatorRequest(turn.id, null, false);
458
+ throw error;
459
+ }
460
+ }