@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +217 -104
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/engine.js +1 -0
- package/yeaft/sub-agent/runner.js +4 -1
- package/yeaft/tools/activation.js +5 -0
- package/yeaft/tools/git-read.js +121 -24
- package/yeaft/work-center/bridge.js +3 -2
- package/yeaft/work-center/capabilities.js +44 -0
- package/yeaft/work-center/completion-contract.js +11 -3
- package/yeaft/work-center/controller.js +8 -1
- package/yeaft/work-center/coordinator.js +42 -21
- package/yeaft/work-center/dynamic-coordination.js +51 -2
- package/yeaft/work-center/goal-state.js +135 -0
- package/yeaft/work-center/projection.js +57 -8
- package/yeaft/work-center/resource-control.js +460 -0
- package/yeaft/work-center/runner.js +10 -20
- package/yeaft/work-center/service.js +28 -7
- package/yeaft/work-center/store.js +161 -61
- package/yeaft/work-center/workflow.js +7 -3
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
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(
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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 (
|
|
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'
|