@yeaft/webchat-agent 1.0.530 → 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.
@@ -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
+ }
@@ -17,6 +17,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { sessionMessageQuotePrompt } from '../session-message-quote.js';
19
19
  import { buildWorkItemAttachmentContext } from './attachments.js';
20
+ import { WorkCenterResourceAdapter } from './resource-control.js';
20
21
  import { withUsageAccounting } from '../llm/usage-accounting.js';
21
22
  import {
22
23
  commitActionWorktree,
@@ -51,20 +52,7 @@ import {
51
52
  renderMainlineContextSnapshot,
52
53
  } from './mainline-projection.js';
53
54
 
54
- const WORK_ITEM_TOOL_NAMES = Object.freeze([
55
- 'FileRead',
56
- 'FileWrite',
57
- 'FileEdit',
58
- 'ApplyPatch',
59
- 'Glob',
60
- 'Grep',
61
- 'ListDir',
62
- 'Bash',
63
- 'WebSearch',
64
- 'WebFetch',
65
- 'ViewImage',
66
- 'Skill',
67
- ]);
55
+ import { WORK_ITEM_TOOL_NAMES, workItemBuiltinToolNames } from './capabilities.js';
68
56
  const WORK_ITEM_TOOL_ALLOWLIST = new Set(WORK_ITEM_TOOL_NAMES);
69
57
  const DEFAULT_PROGRESS_INTERVAL_MS = 200;
70
58
  const ACTION_INPUT_QUOTE_MAX_BYTES = 8 * 1024;
@@ -272,7 +260,7 @@ function assertToolInput(toolName, input, workDir, attachmentFiles) {
272
260
 
273
261
  export function workItemToolPolicySnapshot(workDir, attachmentRefs = [], extraToolNames = []) {
274
262
  const hasAttachments = attachmentRefs.length > 0;
275
- const builtInTools = WORK_ITEM_TOOL_NAMES.filter(name => !hasAttachments || name !== 'Bash');
263
+ const builtInTools = workItemBuiltinToolNames(hasAttachments);
276
264
  return {
277
265
  policyVersion: 1,
278
266
  allowedToolNames: [...builtInTools, ...extraToolNames],
@@ -427,7 +415,7 @@ export function createSubmitWorkItemPlanTool({
427
415
  properties: {
428
416
  summary: { type: 'string', minLength: 1, maxLength: 2_000 },
429
417
  evidence: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1_000 } },
430
- acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
418
+ acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'failed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
431
419
  contractPatch: { type: 'object', additionalProperties: false, required: ['title', 'goal', 'acceptanceCriteria'], properties: { title: { type: 'string', minLength: 1, maxLength: 200 }, goal: { type: 'string', minLength: 1, maxLength: 8_000 }, acceptanceCriteria: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1, maxLength: 2_000 } } } },
432
420
  workItemType: { type: 'string', minLength: 1, maxLength: 64 },
433
421
  actions: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'object', additionalProperties: false, required: ['id', 'name', 'type', 'objective', 'approach', 'expectedOutcome', 'candidateVpIds', 'assignmentReason', 'dependsOnActionIds', 'workspaceMode'], properties: {
@@ -483,7 +471,7 @@ function terminalPlanningFields(options = {}) {
483
471
  return {
484
472
  summary: { type: 'string', minLength: 1, maxLength: 2_000 },
485
473
  evidence: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1_000 } },
486
- acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
474
+ acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'failed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
487
475
  ...(options.review === true ? {
488
476
  reviewDecision: { type: 'string', const: 'changes_requested' },
489
477
  } : {}),
@@ -748,7 +736,7 @@ function completionContract(action, workItem) {
748
736
  : '';
749
737
  const acceptanceChecks = (workItem?.acceptanceCriteria || []).map(criterion => ({
750
738
  criterion,
751
- status: 'passed|deferred|not_applicable',
739
+ status: 'passed|failed|deferred|not_applicable',
752
740
  evidence: 'specific evidence reference',
753
741
  }));
754
742
  return `${toolSubmission}\n\nYou are executing one Work Center Action. Before the terminal JSON, write a concise user-facing response describing what you did and the result. Do not include raw tool output or secrets. End your response with exactly one JSON object, preferably in a json code fence:\n{
@@ -759,7 +747,7 @@ function completionContract(action, workItem) {
759
747
  "acceptanceChecks": ${JSON.stringify(acceptanceChecks)},
760
748
  "waitingReason": null,
761
749
  "error": null${reviewField}${triageField}${planField}
762
- }\nFor completed, provide at least one concrete evidence item and exactly one acceptanceChecks entry for every current acceptance criterion, in the same order, with status passed, deferred, or not_applicable and a non-empty evidence reference. Report every user-consumable file, URL, PR, or commit in outputs; evidence proves work, while outputs tell the user where the deliverable is. Triage must use its proposed criteria when submitting a contractPatch. An intermediate Action may defer criteria outside its task-specific expected result; the final deliver Action, and an approved review with no downstream work, require every criterion to pass. If a criterion is no longer applicable, ask the WorkItem Coordinator to revise the contract instead of pretending it passed. This is a deterministic submission gate, not independent proof: later verification and delivery Actions must verify the claims. A model turn ending is not completion. Use waiting when user or external input is required. Use retryable only for a transient failure. Do not start background jobs or delegate this Action.`;
750
+ }\nFor completed, provide at least one concrete evidence item and exactly one acceptanceChecks entry for every current acceptance criterion, in the same order, with status passed, failed, deferred, or not_applicable and a non-empty evidence reference. Report every user-consumable file, URL, PR, or commit in outputs; evidence proves work, while outputs tell the user where the deliverable is. Triage must use its proposed criteria when submitting a contractPatch. An intermediate Action may defer criteria outside its task-specific expected result; the final deliver Action, and an approved review with no downstream work, require every criterion to pass. If a criterion is no longer applicable, request user confirmation of any contract change instead of pretending it passed. For response delivery, provide the user-facing conclusion in summary with concrete evidence; no artificial file or PR is required. This is a deterministic submission gate, not independent proof: later verification and delivery Actions must verify the claims. A model turn ending is not completion. Use waiting when user or external input is required. Use retryable only for a transient failure. Do not start background jobs or delegate this Action.`;
763
751
  }
764
752
 
765
753
  function safeCheckpointUrl(value) {
@@ -1163,6 +1151,7 @@ export class WorkItemRunner {
1163
1151
  )
1164
1152
  : null;
1165
1153
  const isRunActive = () => !signal.aborted
1154
+ && !this.store.isExecutionStopped?.(workItem.id)
1166
1155
  && this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
1167
1156
  const workspaceRuntime = await this.#workspaceRuntime(workspaceDir, workDir, isRunActive);
1168
1157
  const mcpToolNames = workspaceRuntime.mcpTools.map(tool => tool.name);
@@ -1337,7 +1326,8 @@ export class WorkItemRunner {
1337
1326
  return onProgress(currentProgress());
1338
1327
  };
1339
1328
  if (typeof registerProgressReader === 'function') registerProgressReader(currentProgress);
1340
- const adapter = withUsageAccounting(runtime.adapter, usage => {
1329
+ const adapter = withUsageAccounting(
1330
+ new WorkCenterResourceAdapter(runtime.adapter, this.store, workItem.id, run.id), usage => {
1341
1331
  usageStats.inputTokens += usage.inputTokens;
1342
1332
  usageStats.outputTokens += usage.outputTokens;
1343
1333
  usageStats.cacheReadTokens += usage.cacheReadTokens;
@@ -247,13 +247,16 @@ export class WorkCenterService {
247
247
  workItemId,
248
248
  });
249
249
  const shouldStart = payload.start === undefined ? settings.startImmediately : payload.start !== false;
250
+ const goal = requiredString(payload.goal, 'goal');
251
+ const requestedCriteria = Array.isArray(payload.acceptanceCriteria)
252
+ ? payload.acceptanceCriteria.map(value => String(value).trim()).filter(Boolean) : [];
250
253
  this.controller.create({
251
254
  id: workItemId,
252
255
  title: requiredString(payload.title, 'title'),
253
- goal: requiredString(payload.goal, 'goal'),
254
- acceptanceCriteria: Array.isArray(payload.acceptanceCriteria)
255
- ? payload.acceptanceCriteria.map(value => String(value).trim()).filter(Boolean)
256
- : [],
256
+ goal,
257
+ // With no separate criteria, the user's goal itself is the minimum
258
+ // contract. Do not force a follow-up or invent broader requirements.
259
+ acceptanceCriteria: requestedCriteria.length ? requestedCriteria : [goal],
257
260
  workflowTemplate,
258
261
  workflowSnapshot,
259
262
  coordinationMode: DYNAMIC_COORDINATION_MODE,
@@ -263,7 +266,7 @@ export class WorkCenterService {
263
266
  // browser/user request. Trusted model producers may provide
264
267
  // Session provenance, but cannot grant themselves delivery rights.
265
268
  deliveryTarget: requestContext.userOriginated === true
266
- && ['workspace_files', 'pull_request', 'merge'].includes(payload.deliveryTarget)
269
+ && ['response', 'workspace_files', 'pull_request', 'merge'].includes(payload.deliveryTarget)
267
270
  ? payload.deliveryTarget : null,
268
271
  reuseMemory: payload.reuseMemory !== false,
269
272
  origin: payload.origin && typeof payload.origin === 'object'
@@ -320,9 +323,21 @@ export class WorkCenterService {
320
323
  this.#emit({ type: 'work_item.cancelled', workItem: detail });
321
324
  return detail;
322
325
  }
326
+ case 'extend_budget': {
327
+ if (requestContext.userOriginated !== true) throw new Error('Only explicit user requests can extend execution budget');
328
+ const id = requiredString(payload.id, 'id');
329
+ const detail = this.controller.extendBudget(id, payload);
330
+ this.#emit({ type: 'work_item.execution_budget_extended', workItem: detail });
331
+ return detail;
332
+ }
323
333
  case 'resume': {
334
+ if (requestContext.userOriginated !== true) throw new Error('Only explicit user requests can resume execution');
324
335
  const id = requiredString(payload.id, 'id');
325
- const detail = this.controller.resume(id, { revision: payload.revision });
336
+ if (!Number.isSafeInteger(payload.executionControlRevision)) {
337
+ throw new Error('executionControlRevision is required to resume a WorkItem');
338
+ }
339
+ const detail = this.controller.resume(id, { revision: payload.revision,
340
+ executionControlRevision: payload.executionControlRevision });
326
341
  this.watcher.abortInvalidWorkItemRuns(id);
327
342
  if (detail.coordinationMode === DYNAMIC_COORDINATION_MODE) {
328
343
  this.#queueDynamicCoordinatorWake(id);
@@ -633,7 +648,8 @@ export class WorkCenterService {
633
648
  const entry = entries.find(candidate => candidate.payload?.turnId) || entries[0];
634
649
  if (!entry) return null;
635
650
  const detail = this.store.getWorkItemDetail(workItemId);
636
- if (detail?.actions?.some(action => action.status === 'running')) return null;
651
+ if (!this.store.canAutomaticallyCoordinate(workItemId)
652
+ || detail?.actions?.some(action => action.status === 'running')) return null;
637
653
  let turn;
638
654
  try {
639
655
  turn = this.coordinator.advance(entry.id, {
@@ -673,6 +689,7 @@ export class WorkCenterService {
673
689
  this.store.recoverCoordinatorProviderTurns();
674
690
  this.store.recoverCoordinatorMailbox();
675
691
  for (const recoverable of this.store.getRecoverableCoordinatorTurns?.() || []) {
692
+ if (!this.store.canAutomaticallyCoordinate(recoverable.workItemId)) continue;
676
693
  const claim = this.store.claimCoordinatorTurn(
677
694
  recoverable.workItemId, recoverable.turnId, this.ownerBootId,
678
695
  );
@@ -717,6 +734,10 @@ export class WorkCenterService {
717
734
  let next = null;
718
735
  for (const [key, entry] of this.recoveryQueue) {
719
736
  const detail = this.store.getWorkItemDetail(entry.workItemId);
737
+ if (!this.store.canAutomaticallyCoordinate(entry.workItemId)) {
738
+ this.recoveryQueue.delete(key);
739
+ continue;
740
+ }
720
741
  const action = detail?.actions?.find(candidate => candidate.id === entry.actionId);
721
742
  if (!detail || ['done', 'cancelled'].includes(detail.status)
722
743
  || action?.status !== 'failed'