@ccdd/project 5.1.2 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/monitor-ui/assets/{GraphView-BILt_jGR.js → GraphView-Dvqux-3b.js} +3 -3
  2. package/dist/monitor-ui/assets/ValidationView-ksCr_lB7.css +1 -0
  3. package/dist/monitor-ui/assets/ValidationView-qhnNfj00.js +1 -0
  4. package/dist/monitor-ui/assets/{elk-api-CW6DDhyE.js → elk-api-D1k8qsLm.js} +1 -1
  5. package/dist/monitor-ui/assets/{elk.bundled-WR-av3nY.js → elk.bundled-DgeQWR_s.js} +1 -1
  6. package/dist/monitor-ui/assets/{index-Drtw0yLv.js → index-beXagC6U.js} +3 -3
  7. package/dist/monitor-ui/index.html +1 -1
  8. package/dist/src/artifacts/scope.d.ts +1 -1
  9. package/dist/src/artifacts/scope.js +2 -2
  10. package/dist/src/artifacts/scope.js.map +1 -1
  11. package/dist/src/broker/admission.d.ts +19 -0
  12. package/dist/src/broker/admission.js +33 -0
  13. package/dist/src/broker/admission.js.map +1 -0
  14. package/dist/src/broker/changes.d.ts +25 -0
  15. package/dist/src/broker/changes.js +27 -0
  16. package/dist/src/broker/changes.js.map +1 -0
  17. package/dist/src/broker/coalescing.d.ts +1 -0
  18. package/dist/src/broker/coalescing.js +5 -3
  19. package/dist/src/broker/coalescing.js.map +1 -1
  20. package/dist/src/broker/graph.d.ts +1 -1
  21. package/dist/src/broker/graph.js +1 -1
  22. package/dist/src/broker/graph.js.map +1 -1
  23. package/dist/src/broker/index.d.ts +29 -3
  24. package/dist/src/broker/index.js +263 -307
  25. package/dist/src/broker/index.js.map +1 -1
  26. package/dist/src/broker/readiness.d.ts +17 -0
  27. package/dist/src/broker/readiness.js +252 -0
  28. package/dist/src/broker/readiness.js.map +1 -0
  29. package/dist/src/broker/storage.d.ts +101 -0
  30. package/dist/src/broker/storage.js +315 -0
  31. package/dist/src/broker/storage.js.map +1 -0
  32. package/dist/src/contracts.d.ts +1 -1
  33. package/dist/src/monitor/store.js +6 -4
  34. package/dist/src/monitor/store.js.map +1 -1
  35. package/dist/src/monitor/ui/format.js +1 -1
  36. package/dist/src/monitor/ui/format.js.map +1 -1
  37. package/dist/src/project/cli.js +8 -7
  38. package/dist/src/project/cli.js.map +1 -1
  39. package/dist/src/project/gates.d.ts +6 -0
  40. package/dist/src/project/gates.js +33 -0
  41. package/dist/src/project/gates.js.map +1 -0
  42. package/dist/src/project/index.d.ts +2 -1
  43. package/dist/src/project/index.js +2 -2
  44. package/dist/src/project/index.js.map +1 -1
  45. package/dist/src/project/query.js +35 -8
  46. package/dist/src/project/query.js.map +1 -1
  47. package/dist/src/project/store.d.ts +1 -1
  48. package/dist/src/project/store.js +29 -17
  49. package/dist/src/project/store.js.map +1 -1
  50. package/dist/src/project/types.d.ts +7 -3
  51. package/dist/src/requester/index.d.ts +3 -2
  52. package/dist/src/requester/index.js +6 -5
  53. package/dist/src/requester/index.js.map +1 -1
  54. package/dist/src/state-format.js +2 -2
  55. package/package.json +2 -2
  56. package/dist/monitor-ui/assets/ValidationView-D18mwExY.css +0 -1
  57. package/dist/monitor-ui/assets/ValidationView-L-a3hI_A.js +0 -1
@@ -6,10 +6,14 @@ import { randomUUID } from 'node:crypto';
6
6
  import { performance } from 'node:perf_hooks';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { setTimeout as delay } from 'node:timers/promises';
9
+ import { setTimeout as delay, setImmediate as yieldTurn } from 'node:timers/promises';
10
10
  import { prepareReviewRequests } from '../requester/index.js';
11
11
  import { readWorkspaceConfig } from './config.js';
12
12
  import { createGraphDefinition } from './graph.js';
13
+ import { localAdmission } from './admission.js';
14
+ import { readChanges } from './changes.js';
15
+ import { initializeRecords, records } from './storage.js';
16
+ import { createReadiness } from './readiness.js';
13
17
  import { createStatusPolling } from './polling.js';
14
18
  import { ownerAlive, ownProcessIdentity } from './ownership.js';
15
19
  import { coalescingEligibility, findCoalescibleRequest } from './coalescing.js';
@@ -86,7 +90,7 @@ function prepareStateDirectory(repoPath, stateDir) {
86
90
  return fs.realpathSync(canonical);
87
91
  }
88
92
  /** Durable broker operations. Merely opening the store never starts a worker. */
89
- export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coalescingGraceMs = 15_000, maxConcurrentExecutors = 4, identityConcurrency = DEFAULT_IDENTITY_CONCURRENCY, executors, workspaceIntegrity = 'content', workspaceAdapter = { prepareWorkspace, reopenWorkspace } }) {
93
+ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coalescingGraceMs = 15_000, maxConcurrentExecutors = 4, admission = localAdmission(maxConcurrentExecutors), identityConcurrency = DEFAULT_IDENTITY_CONCURRENCY, executors, workspaceIntegrity = 'content', workspaceAdapter = { prepareWorkspace, reopenWorkspace } }) {
90
94
  positiveConcurrency(maxConcurrentExecutors, 'maxConcurrentExecutors');
91
95
  positiveConcurrency(identityConcurrency, 'identityConcurrency');
92
96
  if (!Number.isSafeInteger(coalescingGraceMs) || coalescingGraceMs < 0 || coalescingGraceMs > 300_000)
@@ -121,10 +125,12 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
121
125
  CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL);
122
126
  CREATE TABLE IF NOT EXISTS requests (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), ordinal INTEGER NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL);
123
127
  CREATE INDEX IF NOT EXISTS requests_run ON requests(run_id, ordinal);
124
- CREATE INDEX IF NOT EXISTS requests_validation_input ON requests(json_extract(data, '$.validationInput.key'));
128
+ CREATE INDEX IF NOT EXISTS requests_validation_input ON requests(json_extract(data, '$.criticId'),json_extract(data, '$.inputKey'));
129
+ CREATE INDEX IF NOT EXISTS requests_ready ON requests(run_id,status,ordinal);
125
130
  CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL REFERENCES runs(id), request_id TEXT, created_at TEXT NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, data TEXT);
126
131
  CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
127
132
  CREATE TABLE IF NOT EXISTS run_owners (run_id TEXT PRIMARY KEY REFERENCES runs(id), pid INTEGER NOT NULL, process_identity TEXT, token TEXT NOT NULL, claimed_at TEXT NOT NULL);`);
133
+ initializeRecords(db);
128
134
  const previousRepo = db.prepare('SELECT value FROM metadata WHERE key = ?').get('registered-repo');
129
135
  const identity = JSON.stringify({ repoPath, repoId });
130
136
  if (previousRepo && previousRepo.value !== identity)
@@ -144,102 +150,36 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
144
150
  const active = new Map();
145
151
  const listeners = new Set();
146
152
  const ensureOpen = () => { if (closed || closing)
147
- throw new Error('Broker is closed.'); sweepSubmissionDeadlines(); };
153
+ throw new Error('Broker is closed.'); };
148
154
  const requireExecutors = () => {
149
155
  if (!executors || typeof executors.canExecute !== 'function' || typeof executors.execute !== 'function')
150
156
  throw new Error('Review submission and execution require an executor registry.');
151
157
  return executors;
152
158
  };
153
- // data_version changes for commits from other connections, not our own writes.
154
- // Check inside the same SQLite snapshot as each read; rollback discards every
155
- // locally published cache entry. These are bounded read caches, never evidence.
156
- const versionStatement = db.prepare('PRAGMA data_version');
157
- let dataVersion = Number(versionStatement.get().data_version);
158
- const runCache = new Map();
159
- const attemptCache = new Map();
160
- let evidenceCache;
161
- const clearReadCaches = () => { runCache.clear(); attemptCache.clear(); evidenceCache = undefined; };
162
- const syncReadCaches = () => {
163
- const version = Number(versionStatement.get().data_version);
164
- if (version !== dataVersion) {
165
- clearReadCaches();
166
- dataVersion = version;
167
- }
168
- };
169
- const currentEvidence = () => {
170
- syncReadCaches();
171
- if (evidenceCache)
172
- return evidenceCache;
173
- const evidence = readEvidence(db);
174
- // Large histories remain authoritative reads rather than unbounded retention.
175
- if (Buffer.byteLength(JSON.stringify(evidence)) <= 16 * 1024 ** 2)
176
- evidenceCache = evidence;
177
- return evidence;
178
- };
179
- const cacheRun = (run, bytes) => {
180
- runCache.delete(run.id);
181
- while (runCache.size && ([...runCache.values()].reduce((sum, item) => sum + item.bytes, 0) + bytes > 96 * 1024 ** 2 || runCache.size >= 4))
182
- runCache.delete(runCache.keys().next().value);
183
- // Keep at most four Runs / 96 MiB of serialized definitions, or one
184
- // oversized current Run. Lifecycle fields are copied for each caller.
185
- runCache.set(run.id, { value: { ...run, ...(run.project ? { project: { ...run.project } } : {}) }, bytes });
186
- };
187
- const cacheAttempt = ({ id, criticId, status, error }) => {
188
- attemptCache.delete(id);
189
- if (attemptCache.size >= 4096)
190
- attemptCache.delete(attemptCache.keys().next().value);
191
- const attempt = { id, criticId, status, error };
192
- attemptCache.set(id, attempt);
193
- return attempt;
194
- };
159
+ const store = records(db);
160
+ const clearReadCaches = () => store.clear();
195
161
  const transaction = (callback) => {
196
162
  db.exec('BEGIN IMMEDIATE');
197
163
  try {
198
- syncReadCaches();
199
- const result = callback();
164
+ const value = callback();
200
165
  db.exec('COMMIT');
201
- return result;
166
+ return value;
202
167
  }
203
168
  catch (error) {
204
169
  try {
205
170
  db.exec('ROLLBACK');
206
171
  }
207
172
  finally {
208
- clearReadCaches();
173
+ store.clear();
209
174
  }
210
175
  throw error;
211
176
  }
212
177
  };
213
- const requestData = (id) => {
214
- const row = db.prepare('SELECT data FROM requests WHERE id = ?').get(id);
215
- return row ? parseStored(row.data) : null;
216
- };
217
- const runData = (id) => {
218
- syncReadCaches();
219
- let cached = runCache.get(id);
220
- if (!cached) {
221
- const row = db.prepare('SELECT data FROM runs WHERE id = ?').get(id);
222
- if (!row)
223
- return null;
224
- cacheRun(parseStored(row.data), Buffer.byteLength(String(row.data)));
225
- cached = runCache.get(id);
226
- }
227
- const run = cached.value;
228
- return { ...run, ...(run.project ? { project: { ...run.project } } : {}) };
229
- };
178
+ const requestData = (id) => store.request(id);
179
+ const runData = (id) => store.run(id);
230
180
  const ownerData = (id) => db.prepare('SELECT * FROM run_owners WHERE run_id = ?').get(id);
231
- const runRequests = (id) => db.prepare('SELECT data FROM requests WHERE run_id = ? ORDER BY ordinal').all(id).map(row => parseStored(row.data));
232
- const sharedRequests = (run) => (run.project?.coalescedRequestIds ?? []).map(id => required(requestData(id), 'Shared Request'));
233
- const attemptData = (id) => {
234
- syncReadCaches();
235
- const cached = attemptCache.get(id);
236
- if (cached)
237
- return cached;
238
- const row = required(db.prepare("SELECT json_object('id',id,'criticId',json_extract(data,'$.criticId'),'status',status,'error',json_extract(data,'$.error')) AS data FROM requests WHERE id = ?").get(id), 'Request');
239
- return cacheAttempt(parseStored(row.data));
240
- };
241
- const runAttempts = (id) => polling.requests(id).map(row => attemptData(row.id));
242
- const attemptsFor = (run) => [...runAttempts(run.id), ...(run.project?.coalescedRequestIds ?? []).map(attemptData)];
181
+ const runRequests = (id) => db.prepare('SELECT id FROM requests WHERE run_id=? ORDER BY ordinal').all(id).map(row => required(requestData(String(row.id)), 'Request'));
182
+ const sharedRequests = (run) => db.prepare("SELECT q.data FROM shared_members m JOIN requests q ON q.id=m.request_id WHERE m.run_id=? AND m.source_run_id!=?").all(run.id, run.id).map(row => parseStored(row.data));
243
183
  const submissionDeadlines = new Map();
244
184
  function sweepSubmissionDeadlines() {
245
185
  for (const id of submissionDeadlines.keys()) {
@@ -252,22 +192,22 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
252
192
  }
253
193
  }
254
194
  const coalescing = { reconcile: reconcileWithin, deadlines: submissionDeadlines };
255
- const shareable = (request) => coalescingEligibility(db, request, coalescing) !== null;
256
- const saveRequest = (request) => {
257
- syncReadCaches();
258
- const previous = polling.requestStatus(request.id);
259
- db.prepare('UPDATE requests SET status = ?, data = ? WHERE id = ?').run(request.status, JSON.stringify(request), request.id);
260
- if (previous === 'GREEN' || previous === 'RED' || request.status === 'GREEN' || request.status === 'RED')
261
- evidenceCache = undefined;
262
- cacheAttempt(request);
195
+ const requestHeader = (id) => { const row = db.prepare('SELECT data FROM requests WHERE id=?').get(id); return row ? parseStored(row.data) : null; };
196
+ const saveHeader = (packed) => {
197
+ db.prepare('UPDATE requests SET status=?,data=? WHERE id=?').run(packed.status, JSON.stringify(packed), packed.id);
198
+ readiness.transition(packed);
263
199
  };
264
- const saveRun = (run) => {
265
- syncReadCaches();
266
- const json = JSON.stringify(run);
267
- db.prepare('UPDATE runs SET status = ?, data = ? WHERE id = ?').run(run.status, json, run.id);
268
- cacheRun(run, Buffer.byteLength(json));
269
- if (terminal.has(run.status))
270
- submissionDeadlines.delete(run.id);
200
+ const saveRequest = (request) => {
201
+ const packed = required(requestHeader(request.id), 'Request');
202
+ for (const key of ['status', 'startedAt', 'completedAt', 'claimedBy', 'claimedAt', 'tryClaim', 'preparationAttempt', 'claimAttemptId', 'notifiedAt', 'error', 'errorCode', 'blockedReason']) {
203
+ if (request[key] === undefined)
204
+ delete packed[key];
205
+ else
206
+ packed[key] = request[key];
207
+ }
208
+ packed.resultRef = request.result ? store.put(request.result) : null;
209
+ packed.semanticRef = request.result ? store.put(semanticResult(request.result)) : null;
210
+ saveHeader(packed);
271
211
  };
272
212
  const appendEvent = (runId, requestId, type, message, data = null) => {
273
213
  db.prepare('INSERT INTO events(run_id,request_id,created_at,type,message,data) VALUES (?,?,?,?,?,?)').run(runId, requestId, now(), type, message.slice(0, 4000), data === null ? null : JSON.stringify(data));
@@ -290,112 +230,47 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
290
230
  throw new Error('An in-place review requires its monitoring worker to remain alive.');
291
231
  },
292
232
  });
293
- function refreshReadinessWithin(runId) {
294
- sweepSubmissionDeadlines();
295
- const run = required(runData(runId), 'Run'), requests = runAttempts(runId);
296
- if (run.project?.version === 3) {
297
- if (terminal.has(run.status))
298
- return;
299
- for (const shared of sharedRequests(run))
300
- reconcileWithin(shared.runId);
301
- // Called in a write transaction: expire only unowned queued sources, then
302
- // recheck all matching owned/leased requests before creating our own ticket.
303
- const retained = sharedRequests(run).filter(request => terminal.has(request.status) || shareable(request));
304
- if (retained.length !== (run.project.coalescedRequestIds ?? []).length) {
305
- run.project.coalescedRequestIds = retained.map(request => request.id);
306
- saveRun(run);
307
- appendEvent(runId, null, 'request.uncoalesced', 'Unowned source submission lease expired; replanning this Run.');
308
- }
309
- brokerTestHooks.onPlan?.();
310
- const plan = planProject(run.project.snapshot, currentEvidence(), { selection: run.project.selection, recursive: run.project.recursive, force: run.project.force, runId, coalescedRequestIds: run.project.coalescedRequestIds, attempts: [...requests, ...(run.project.coalescedRequestIds ?? []).map(attemptData)] });
311
- for (const item of plan.items.filter(item => item.action === 'EXECUTE')) {
312
- if (!run.project.force) {
313
- const shared = findCoalescibleRequest(db, item.id, item.input.key, coalescing)?.request;
314
- if (shared) {
315
- run.project.coalescedRequestIds = [...new Set([...(run.project.coalescedRequestIds ?? []), shared.id])];
316
- saveRun(run);
317
- appendEvent(runId, shared.id, 'request.coalesced', 'Waiting for an identical active review in another Run.', { sourceRunId: shared.runId });
318
- continue;
319
- }
320
- }
321
- const envelope = run.project.templates.find(template => template.criticId === item.id);
322
- if (!envelope)
323
- throw codedError(`Missing prepared Critic ${item.id}.`, 'REVIEW_GRAPH_INVALID');
324
- const request = { ...copy(envelope), validationInput: copy(item.input), id: randomUUID(), runId,
325
- workspace: run.workspace, worktreePath: run.workspace.path, status: 'QUEUED', createdAt: now(),
326
- startedAt: null, completedAt: null, claimedBy: null, claimedAt: null, notifiedAt: null, result: null, error: null, blockedReason: null };
327
- db.prepare('INSERT INTO requests(id,run_id,ordinal,status,data) VALUES (?,?,?,?,?)').run(request.id, runId, requests.length, request.status, JSON.stringify(request));
328
- requests.push(cacheAttempt(request));
329
- appendEvent(runId, request.id, 'request.queued', 'Pull validation found an executable Critic requiring an actual review.');
330
- }
331
- return;
332
- }
333
- throw new Error('Invalid stored Run format; submit a new validation request.');
334
- }
335
- const updateRunStatus = (runId) => {
336
- const run = required(runData(runId), 'Run');
337
- const attempts = attemptsFor(run), states = attempts.map(request => request.status);
338
- if (run.project?.version === 3) {
339
- if (terminal.has(run.status))
340
- return;
341
- brokerTestHooks.onPlan?.();
342
- const plan = planProject(run.project.snapshot, currentEvidence(), { selection: run.project.selection, recursive: run.project.recursive, force: run.project.force, runId, coalescedRequestIds: run.project.coalescedRequestIds, attempts });
343
- const status = states.includes('RUNNING') ? 'RUNNING' : states.includes('QUEUED') ? 'QUEUED' : states.includes('WAITING_HUMAN') ? 'WAITING_HUMAN' :
344
- states.includes('ERROR') ? 'ERROR' : states.includes('RED') || plan.critics.some(critic => critic.status === 'RED') ? 'RED' : plan.satisfied ? 'GREEN' : 'INCOMPLETE';
345
- if (run.status !== status) {
346
- run.status = status;
347
- if (terminal.has(status)) {
348
- run.completedAt = now();
349
- run.project.evidenceRequestIds = [...new Set(plan.critics.flatMap(c => c.result ? [c.result.requestId] : []))];
350
- }
351
- saveRun(run);
352
- appendEvent(runId, null, 'run.status', `Run ${status}`, { status });
353
- }
354
- return;
355
- }
356
- throw new Error('Invalid stored Run format.');
357
- };
233
+ const readiness = createReadiness(db, store, coalescing, appendEvent);
234
+ const refreshReadinessWithin = (id) => readiness.refresh(id);
235
+ const updateRunStatus = (id) => readiness.updateRun(id);
358
236
  function finishWithin(requestId, outcome, expectedStates = ['RUNNING', 'WAITING_HUMAN']) {
359
237
  const { result, error } = outcome, hasError = Object.hasOwn(outcome, 'error');
360
- const request = requestData(requestId);
361
- if (!request || !expectedStates.includes(request.status) || terminal.has(required(runData(request.runId), 'Run').status))
238
+ const request = requestHeader(requestId);
239
+ if (!request || !expectedStates.includes(request.status) || terminal.has(required(polling.runStatus(request.runId), 'Run')))
362
240
  return false;
363
241
  request.status = hasError ? 'ERROR' : required(result, 'review result').verdict;
364
- request.result = result ?? null;
242
+ request.resultRef = result ? store.put(result) : null;
243
+ request.semanticRef = result ? store.put(semanticResult(result)) : null;
365
244
  request.error = hasError ? errorText(error) : null;
366
245
  request.errorCode = errorCode(error) ?? null;
367
246
  request.completedAt = now();
368
- saveRequest(request);
247
+ saveHeader(request);
369
248
  appendEvent(request.runId, request.id, hasError ? 'request.error' : 'request.completed', hasError ? required(request.error, 'error message') : `Review ${required(result, 'review result').verdict}.`, { status: request.status, ...(request.errorCode ? { code: request.errorCode } : {}) });
370
- refreshReadinessWithin(request.runId);
371
- updateRunStatus(request.runId);
372
249
  return true;
373
250
  }
374
251
  function failWithin(runId, error) {
375
- const run = runData(runId);
252
+ const run = readiness.header(runId);
376
253
  if (!run || terminal.has(run.status))
377
254
  return;
378
- for (const request of runRequests(runId)) {
379
- if (terminal.has(request.status))
380
- continue;
255
+ // Cancellation is proportional to affected requests, never definitions.
256
+ submissionDeadlines.delete(runId);
257
+ run.status = 'ERROR';
258
+ run.error = errorText(error);
259
+ run.completedAt = now();
260
+ db.prepare('UPDATE runs SET status=?,data=? WHERE id=?').run(run.status, JSON.stringify(run), runId);
261
+ for (const row of db.prepare("SELECT data FROM requests WHERE run_id=? AND status IN ('QUEUED','RUNNING','WAITING_HUMAN','WAIT_DEPENDENCY','BLOCKED')").all(runId)) {
262
+ const request = parseStored(row.data);
381
263
  request.status = 'ERROR';
382
- request.result = null;
264
+ request.resultRef = null;
265
+ request.semanticRef = null;
383
266
  request.error = errorText(error);
384
267
  request.errorCode = errorCode(error) ?? null;
385
268
  request.completedAt = now();
386
269
  request.blockedReason = null;
387
- saveRequest(request);
270
+ db.prepare('UPDATE requests SET status=?,data=? WHERE id=?').run('ERROR', JSON.stringify(request), request.id);
271
+ readiness.transition(request);
388
272
  appendEvent(runId, request.id, 'request.error', request.error, { status: request.status, ...(request.errorCode ? { code: request.errorCode } : {}) });
389
273
  }
390
- if (run.project?.version === 3) {
391
- brokerTestHooks.onPlan?.();
392
- const plan = planProject(run.project.snapshot, currentEvidence(), { selection: run.project.selection, recursive: run.project.recursive, force: run.project.force, runId, coalescedRequestIds: run.project.coalescedRequestIds, attempts: attemptsFor(run) });
393
- run.project.evidenceRequestIds = [...new Set(plan.critics.flatMap(c => c.result ? [c.result.requestId] : []))];
394
- }
395
- run.status = 'ERROR';
396
- run.error = errorText(error);
397
- run.completedAt = now();
398
- saveRun(run);
399
274
  appendEvent(runId, null, 'run.error', run.error);
400
275
  }
401
276
  function reconcileWithin(runId) {
@@ -418,12 +293,14 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
418
293
  }
419
294
  function getRun(id) {
420
295
  ensureOpen();
421
- if (!runData(id))
296
+ if (!polling.runStatus(id))
422
297
  return null;
423
298
  if (ownerData(id))
424
299
  reconcile(id);
425
300
  const run = required(runData(id), 'Run');
426
301
  const owner = ownerData(id);
302
+ if (owner || terminal.has(run.status))
303
+ submissionDeadlines.delete(id);
427
304
  const events = db.prepare('SELECT * FROM (SELECT * FROM events WHERE run_id = ? ORDER BY id DESC LIMIT 500) ORDER BY id').all(id).map(event => ({ id: Number(event.id), runId: String(event.run_id), requestId: event.request_id === null ? null : String(event.request_id), createdAt: String(event.created_at), type: String(event.type), message: String(event.message), ...(event.data ? { data: parseStored(event.data) } : {}) }));
428
305
  const view = copy(run);
429
306
  return { ...view, scope: view.scope ?? { kind: view.graph ? 'graph' : 'chain' }, owner: owner ? { pid: owner.pid, claimedAt: owner.claimed_at } : null, requests: runRequests(id), events };
@@ -434,120 +311,140 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
434
311
  changed();
435
312
  }
436
313
  async function executeOne(requestId, workspace, token, signal) {
437
- const initial = required(requestData(requestId), 'Request');
314
+ const initial = required(requestHeader(requestId), 'Request');
438
315
  const runId = initial.runId;
439
- transaction(() => {
440
- const request = required(requestData(requestId), 'Request');
441
- if (ownerData(runId)?.token !== token || request.status !== 'QUEUED')
442
- throw codedError('Review ownership changed before execution.', 'RUN_OWNERSHIP_LOST');
443
- request.status = 'RUNNING';
444
- request.startedAt = now();
445
- request.blockedReason = null;
446
- saveRequest(request);
447
- appendEvent(runId, request.id, 'request.started', `${request.title}: reviewing the supplied workspace.`, { snapshotHash: workspace.descriptor.hash });
448
- updateRunStatus(runId);
449
- });
450
- changed();
316
+ let lease;
451
317
  try {
318
+ if (initial.profile.kind !== 'human')
319
+ lease = await admission.acquire({ requestId, runId, kind: initial.profile.kind, provider: initial.profile.provider, model: initial.profile.model }, { signal, waiting(reason) {
320
+ transaction(() => { const current = required(requestHeader(requestId), 'Request'); if (current.status !== 'QUEUED')
321
+ return; current.blockedReason = reason.slice(0, 2000); saveHeader(current); });
322
+ changed();
323
+ } });
452
324
  signal.throwIfAborted();
453
- await workspace.assertUnchanged();
454
- const request = required(requestData(requestId), 'Request');
455
- const runDir = path.join(stateDir, 'runs', runId, request.id);
456
- fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
457
- if (required(runData(runId), 'Run').project) {
458
- const capability = await requireExecutors().canExecute(copy(request));
459
- if (!capability?.ok)
460
- throw codedError(capability?.reason ?? 'No compatible executor.', capability?.code ?? 'EXECUTOR_UNAVAILABLE');
461
- }
462
- if (request.profile.kind === 'human') {
463
- if (typeof requireExecutors().notifyHuman !== 'function')
464
- throw new Error('Human execution requires a registered alarm method.');
465
- transaction(() => {
466
- const current = required(requestData(requestId), 'Request');
467
- if (current.status !== 'RUNNING')
468
- throw codedError('Review was canceled before Human notification.', 'REVIEW_CANCELED');
469
- current.status = 'WAITING_HUMAN';
470
- saveRequest(current);
471
- appendEvent(runId, requestId, 'human.waiting', 'Human review is ready; registered alarm delivery is pending.');
472
- updateRunStatus(runId);
473
- });
474
- await requireExecutors().notifyHuman(copy(required(requestData(requestId), 'Request')), { signal });
325
+ transaction(() => {
326
+ const request = required(requestHeader(requestId), 'Request');
327
+ if (ownerData(runId)?.token !== token || request.status !== 'QUEUED')
328
+ throw codedError('Review ownership changed before execution.', 'RUN_OWNERSHIP_LOST');
329
+ request.status = 'RUNNING';
330
+ request.startedAt = now();
331
+ request.blockedReason = null;
332
+ saveHeader(request);
333
+ appendEvent(runId, request.id, 'request.started', `${request.title}: reviewing the supplied workspace.`, { snapshotHash: workspace.descriptor.hash });
334
+ updateRunStatus(runId);
335
+ });
336
+ changed();
337
+ try {
475
338
  signal.throwIfAborted();
476
339
  await workspace.assertUnchanged();
477
- transaction(() => {
478
- const current = required(requestData(requestId), 'Request');
479
- if (current.status !== 'WAITING_HUMAN')
480
- return;
481
- current.notifiedAt = now();
482
- saveRequest(current);
483
- appendEvent(runId, requestId, 'human.notified', 'Registered human alarm methods confirmed delivery.');
484
- });
485
- changed();
486
- return;
487
- }
488
- const result = validateResult(await requireExecutors().execute(copy(request), {
489
- worktreePath: workspace.descriptor.path, workspacePath: workspace.descriptor.path, runDir, signal,
490
- onEvent(event) {
491
- if (closed || closing || signal.aborted || !event || polling.requestStatus(requestId) !== 'RUNNING' || !['executor.started', 'artifact.tools.ready', 'artifact.tool.called', 'artifact.tool.completed', 'executor.usage', 'executor.final.invalid', 'executor.final.repair', 'executor.telemetry.failed', 'executor.completed'].includes(event.type))
492
- return;
493
- if (event.type === 'executor.final.invalid' || event.type === 'executor.final.repair') {
494
- const safe = finalResultEventData(event);
495
- if (safe) {
496
- appendEvent(runId, requestId, event.type, event.type, safe);
497
- changed();
498
- }
499
- return;
500
- }
501
- const safe = {};
502
- for (const key of ['name', 'provider', 'model', 'kind', 'artifactId', 'path'])
503
- if (typeof event[key] === 'string')
504
- safe[key] = event[key].slice(0, 1000);
505
- if (event.type === 'artifact.tool.completed') {
506
- Object.assign(safe, toolResponseBytes(event));
507
- if (typeof event.startedAt === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(event.startedAt) && Number.isFinite(Date.parse(event.startedAt)))
508
- safe.startedAt = event.startedAt;
509
- if (typeof event.durationMs === 'number' && Number.isFinite(event.durationMs) && event.durationMs >= 0)
510
- safe.durationMs = event.durationMs;
511
- if (event.outcome === 'success' || event.outcome === 'error')
512
- safe.outcome = event.outcome;
513
- if (typeof event.operation === 'string')
514
- safe.operation = event.operation.slice(0, 1000);
515
- }
516
- if (event.type === 'executor.usage') {
517
- const usage = tokenUsage(event.usage);
518
- if (!usage)
340
+ const request = required(requestData(requestId), 'Request');
341
+ const runDir = path.join(stateDir, 'runs', runId, request.id);
342
+ fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
343
+ if (required(readiness.header(runId), 'Run').project) {
344
+ const capability = await requireExecutors().canExecute(copy(request));
345
+ if (!capability?.ok)
346
+ throw codedError(capability?.reason ?? 'No compatible executor.', capability?.code ?? 'EXECUTOR_UNAVAILABLE');
347
+ }
348
+ if (request.profile.kind === 'human') {
349
+ if (typeof requireExecutors().notifyHuman !== 'function')
350
+ throw new Error('Human execution requires a registered alarm method.');
351
+ transaction(() => {
352
+ const current = required(requestData(requestId), 'Request');
353
+ if (current.status !== 'RUNNING')
354
+ throw codedError('Review was canceled before Human notification.', 'REVIEW_CANCELED');
355
+ current.status = 'WAITING_HUMAN';
356
+ saveRequest(current);
357
+ appendEvent(runId, requestId, 'human.waiting', 'Human review is ready; registered alarm delivery is pending.');
358
+ updateRunStatus(runId);
359
+ });
360
+ await requireExecutors().notifyHuman(copy(required(requestData(requestId), 'Request')), { signal });
361
+ signal.throwIfAborted();
362
+ await workspace.assertUnchanged();
363
+ transaction(() => {
364
+ const current = required(requestData(requestId), 'Request');
365
+ if (current.status !== 'WAITING_HUMAN')
519
366
  return;
520
- safe.usage = usage;
521
- }
522
- if (event.isError === true)
523
- safe.isError = true;
524
- if (object(event.observation)) {
525
- // Persist the same bounded metadata as final results, even if the Provider later fails.
526
- const observed = storedObservation(event.observation, event.isError === true);
527
- if (observed)
528
- safe.observation = observed;
529
- }
530
- if (Array.isArray(event.tools))
531
- safe.tools = event.tools.slice(0, 32).map(tool => typeof tool === 'string' ? tool : tool?.name).filter(value => typeof value === 'string');
532
- appendEvent(runId, requestId, event.type, event.type === 'executor.telemetry.failed' ? 'Some optional execution diagnostics could not be recorded.' : String(event.message ?? event.type).slice(0, 2000), safe);
367
+ current.notifiedAt = now();
368
+ saveRequest(current);
369
+ appendEvent(runId, requestId, 'human.notified', 'Registered human alarm methods confirmed delivery.');
370
+ });
533
371
  changed();
534
- },
535
- }));
536
- validateFinalResult(semanticResult(result), request);
537
- await workspace.assertUnchanged();
538
- signal.throwIfAborted();
539
- transaction(() => { if (ownerData(runId)?.token === token)
540
- finishWithin(requestId, { result }); });
541
- changed();
372
+ return;
373
+ }
374
+ const result = validateResult(await requireExecutors().execute(copy(request), {
375
+ worktreePath: workspace.descriptor.path, workspacePath: workspace.descriptor.path, runDir, signal,
376
+ onEvent(event) {
377
+ if (closed || closing || signal.aborted || !event || polling.requestStatus(requestId) !== 'RUNNING' || !['executor.started', 'artifact.tools.ready', 'artifact.tool.called', 'artifact.tool.completed', 'executor.usage', 'executor.final.invalid', 'executor.final.repair', 'executor.telemetry.failed', 'executor.completed'].includes(event.type))
378
+ return;
379
+ if (event.type === 'executor.final.invalid' || event.type === 'executor.final.repair') {
380
+ const safe = finalResultEventData(event);
381
+ if (safe) {
382
+ appendEvent(runId, requestId, event.type, event.type, safe);
383
+ changed();
384
+ }
385
+ return;
386
+ }
387
+ const safe = {};
388
+ for (const key of ['name', 'provider', 'model', 'kind', 'artifactId', 'path'])
389
+ if (typeof event[key] === 'string')
390
+ safe[key] = event[key].slice(0, 1000);
391
+ if (event.type === 'artifact.tool.completed') {
392
+ Object.assign(safe, toolResponseBytes(event));
393
+ if (typeof event.startedAt === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(event.startedAt) && Number.isFinite(Date.parse(event.startedAt)))
394
+ safe.startedAt = event.startedAt;
395
+ if (typeof event.durationMs === 'number' && Number.isFinite(event.durationMs) && event.durationMs >= 0)
396
+ safe.durationMs = event.durationMs;
397
+ if (event.outcome === 'success' || event.outcome === 'error')
398
+ safe.outcome = event.outcome;
399
+ if (typeof event.operation === 'string')
400
+ safe.operation = event.operation.slice(0, 1000);
401
+ }
402
+ if (event.type === 'executor.usage') {
403
+ const usage = tokenUsage(event.usage);
404
+ if (!usage)
405
+ return;
406
+ safe.usage = usage;
407
+ }
408
+ if (event.isError === true)
409
+ safe.isError = true;
410
+ if (object(event.observation)) {
411
+ // Persist the same bounded metadata as final results, even if the Provider later fails.
412
+ const observed = storedObservation(event.observation, event.isError === true);
413
+ if (observed)
414
+ safe.observation = observed;
415
+ }
416
+ if (Array.isArray(event.tools))
417
+ safe.tools = event.tools.slice(0, 32).map(tool => typeof tool === 'string' ? tool : tool?.name).filter(value => typeof value === 'string');
418
+ appendEvent(runId, requestId, event.type, event.type === 'executor.telemetry.failed' ? 'Some optional execution diagnostics could not be recorded.' : String(event.message ?? event.type).slice(0, 2000), safe);
419
+ changed();
420
+ },
421
+ }));
422
+ validateFinalResult(semanticResult(result), request);
423
+ await workspace.assertUnchanged();
424
+ signal.throwIfAborted();
425
+ transaction(() => { if (ownerData(runId)?.token === token)
426
+ finishWithin(requestId, { result }); });
427
+ changed();
428
+ }
429
+ catch (error) {
430
+ const reason = signal.aborted ? signal.reason : error;
431
+ if (signal.aborted || fatalExecutionError(reason))
432
+ throw reason;
433
+ transaction(() => { if (ownerData(runId)?.token === token)
434
+ finishWithin(requestId, { error: reason }); });
435
+ changed();
436
+ }
542
437
  }
543
438
  catch (error) {
544
- const reason = signal.aborted ? signal.reason : error;
545
- if (signal.aborted || fatalExecutionError(reason))
546
- throw reason;
439
+ if (signal.aborted || fatalExecutionError(error))
440
+ throw error;
547
441
  transaction(() => { if (ownerData(runId)?.token === token)
548
- finishWithin(requestId, { error: reason }); });
442
+ finishWithin(requestId, { error }, ['QUEUED']); });
549
443
  changed();
550
444
  }
445
+ finally {
446
+ await lease?.release();
447
+ }
551
448
  }
552
449
  async function run(runId, { signal, onStarted } = {}) {
553
450
  ensureOpen();
@@ -556,7 +453,8 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
556
453
  let stored = null;
557
454
  const acquired = transaction(() => {
558
455
  reconcileWithin(runId);
559
- stored = runData(runId);
456
+ const header = readiness.header(runId);
457
+ stored = header ? { ...header, workspace: store.get(header.workspaceRef) } : null;
560
458
  if (!stored)
561
459
  throw new Error('Unknown Run.');
562
460
  if (stored.project?.version !== 3)
@@ -567,6 +465,7 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
567
465
  throw new Error('Invalid Run: no workspace descriptor.');
568
466
  if (ownerData(runId))
569
467
  throw codedError('Another process already owns this review Run.', 'RUN_ALREADY_OWNED');
468
+ submissionDeadlines.delete(runId);
570
469
  db.prepare('INSERT INTO run_owners(run_id,pid,process_identity,token,claimed_at) VALUES (?,?,?,?,?)').run(runId, process.pid, ownProcessIdentity, token, now());
571
470
  appendEvent(runId, null, 'worker.started', 'A request-scoped worker owns this Run.', { pid: process.pid });
572
471
  return true;
@@ -585,7 +484,6 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
585
484
  let plannedRevision = -1, leaseDeadline = Infinity;
586
485
  let pendingSources = [];
587
486
  const notified = new Set();
588
- const kinds = new Map();
589
487
  try {
590
488
  onStarted?.({ runId, pid: process.pid });
591
489
  workspace = await workspaceAdapter.reopenWorkspace(copy(ownedRun.workspace), { signal: executionSignal });
@@ -609,16 +507,15 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
609
507
  transaction(() => {
610
508
  refreshReadinessWithin(runId);
611
509
  updateRunStatus(runId);
612
- const pending = sharedRequests(required(runData(runId), 'Run')).filter(request => !terminal.has(request.status));
510
+ const pending = sharedRequests({ id: runId }).filter(request => !terminal.has(request.status));
613
511
  pendingSources = [...new Set(pending.map(request => request.runId))];
614
512
  leaseDeadline = Math.min(Infinity, ...pendingSources.filter(id => !ownerData(id)).map(id => submissionDeadlines.get(id) ?? performance.now()));
615
513
  plannedRevision = polling.revision();
616
514
  });
617
- const requests = polling.requests(runId);
515
+ const requests = db.prepare("SELECT id,status,json_extract(data,'$.profile.kind') AS kind FROM requests WHERE run_id=? AND status='QUEUED' ORDER BY ordinal LIMIT ?").all(runId, maxConcurrentExecutors + inFlight.size + 1);
618
516
  let executing = [...inFlight.values()].filter(item => item.kind !== 'human').length;
619
- for (const row of requests.filter(request => request.status === 'QUEUED')) {
620
- const kind = kinds.get(row.id) ?? required(requestData(row.id), 'Request').profile.kind;
621
- kinds.set(row.id, kind);
517
+ for (const row of requests.filter(request => request.status === 'QUEUED' && !inFlight.has(request.id))) {
518
+ const kind = row.kind;
622
519
  if (kind !== 'human' && executing >= maxConcurrentExecutors)
623
520
  continue;
624
521
  if (kind !== 'human')
@@ -640,7 +537,7 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
640
537
  await delay(Math.max(1, Math.min(100, leaseDeadline - performance.now())), undefined, { signal: reviewSignal });
641
538
  continue;
642
539
  }
643
- const waiting = requests.filter(request => request.status === 'WAITING_HUMAN');
540
+ const waiting = db.prepare("SELECT id FROM requests WHERE run_id=? AND status='WAITING_HUMAN' ORDER BY ordinal LIMIT 1").all(runId);
644
541
  if (waiting.length) {
645
542
  for (const request of waiting) {
646
543
  if (notified.has(request.id))
@@ -683,14 +580,18 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
683
580
  }
684
581
  }
685
582
  // close() can be awaiting this promise; read directly before it closes the DB.
686
- return { ...copy(required(runData(runId), 'Run')), requests: runRequests(runId) };
583
+ return detail === 'full' ? { ...copy(required(runData(runId), 'Run')), requests: runRequests(runId) } : { ...ownedRun, requests: [] };
687
584
  })();
688
585
  return entry.promise;
689
586
  }
690
587
  const broker = {
691
- async submitProject({ requesterId = 'cli', selection, recursive = false, force = false, signal, identityConcurrency: submissionIdentityConcurrency = identityConcurrency, ...removed }) {
588
+ async submitProject({ requesterId = 'cli', selection, recursive = false, force = false, ignoreGates = false, signal, identityConcurrency: submissionIdentityConcurrency = identityConcurrency, ...removed }) {
589
+ // Options are destructured before entry; selection is the sole mutable data
590
+ // input. Keep signal live for cancellation rather than cloning its state.
591
+ selection = structuredClone(selection);
692
592
  ensureOpen();
693
593
  requireExecutors();
594
+ sweepSubmissionDeadlines();
694
595
  positiveConcurrency(submissionIdentityConcurrency, 'identityConcurrency');
695
596
  if ('mode' in removed)
696
597
  throw new Error('Workspace modes are no longer supported; supply an unchanged workspace.');
@@ -699,25 +600,39 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
699
600
  await requireExecutors().validateWorkspace?.(repoPath);
700
601
  const workspace = await workspaceAdapter.prepareWorkspace({ repoPath, stateDir, integrity: workspaceIntegrity, signal });
701
602
  try {
702
- const { config } = await readWorkspaceConfig(workspace.descriptor.path, workspace.signal);
703
- const snapshot = await createProjectSnapshot(config, workspace.descriptor.path, workspace.descriptor.hash, workspace.signal, workspace.descriptor.integrity, selection, { identityConcurrency: submissionIdentityConcurrency });
603
+ // A custom adapter may retain a mutable descriptor. Snapshot it once,
604
+ // before any later await, and never store its externally owned object.
605
+ const descriptor = structuredClone(workspace.descriptor);
606
+ const { config } = await readWorkspaceConfig(descriptor.path, workspace.signal);
607
+ const snapshot = await createProjectSnapshot(config, descriptor.path, descriptor.hash, workspace.signal, descriptor.integrity, selection, { identityConcurrency: submissionIdentityConcurrency });
704
608
  const ids = includedCritics(snapshot, selection, recursive);
705
609
  const templates = [];
706
- for (const id of ids)
707
- templates.push(...await prepareReviewRequests({ repoPath: workspace.descriptor.path, repoId, snapshotHash: workspace.descriptor.hash, criticId: id, preparedConfig: config }));
610
+ const critics = new Map(config.critics.map(critic => [critic.id, critic]));
611
+ for (const id of ids) {
612
+ templates.push(...await prepareReviewRequests({ repoPath: descriptor.path, repoId, snapshotHash: descriptor.hash, criticId: id, preparedConfig: { ...config, critics: [critics.get(id)] }, copy: false }));
613
+ if (templates.length % 16 === 0) {
614
+ workspace.signal.throwIfAborted();
615
+ await yieldTurn();
616
+ }
617
+ }
708
618
  const id = randomUUID(), createdAt = now();
709
- const record = { id, repoId, coalescingGraceMs, snapshotHash: workspace.descriptor.hash, workspace: workspace.descriptor, requesterId,
710
- scope: { kind: 'project' }, graph: createGraphDefinition(config), project: { version: 3, snapshot, selection, recursive, force, templates }, status: 'QUEUED', createdAt };
619
+ const record = { id, repoId, coalescingGraceMs, snapshotHash: descriptor.hash, workspace: descriptor, requesterId,
620
+ scope: { kind: 'project' }, graph: createGraphDefinition(config, false), project: { version: 3, snapshot, selection, recursive, force, ignoreGates, templates }, status: 'QUEUED', createdAt };
621
+ const prepared = await store.prepareRun(record, workspace.signal);
711
622
  await workspace.assertUnchanged();
712
623
  workspace.signal.throwIfAborted();
624
+ ensureOpen();
625
+ const commitStarted = performance.now();
713
626
  transaction(() => {
714
- db.prepare('INSERT INTO runs(id,created_at,status,data) VALUES (?,?,?,?)').run(id, createdAt, record.status, JSON.stringify(record));
627
+ prepared.verify();
628
+ db.prepare('INSERT INTO runs(id,created_at,status,data) VALUES (?,?,?,?)').run(id, createdAt, record.status, JSON.stringify(prepared.header));
715
629
  appendEvent(id, null, 'run.submitted', 'Project validation requested against fixed input.', { selection, recursive, force });
716
- refreshReadinessWithin(id);
717
- updateRunStatus(id);
630
+ brokerTestHooks.onPlan?.();
631
+ readiness.initialize(record, prepared);
718
632
  });
633
+ brokerTestHooks.onSubmissionCommit?.(performance.now() - commitStarted);
719
634
  changed();
720
- return required(getRun(id), 'Run');
635
+ return detail === 'full' ? required(getRun(id), 'Run') : { ...record, scope: record.scope, requests: [], events: [], owner: null };
721
636
  }
722
637
  finally {
723
638
  await workspace.close();
@@ -926,7 +841,7 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
926
841
  ensureOpen();
927
842
  transaction(() => {
928
843
  reconcileWithin(runId);
929
- if (!runData(runId))
844
+ if (!polling.runStatus(runId))
930
845
  throw new Error('Unknown Run.');
931
846
  if (ownerData(runId))
932
847
  throw codedError('Another process already owns this review Run.', 'RUN_ALREADY_OWNED');
@@ -939,7 +854,7 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
939
854
  ensureOpen();
940
855
  const error = codedError('Review canceled by requester.', 'REVIEW_CANCELED');
941
856
  transaction(() => {
942
- if (!runData(runId))
857
+ if (!polling.runStatus(runId))
943
858
  throw new Error('Unknown Run.');
944
859
  failWithin(runId, error);
945
860
  });
@@ -949,6 +864,46 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
949
864
  changed();
950
865
  return getRun(runId);
951
866
  },
867
+ retryRequest(requestId) {
868
+ ensureOpen();
869
+ transaction(() => {
870
+ const request = required(requestHeader(requestId), 'Request');
871
+ if (request.status !== 'ERROR')
872
+ throw new Error('Only an operationally failed request can be retried. Submit changed input as a new Run.');
873
+ const run = required(readiness.header(request.runId), 'Run');
874
+ if (ownerData(run.id))
875
+ throw codedError('Wait for the current worker to settle before retrying.', 'RUN_ALREADY_OWNED');
876
+ run.status = 'QUEUED';
877
+ delete run.completedAt;
878
+ delete run.error;
879
+ db.prepare('UPDATE runs SET status=?,data=? WHERE id=?').run(run.status, JSON.stringify(run), run.id);
880
+ const gate = db.prepare('SELECT unmet,red FROM gate_counts WHERE run_id=? AND critic_id=?').get(request.runId, request.criticId);
881
+ request.status = Number(gate?.unmet ?? 0) ? Number(gate?.red ?? 0) ? 'BLOCKED' : 'WAIT_DEPENDENCY' : 'QUEUED';
882
+ request.error = null;
883
+ request.errorCode = null;
884
+ request.resultRef = null;
885
+ request.semanticRef = null;
886
+ request.startedAt = null;
887
+ request.completedAt = null;
888
+ saveHeader(request);
889
+ appendEvent(run.id, request.id, 'request.retried', 'Retry requested against the same immutable input.');
890
+ });
891
+ changed();
892
+ return viewRequest(requestData(requestId));
893
+ },
894
+ changes(runId, options) {
895
+ ensureOpen();
896
+ db.exec('BEGIN');
897
+ try {
898
+ const page = readChanges(db, runId, stateDir, options);
899
+ db.exec('COMMIT');
900
+ return page;
901
+ }
902
+ catch (error) {
903
+ db.exec('ROLLBACK');
904
+ throw error;
905
+ }
906
+ },
952
907
  onChange(callback) { listeners.add(callback); return () => listeners.delete(callback); },
953
908
  async close() {
954
909
  if (closed)
@@ -975,8 +930,9 @@ export function createBroker({ detail, repoPath, stateDir, repoId = 'demo', coal
975
930
  ...broker,
976
931
  submitProject: async (...args) => viewRun(await broker.submitProject(...args)),
977
932
  run: async (...args) => viewRun(await broker.run(...args)),
978
- getRun: (id) => viewRun(broker.getRun(id)),
979
- listRuns: () => broker.listRuns().map(value => viewRun(value)),
933
+ getRun: (id) => { ensureOpen(); if (ownerData(id))
934
+ reconcile(id); return resultView({ detail }, detail === 'full' ? broker.getRun(id) : null, () => storedRequesterRun(db, id, stateDir)); },
935
+ listRuns: () => { ensureOpen(); return db.prepare('SELECT id FROM runs ORDER BY created_at DESC,rowid DESC').all().map(row => { const id = String(row.id); return resultView({ detail }, detail === 'full' ? required(broker.getRun(id), 'Run') : null, () => required(storedRequesterRun(db, id, stateDir), 'Run')); }); },
980
936
  getRequest: (id) => viewRequest(broker.getRequest(id)),
981
937
  claimHuman: async (...args) => viewRequest(await broker.claimHuman(...args)),
982
938
  completeHuman: async (...args) => viewRequest(await broker.completeHuman(...args)),