ferrycat 0.0.0-stage → 0.3.2

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/dist/engine.js ADDED
@@ -0,0 +1,849 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { computeFingerprints, verifyAllPointers, verifyFingerprintsWithRetry, } from './fingerprint.js';
7
+ import { validateContractOutput } from './schema-validator.js';
8
+ import { validateJsonPayload } from './schemas.js';
9
+ /** Default queue window (SUBMITTED) in seconds. */
10
+ export const DEFAULT_QUEUE_TTL_SEC = 1800;
11
+ /**
12
+ * Tiered default execution window (CLAIMED) per task type.
13
+ * Review/audit work usually involves deep reasoning or a human in the loop,
14
+ * while implementation/test generation is expected to run as an automated pipeline.
15
+ */
16
+ export const DEFAULT_EXEC_TTL_SEC_BY_TASK_TYPE = {
17
+ code_review: 1800,
18
+ architecture_audit: 1800,
19
+ implementation: 300,
20
+ test_generation: 300,
21
+ };
22
+ /** Max extend_ttl calls per holder (counter resets when the contract is released). */
23
+ export const MAX_TTL_EXTENSIONS = 5;
24
+ /** Max times a contract may be released back to the queue before the holder must fail it. */
25
+ export const MAX_RELEASES = 3;
26
+ /**
27
+ * Number of TOCTOU-blocked fulfill attempts TOLERATED before transitioning to
28
+ * terminal POINTER_STALE (ADR-0004). Exact counting semantics:
29
+ *
30
+ * attempt N where N <= MAX_TOCTOU_STRIKES -> RETRY_NEEDED (contract stays CLAIMED,
31
+ * window renewed so files can be restored)
32
+ * attempt N where N > MAX_TOCTOU_STRIKES -> POINTER_STALE (terminal, payload salvaged)
33
+ *
34
+ * With MAX_TOCTOU_STRIKES = 1 the holder gets exactly ONE recoverable rejection:
35
+ * the 1st blocked fulfill is recoverable, the 2nd is terminal. Do not describe this
36
+ * as "Strike 1 and Strike 2" as though two independent chances existed.
37
+ */
38
+ export const MAX_TOCTOU_STRIKES = 1;
39
+ /**
40
+ * extend_ttl is refused once now >= created_at + LIFETIME_QUEUE_TTL_MULTIPLIER * queue_ttl_sec,
41
+ * bounding the total lifetime of a contract even under a misbehaving heartbeat loop.
42
+ */
43
+ export const LIFETIME_QUEUE_TTL_MULTIPLIER = 4;
44
+ /** SQL expression for the start of the current queue window (NULL-safe for rows from older hosts). */
45
+ const QUEUE_START_SQL = 'COALESCE(queued_at, created_at)';
46
+ export function expandTilde(filePath) {
47
+ const trimmed = filePath.trim();
48
+ if (trimmed === '~') {
49
+ return os.homedir();
50
+ }
51
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
52
+ return path.join(os.homedir(), trimmed.slice(2));
53
+ }
54
+ return trimmed;
55
+ }
56
+ export function resolveDbPath(customDbPath) {
57
+ if (customDbPath) {
58
+ return path.resolve(expandTilde(customDbPath));
59
+ }
60
+ if (process.env.FERRYCAT_DB_PATH) {
61
+ return path.resolve(expandTilde(process.env.FERRYCAT_DB_PATH));
62
+ }
63
+ if (process.env.MESH_DB_DIR) {
64
+ return path.resolve(expandTilde(process.env.MESH_DB_DIR), 'mesh.db');
65
+ }
66
+ return path.join(os.homedir(), '.ferrycat', 'mesh.db');
67
+ }
68
+ export class FerryCatEngine {
69
+ dbPath;
70
+ db;
71
+ defaultAgentName;
72
+ fingerprintRetryDelayMs;
73
+ strictLeaseTokens;
74
+ constructor(options = {}) {
75
+ this.assertNodeVersion();
76
+ this.dbPath = resolveDbPath(options.dbPath);
77
+ this.defaultAgentName = options.defaultAgentName ?? process.env.AGENT_NAME;
78
+ this.fingerprintRetryDelayMs = options.fingerprintRetryDelayMs ?? 150;
79
+ this.strictLeaseTokens = options.strictLeaseTokens ?? true;
80
+ const dir = path.dirname(this.dbPath);
81
+ if (!fs.existsSync(dir)) {
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ }
84
+ this.db = new DatabaseSync(this.dbPath);
85
+ this.initSchema();
86
+ }
87
+ assertNodeVersion() {
88
+ const [major, minor] = process.versions.node.split('.').map(Number);
89
+ if (major < 22 || (major === 22 && minor < 5)) {
90
+ throw new Error(`FerryCat requires Node.js >= 22.5.0 (current: ${process.version}). Please upgrade Node or configure MCP command to use Node >= 22.5 with --experimental-sqlite.`);
91
+ }
92
+ }
93
+ initSchema() {
94
+ this.db.exec(`
95
+ PRAGMA busy_timeout = 5000;
96
+ PRAGMA journal_mode = WAL;
97
+ PRAGMA synchronous = NORMAL;
98
+
99
+ CREATE TABLE IF NOT EXISTS contracts (
100
+ id TEXT PRIMARY KEY,
101
+ requester_agent TEXT NOT NULL,
102
+ target_agent TEXT NOT NULL,
103
+ task_type TEXT NOT NULL,
104
+ title TEXT NOT NULL,
105
+ context_summary TEXT NOT NULL,
106
+ workspace_root TEXT NOT NULL,
107
+ pointers_json TEXT NOT NULL,
108
+ contract_schema_json TEXT,
109
+ status TEXT NOT NULL DEFAULT 'SUBMITTED',
110
+ retry_count INTEGER NOT NULL DEFAULT 0,
111
+ max_retries INTEGER NOT NULL DEFAULT 2,
112
+ queue_ttl_sec INTEGER NOT NULL DEFAULT 1800,
113
+ exec_ttl_sec INTEGER NOT NULL DEFAULT 300,
114
+ result_summary TEXT,
115
+ structured_output_json TEXT,
116
+ artifacts_pointers_json TEXT,
117
+ verified_sha256_json TEXT,
118
+ error_reason TEXT,
119
+ created_at INTEGER NOT NULL,
120
+ queued_at INTEGER,
121
+ claimed_at INTEGER,
122
+ completed_at INTEGER,
123
+ extend_count INTEGER NOT NULL DEFAULT 0,
124
+ release_count INTEGER NOT NULL DEFAULT 0,
125
+ toctou_strike INTEGER NOT NULL DEFAULT 0,
126
+ salvaged_result_json TEXT,
127
+ claim_token TEXT
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_contracts_poll
131
+ ON contracts(target_agent, status, created_at);
132
+ `);
133
+ // Additive, idempotent column migrations for databases created by older FerryCat versions.
134
+ // Older hosts sharing the same mesh.db keep working: they simply leave the new columns at defaults.
135
+ const migrations = [
136
+ ['verified_sha256_json', 'TEXT'],
137
+ ['queued_at', 'INTEGER'],
138
+ ['extend_count', 'INTEGER NOT NULL DEFAULT 0'],
139
+ ['release_count', 'INTEGER NOT NULL DEFAULT 0'],
140
+ ['toctou_strike', 'INTEGER NOT NULL DEFAULT 0'],
141
+ ['salvaged_result_json', 'TEXT'],
142
+ ['claim_token', 'TEXT'],
143
+ ];
144
+ const existingColumns = new Set(this.db.prepare('PRAGMA table_info(contracts)').all().map((c) => c.name));
145
+ for (const [column, ddl] of migrations) {
146
+ if (existingColumns.has(column))
147
+ continue;
148
+ try {
149
+ this.db.exec(`ALTER TABLE contracts ADD COLUMN ${column} ${ddl};`);
150
+ }
151
+ catch {
152
+ // Another process added the column concurrently
153
+ }
154
+ }
155
+ // Poll order follows the current queue window so released contracts go to the tail.
156
+ this.db.exec(`
157
+ CREATE INDEX IF NOT EXISTS idx_contracts_queue_order
158
+ ON contracts(target_agent, status, ${QUEUE_START_SQL});
159
+ `);
160
+ }
161
+ resolveAgentIdentity(explicitAgent, roleLabel = 'agent') {
162
+ const resolved = (explicitAgent ?? this.defaultAgentName ?? '').trim();
163
+ if (!resolved) {
164
+ throw new Error(`${roleLabel} identity is missing: please provide it explicitly in tool arguments or set AGENT_NAME in the MCP server env configuration.`);
165
+ }
166
+ return resolved;
167
+ }
168
+ dispatchContract(input, nowMs = Date.now()) {
169
+ const requester = this.resolveAgentIdentity(input.requester_agent, 'requester_agent');
170
+ const target = input.target_agent.trim();
171
+ if (!target) {
172
+ throw new Error('target_agent must not be empty.');
173
+ }
174
+ if (!input.pointers || input.pointers.length === 0) {
175
+ throw new Error('pointers must contain at least one file pointer.');
176
+ }
177
+ if (input.contract_schema) {
178
+ const payloadCheck = validateJsonPayload(input.contract_schema);
179
+ if (!payloadCheck.valid) {
180
+ throw new Error(`contract_schema validation failed: ${payloadCheck.error}`);
181
+ }
182
+ }
183
+ const resolvedWorkspace = path.resolve(expandTilde(input.workspace_root));
184
+ const { fingerprints, recentlyModified } = computeFingerprints(resolvedWorkspace, input.pointers, nowMs);
185
+ const contractId = `fc_${nowMs}_${crypto.randomBytes(4).toString('hex')}`;
186
+ const queueTtlSec = input.queue_ttl_sec ?? DEFAULT_QUEUE_TTL_SEC;
187
+ const execTtlSec = input.exec_ttl_sec ?? DEFAULT_EXEC_TTL_SEC_BY_TASK_TYPE[input.task_type] ?? 300;
188
+ const stmt = this.db.prepare(`
189
+ INSERT INTO contracts (
190
+ id, requester_agent, target_agent, task_type, title, context_summary,
191
+ workspace_root, pointers_json, contract_schema_json, status,
192
+ retry_count, max_retries, queue_ttl_sec, exec_ttl_sec, created_at, queued_at
193
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'SUBMITTED', 0, 2, ?, ?, ?, ?)
194
+ `);
195
+ stmt.run(contractId, requester, target, input.task_type, input.title, input.context_summary, resolvedWorkspace, JSON.stringify(fingerprints), input.contract_schema ? JSON.stringify(input.contract_schema) : null, queueTtlSec, execTtlSec, nowMs, nowMs);
196
+ const result = {
197
+ contract_id: contractId,
198
+ status: 'SUBMITTED',
199
+ created_at: nowMs,
200
+ files: fingerprints.map((fp) => ({
201
+ path: fp.path,
202
+ size: fp.size,
203
+ mtime_ms: fp.mtime_ms,
204
+ sha256: fp.sha256,
205
+ first_line: fp.first_line,
206
+ last_line: fp.last_line,
207
+ ...(fp.line_range ? { line_range: fp.line_range } : {}),
208
+ })),
209
+ };
210
+ if (recentlyModified) {
211
+ result.warning =
212
+ 'One or more target files were modified within the last 3 seconds. Verify first_line/last_line to ensure unsaved IDE buffer changes were flushed to disk.';
213
+ }
214
+ return result;
215
+ }
216
+ /**
217
+ * Read-before-write lazy timeout sweep:
218
+ * Only acquires a write lock if expired active contracts actually exist.
219
+ */
220
+ sweepExpiredContractsIfNeeded(nowMs) {
221
+ const countRow = this.db
222
+ .prepare(`SELECT COUNT(*) AS c FROM contracts
223
+ WHERE (status = 'SUBMITTED' AND (${QUEUE_START_SQL} + queue_ttl_sec * 1000) < ?)
224
+ OR (status = 'CLAIMED' AND (claimed_at + exec_ttl_sec * 1000) < ?)`)
225
+ .get(nowMs, nowMs);
226
+ if (countRow && Number(countRow.c) > 0) {
227
+ this.db
228
+ .prepare(`UPDATE contracts
229
+ SET status = 'TIMED_OUT',
230
+ error_reason = CASE
231
+ WHEN status = 'SUBMITTED' THEN 'QUEUE_TTL_EXPIRED'
232
+ ELSE 'EXEC_TTL_EXPIRED'
233
+ END
234
+ WHERE (status = 'SUBMITTED' AND (${QUEUE_START_SQL} + queue_ttl_sec * 1000) < ?)
235
+ OR (status = 'CLAIMED' AND (claimed_at + exec_ttl_sec * 1000) < ?)`)
236
+ .run(nowMs, nowMs);
237
+ }
238
+ }
239
+ pollNextTask(input = {}, nowMs = Date.now()) {
240
+ const agentId = this.resolveAgentIdentity(input.agent_id, 'agent_id');
241
+ // Step 1: Lazy timeout sweep (SELECT first to avoid write lock contention)
242
+ this.sweepExpiredContractsIfNeeded(nowMs);
243
+ // Step 2: Fetch and evaluate candidate SUBMITTED contracts with continuous pagination
244
+ const claimStmt = this.db.prepare(`UPDATE contracts
245
+ SET status = 'CLAIMED', claimed_at = ?, claim_token = ?
246
+ WHERE id = ? AND status = 'SUBMITTED'`);
247
+ const candidatesStmt = this.db.prepare(`SELECT * FROM contracts
248
+ WHERE target_agent = ? AND status = 'SUBMITTED'
249
+ ORDER BY ${QUEUE_START_SQL} ASC, rowid ASC
250
+ LIMIT 10`);
251
+ let lastStaleError;
252
+ let anyCandidatesSeen = false;
253
+ while (true) {
254
+ const candidates = candidatesStmt.all(agentId);
255
+ if (candidates.length === 0) {
256
+ break;
257
+ }
258
+ anyCandidatesSeen = true;
259
+ for (const candidate of candidates) {
260
+ // Step 3: Atomic optimistic lock preemption with unique lease token (FC-301)
261
+ const claimToken = crypto.randomUUID();
262
+ const updateResult = claimStmt.run(nowMs, claimToken, candidate.id);
263
+ if (Number(updateResult.changes) !== 1) {
264
+ // Preempted by another concurrent worker process; try next candidate
265
+ continue;
266
+ }
267
+ // Step 4: Verify file fingerprints on disk (with 150ms transient write retry & exception guard)
268
+ let fingerprints;
269
+ let verification;
270
+ try {
271
+ fingerprints = JSON.parse(candidate.pointers_json);
272
+ verification = verifyFingerprintsWithRetry(candidate.workspace_root, fingerprints, this.fingerprintRetryDelayMs);
273
+ }
274
+ catch (err) {
275
+ const errMsg = `UNEXPECTED_FS_ERROR: ${err instanceof Error ? err.message : String(err)}`;
276
+ this.db
277
+ .prepare(`UPDATE contracts SET status = 'POINTER_STALE', error_reason = ? WHERE id = ?`)
278
+ .run(errMsg, candidate.id);
279
+ lastStaleError = {
280
+ contract_id: candidate.id,
281
+ reason: 'UNEXPECTED_FS_ERROR',
282
+ message: `Contract "${candidate.id}" marked POINTER_STALE due to filesystem error (${errMsg}).`,
283
+ };
284
+ continue;
285
+ }
286
+ if (!verification.valid) {
287
+ const errorReason = `${verification.reason}: ${verification.detail}`;
288
+ this.db
289
+ .prepare(`UPDATE contracts
290
+ SET status = 'POINTER_STALE', error_reason = ?
291
+ WHERE id = ?`)
292
+ .run(errorReason, candidate.id);
293
+ lastStaleError = {
294
+ contract_id: candidate.id,
295
+ reason: verification.reason,
296
+ message: `Contract "${candidate.id}" rejected as POINTER_STALE (${errorReason}). Please ask requester "${candidate.requester_agent}" to re-dispatch with fresh file fingerprints.`,
297
+ };
298
+ continue;
299
+ }
300
+ return {
301
+ has_task: true,
302
+ status: 'CLAIMED',
303
+ contract: {
304
+ contract_id: candidate.id,
305
+ requester_agent: candidate.requester_agent,
306
+ target_agent: candidate.target_agent,
307
+ task_type: candidate.task_type,
308
+ title: candidate.title,
309
+ context_summary: candidate.context_summary,
310
+ workspace_root: candidate.workspace_root,
311
+ pointers: fingerprints,
312
+ contract_schema: candidate.contract_schema_json
313
+ ? JSON.parse(candidate.contract_schema_json)
314
+ : null,
315
+ claimed_at: nowMs,
316
+ exec_ttl_sec: candidate.exec_ttl_sec,
317
+ claim_token: claimToken,
318
+ },
319
+ };
320
+ }
321
+ }
322
+ if (lastStaleError) {
323
+ return {
324
+ has_task: false,
325
+ status: 'POINTER_STALE',
326
+ stale_error: lastStaleError,
327
+ };
328
+ }
329
+ return {
330
+ has_task: false,
331
+ message: anyCandidatesSeen
332
+ ? `All candidate contracts for "${agentId}" were preempted by concurrent workers.`
333
+ : `No pending SUBMITTED contracts for agent "${agentId}".`,
334
+ };
335
+ }
336
+ fulfillContract(input, nowMs = Date.now()) {
337
+ const agentId = this.resolveAgentIdentity(input.agent_id, 'agent_id');
338
+ const row = this.loadClaimedContractForHolder(input.contract_id, agentId, 'fulfill', input.claim_token);
339
+ if (input.structured_output) {
340
+ const payloadCheck = validateJsonPayload(input.structured_output);
341
+ if (!payloadCheck.valid) {
342
+ throw new Error(`structured_output validation failed: ${payloadCheck.error}`);
343
+ }
344
+ }
345
+ // Check execution TTL
346
+ if (this.expireIfExecTtlElapsed(row, nowMs)) {
347
+ return {
348
+ ok: false,
349
+ contract_id: row.id,
350
+ status: 'TIMED_OUT',
351
+ retry_count: row.retry_count,
352
+ max_retries: row.max_retries,
353
+ message: `Contract "${row.id}" exceeded execution TTL (${row.exec_ttl_sec}s) and was marked TIMED_OUT.`,
354
+ };
355
+ }
356
+ //Re-verify file fingerprints at fulfill time to close the claim->read->fulfill TOCTOU window (ADR-0004)
357
+ const fingerprints = JSON.parse(row.pointers_json);
358
+ const toctouCheck = verifyAllPointers(row.workspace_root, fingerprints);
359
+ if (!toctouCheck.valid && toctouCheck.firstFailure) {
360
+ const toctouReason = `TOCTOU_${toctouCheck.firstFailure.reason}: ${toctouCheck.firstFailure.detail}`;
361
+ const nextStrike = (row.toctou_strike ?? 0) + 1;
362
+ if (nextStrike <= MAX_TOCTOU_STRIKES) {
363
+ // ADR-0004 Soft-Failure Buffering: keep CLAIMED and renew window once so holder can restore files
364
+ this.db
365
+ .prepare(`UPDATE contracts
366
+ SET toctou_strike = ?, claimed_at = ?
367
+ WHERE id = ? AND status = 'CLAIMED'`)
368
+ .run(nextStrike, nowMs, row.id);
369
+ return {
370
+ ok: false,
371
+ contract_id: row.id,
372
+ status: 'RETRY_NEEDED',
373
+ retry_count: row.retry_count,
374
+ max_retries: row.max_retries,
375
+ toctou_strike: nextStrike,
376
+ max_toctou_strikes: MAX_TOCTOU_STRIKES,
377
+ dirty_files: toctouCheck.dirtyFiles,
378
+ message: `Contract "${row.id}" blocked by TOCTOU guard (strike ${nextStrike}/${MAX_TOCTOU_STRIKES}; contract stays CLAIMED with renewed ${row.exec_ttl_sec}s window): ${toctouReason}. RESCUE: (1) If you edited pointer files during review, restore/stash them (e.g., "git stash" or "git checkout -- <file>"), verify via "ferrycat-cli verify --id ${row.id}", and re-run fulfill_contract with your proposed diffs inside structured_output.fixes; (2) If the baseline itself changed on disk, call release_contract or ask "${row.requester_agent}" to re-dispatch.`,
379
+ };
380
+ }
381
+ // Exceeded MAX_TOCTOU_STRIKES: transition to terminal POINTER_STALE, preserving submitted output in salvaged_result_json
382
+ const salvagedJson = JSON.stringify({
383
+ result_summary: input.result_summary,
384
+ structured_output: input.structured_output ?? null,
385
+ artifacts_pointers: input.artifacts_pointers ?? null,
386
+ salvaged_at: nowMs,
387
+ dirty_files: toctouCheck.dirtyFiles,
388
+ });
389
+ this.db
390
+ .prepare(`UPDATE contracts
391
+ SET status = 'POINTER_STALE',
392
+ toctou_strike = ?,
393
+ error_reason = ?,
394
+ salvaged_result_json = ?,
395
+ completed_at = ?
396
+ WHERE id = ? AND status = 'CLAIMED'`)
397
+ .run(nextStrike, toctouReason, salvagedJson, nowMs, row.id);
398
+ return {
399
+ ok: false,
400
+ contract_id: row.id,
401
+ status: 'POINTER_STALE',
402
+ retry_count: row.retry_count,
403
+ max_retries: row.max_retries,
404
+ toctou_strike: nextStrike,
405
+ max_toctou_strikes: MAX_TOCTOU_STRIKES,
406
+ dirty_files: toctouCheck.dirtyFiles,
407
+ message: `Contract "${row.id}" rejected at fulfill time as terminal POINTER_STALE after exceeding max_toctou_strikes (${MAX_TOCTOU_STRIKES}) (${toctouReason}). Your submitted payload was preserved in salvaged_result so work is not lost. Please ask "${row.requester_agent}" to re-dispatch with fresh file fingerprints.`,
408
+ };
409
+ }
410
+ const verifiedShaMap = Object.fromEntries(fingerprints.map((fp) => [fp.path, fp.sha256]));
411
+ const verifiedSha256Json = JSON.stringify(verifiedShaMap);
412
+ // Validate against contract_schema if defined
413
+ if (row.contract_schema_json) {
414
+ const schema = JSON.parse(row.contract_schema_json);
415
+ const validation = validateContractOutput(schema, input.structured_output);
416
+ if (!validation.valid) {
417
+ const nextRetryCount = row.retry_count + 1;
418
+ if (nextRetryCount <= row.max_retries) {
419
+ // A rejected submission renews the execution window so the fix attempt is not starved by TTL
420
+ this.db
421
+ .prepare(`UPDATE contracts SET retry_count = ?, claimed_at = ? WHERE id = ? AND status = 'CLAIMED'`)
422
+ .run(nextRetryCount, nowMs, row.id);
423
+ return {
424
+ ok: false,
425
+ contract_id: row.id,
426
+ status: 'RETRY_NEEDED',
427
+ retry_count: nextRetryCount,
428
+ max_retries: row.max_retries,
429
+ validation_errors: validation.errors,
430
+ message: `structured_output failed schema validation (failed attempt ${nextRetryCount}, max_retries=${row.max_retries}). Execution window renewed for ${row.exec_ttl_sec}s. Fix the fields and call fulfill_contract again.`,
431
+ };
432
+ }
433
+ else {
434
+ const errSummary = `Schema validation failed after ${row.max_retries} retries: ${validation.errors.join('; ')}`;
435
+ this.db
436
+ .prepare(`UPDATE contracts
437
+ SET status = 'VALIDATION_FAILED',
438
+ retry_count = ?,
439
+ error_reason = ?,
440
+ completed_at = ?
441
+ WHERE id = ? AND status = 'CLAIMED'`)
442
+ .run(nextRetryCount, errSummary, nowMs, row.id);
443
+ return {
444
+ ok: false,
445
+ contract_id: row.id,
446
+ status: 'VALIDATION_FAILED',
447
+ retry_count: nextRetryCount,
448
+ max_retries: row.max_retries,
449
+ validation_errors: validation.errors,
450
+ message: `Contract "${row.id}" transitioned to terminal VALIDATION_FAILED after exceeding max_retries (${row.max_retries}).`,
451
+ };
452
+ }
453
+ }
454
+ }
455
+ // Validation succeeded (or no schema required): transition to COMPLETED
456
+ const completeRes = this.db
457
+ .prepare(`UPDATE contracts
458
+ SET status = 'COMPLETED',
459
+ result_summary = ?,
460
+ structured_output_json = ?,
461
+ artifacts_pointers_json = ?,
462
+ verified_sha256_json = ?,
463
+ completed_at = ?
464
+ WHERE id = ? AND status = 'CLAIMED'`)
465
+ .run(input.result_summary, input.structured_output ? JSON.stringify(input.structured_output) : null, input.artifacts_pointers ? JSON.stringify(input.artifacts_pointers) : null, verifiedSha256Json, nowMs, row.id);
466
+ if (Number(completeRes.changes) !== 1) {
467
+ throw new Error(`Concurrent state change detected: contract "${row.id}" is no longer in 'CLAIMED' state.`);
468
+ }
469
+ return {
470
+ ok: true,
471
+ contract_id: row.id,
472
+ status: 'COMPLETED',
473
+ retry_count: row.retry_count,
474
+ max_retries: row.max_retries,
475
+ message: `Contract "${row.id}" fulfilled by "${agentId}" and marked COMPLETED.`,
476
+ };
477
+ }
478
+ /**
479
+ * Shared holder guard for every CLAIMED-state mutation (fulfill / release / extend / fail):
480
+ * the contract must exist, belong to the calling agent, still be CLAIMED,
481
+ * and carry matching claim_token lease credentials (FC-301).
482
+ */
483
+ loadClaimedContractForHolder(contractId, agentId, operation, claimToken) {
484
+ const row = this.db
485
+ .prepare(`SELECT * FROM contracts WHERE id = ?`)
486
+ .get(contractId);
487
+ if (!row) {
488
+ throw new Error(`Contract "${contractId}" not found.`);
489
+ }
490
+ if (row.target_agent !== agentId) {
491
+ const action = operation === 'fulfill' ? 'fulfillment' : operation;
492
+ throw new Error(`Permission denied: contract "${row.id}" was assigned to "${row.target_agent}", but ${action} was attempted by "${agentId}".`);
493
+ }
494
+ if (row.status !== 'CLAIMED') {
495
+ throw new Error(`Cannot ${operation} contract "${contractId}": expected status 'CLAIMED', but current status is '${row.status}'.`);
496
+ }
497
+ // FC-301 Exclusive lease token guard
498
+ if (row.claim_token) {
499
+ if (!claimToken && this.strictLeaseTokens) {
500
+ throw new Error(`Permission denied: contract "${row.id}" is protected by an exclusive lease token (claim_token), but none was provided for ${operation}.`);
501
+ }
502
+ if (claimToken && row.claim_token !== claimToken) {
503
+ throw new Error(`Permission denied: invalid lease token (claim_token) provided for ${operation} on contract "${row.id}".`);
504
+ }
505
+ }
506
+ return row;
507
+ }
508
+ /**
509
+ * If the execution window already elapsed, deterministically transition to TIMED_OUT
510
+ * (the same outcome the lazy sweep would produce) and return true.
511
+ */
512
+ expireIfExecTtlElapsed(row, nowMs, errorReason = 'EXEC_TTL_EXPIRED') {
513
+ if (row.claimed_at === null || nowMs <= row.claimed_at + row.exec_ttl_sec * 1000) {
514
+ return false;
515
+ }
516
+ this.db
517
+ .prepare(`UPDATE contracts SET status = 'TIMED_OUT', error_reason = ? WHERE id = ? AND status = 'CLAIMED'`)
518
+ .run(errorReason, row.id);
519
+ return true;
520
+ }
521
+ /**
522
+ * Helper to compute the absolute lifetime deadline:
523
+ * created_at + LIFETIME_QUEUE_TTL_MULTIPLIER * queue_ttl_sec.
524
+ */
525
+ getLifetimeDeadline(row) {
526
+ return row.created_at + LIFETIME_QUEUE_TTL_MULTIPLIER * row.queue_ttl_sec * 1000;
527
+ }
528
+ /**
529
+ * CLAIMED -> SUBMITTED. Hands a contract back to the queue (tail position) with a fresh
530
+ * queue window. extend_count is reset because the renewal budget belongs to the holder;
531
+ * retry_count is deliberately PRESERVED because the schema-failure circuit breaker is a
532
+ * contract-level quota. Resetting it would let a holder launder retry budget by cycling
533
+ * release -> re-claim. Refused once past the absolute lifetime deadline (-> TIMED_OUT).
534
+ */
535
+ releaseContract(input, nowMs = Date.now()) {
536
+ const agentId = this.resolveAgentIdentity(input.agent_id, 'agent_id');
537
+ const row = this.loadClaimedContractForHolder(input.contract_id, agentId, 'release', input.claim_token);
538
+ if (this.expireIfExecTtlElapsed(row, nowMs)) {
539
+ return {
540
+ ok: false,
541
+ contract_id: row.id,
542
+ status: 'TIMED_OUT',
543
+ release_count: row.release_count,
544
+ max_releases: MAX_RELEASES,
545
+ message: `Contract "${row.id}" exceeded execution TTL (${row.exec_ttl_sec}s) before release and was marked TIMED_OUT.`,
546
+ };
547
+ }
548
+ const lifetimeDeadline = this.getLifetimeDeadline(row);
549
+ if (nowMs >= lifetimeDeadline) {
550
+ this.db
551
+ .prepare(`UPDATE contracts SET status = 'TIMED_OUT', error_reason = 'LIFETIME_DEADLINE_EXPIRED' WHERE id = ? AND status = 'CLAIMED'`)
552
+ .run(row.id);
553
+ return {
554
+ ok: false,
555
+ contract_id: row.id,
556
+ status: 'TIMED_OUT',
557
+ release_count: row.release_count,
558
+ max_releases: MAX_RELEASES,
559
+ message: `Contract "${row.id}" reached its absolute lifetime cap (${LIFETIME_QUEUE_TTL_MULTIPLIER} x queue_ttl_sec) and cannot be released; marked TIMED_OUT.`,
560
+ };
561
+ }
562
+ if (row.release_count >= MAX_RELEASES) {
563
+ return {
564
+ ok: false,
565
+ contract_id: row.id,
566
+ status: 'CLAIMED',
567
+ release_count: row.release_count,
568
+ max_releases: MAX_RELEASES,
569
+ message: `Contract "${row.id}" has already been released ${row.release_count} times (max ${MAX_RELEASES}). It stays CLAIMED by "${agentId}"; call fail_contract with a reason if it cannot be completed.`,
570
+ };
571
+ }
572
+ const reason = input.reason?.trim();
573
+ const releaseEntry = reason ? `RELEASED_BY_HOLDER: ${reason}` : 'RELEASED_BY_HOLDER';
574
+ const errorReason = row.error_reason
575
+ ? `${releaseEntry} | prev: ${row.error_reason}`.slice(0, 2000)
576
+ : releaseEntry;
577
+ const res = this.db
578
+ .prepare(`UPDATE contracts
579
+ SET status = 'SUBMITTED',
580
+ claimed_at = NULL,
581
+ claim_token = NULL,
582
+ queued_at = ?,
583
+ extend_count = 0,
584
+ toctou_strike = 0,
585
+ release_count = release_count + 1,
586
+ error_reason = ?
587
+ WHERE id = ? AND status = 'CLAIMED' AND target_agent = ? AND release_count = ?`)
588
+ .run(nowMs, errorReason, row.id, agentId, row.release_count);
589
+ if (Number(res.changes) !== 1) {
590
+ throw new Error(`Concurrent state change detected: contract "${row.id}" is no longer in 'CLAIMED' state.`);
591
+ }
592
+ const releaseCount = row.release_count + 1;
593
+ return {
594
+ ok: true,
595
+ contract_id: row.id,
596
+ status: 'SUBMITTED',
597
+ release_count: releaseCount,
598
+ max_releases: MAX_RELEASES,
599
+ message: `Contract "${row.id}" released by "${agentId}" and re-queued as SUBMITTED (release ${releaseCount}/${MAX_RELEASES}, fresh ${row.queue_ttl_sec}s queue window).`,
600
+ };
601
+ }
602
+ /**
603
+ * Heartbeat / renewal: resets claimed_at to now, i.e. restarts the full exec_ttl_sec window.
604
+ * Bounded by MAX_TTL_EXTENSIONS per holder and an absolute lifetime cap
605
+ * (created_at + LIFETIME_QUEUE_TTL_MULTIPLIER * queue_ttl_sec).
606
+ */
607
+ extendTtl(input, nowMs = Date.now()) {
608
+ const agentId = this.resolveAgentIdentity(input.agent_id, 'agent_id');
609
+ const row = this.loadClaimedContractForHolder(input.contract_id, agentId, 'extend', input.claim_token);
610
+ if (this.expireIfExecTtlElapsed(row, nowMs)) {
611
+ return {
612
+ ok: false,
613
+ contract_id: row.id,
614
+ status: 'TIMED_OUT',
615
+ extend_count: row.extend_count,
616
+ max_extends: MAX_TTL_EXTENSIONS,
617
+ message: `Contract "${row.id}" exceeded execution TTL (${row.exec_ttl_sec}s) before renewal and was marked TIMED_OUT. Renewal must happen inside the active window.`,
618
+ };
619
+ }
620
+ const currentExpiresAt = (row.claimed_at ?? nowMs) + row.exec_ttl_sec * 1000;
621
+ if (row.extend_count >= MAX_TTL_EXTENSIONS) {
622
+ return {
623
+ ok: false,
624
+ contract_id: row.id,
625
+ status: 'CLAIMED',
626
+ claimed_at: row.claimed_at ?? undefined,
627
+ expires_at: currentExpiresAt,
628
+ extend_count: row.extend_count,
629
+ max_extends: MAX_TTL_EXTENSIONS,
630
+ message: `Extension limit reached for contract "${row.id}" (${MAX_TTL_EXTENSIONS}/${MAX_TTL_EXTENSIONS}). Deliver via fulfill_contract, hand it back via release_contract, or call fail_contract before the window closes.`,
631
+ };
632
+ }
633
+ const lifetimeDeadline = this.getLifetimeDeadline(row);
634
+ if (nowMs >= lifetimeDeadline) {
635
+ return {
636
+ ok: false,
637
+ contract_id: row.id,
638
+ status: 'CLAIMED',
639
+ claimed_at: row.claimed_at ?? undefined,
640
+ expires_at: currentExpiresAt,
641
+ extend_count: row.extend_count,
642
+ max_extends: MAX_TTL_EXTENSIONS,
643
+ message: `Contract "${row.id}" reached its absolute lifetime cap (created_at + ${LIFETIME_QUEUE_TTL_MULTIPLIER} x queue_ttl_sec) and can no longer be renewed. The current window still ends at ${new Date(currentExpiresAt).toISOString()}.`,
644
+ };
645
+ }
646
+ const proposedExpiresAt = nowMs + row.exec_ttl_sec * 1000;
647
+ if (proposedExpiresAt > lifetimeDeadline) {
648
+ return {
649
+ ok: false,
650
+ contract_id: row.id,
651
+ status: 'CLAIMED',
652
+ claimed_at: row.claimed_at ?? undefined,
653
+ expires_at: currentExpiresAt,
654
+ extend_count: row.extend_count,
655
+ max_extends: MAX_TTL_EXTENSIONS,
656
+ message: `Contract "${row.id}" renewal would push execution window to ${new Date(proposedExpiresAt).toISOString()}, exceeding the absolute lifetime cap (${new Date(lifetimeDeadline).toISOString()}). The current window still ends at ${new Date(currentExpiresAt).toISOString()}.`,
657
+ };
658
+ }
659
+ const res = this.db
660
+ .prepare(`UPDATE contracts
661
+ SET claimed_at = ?, extend_count = extend_count + 1
662
+ WHERE id = ? AND status = 'CLAIMED' AND target_agent = ? AND extend_count = ?`)
663
+ .run(nowMs, row.id, agentId, row.extend_count);
664
+ if (Number(res.changes) !== 1) {
665
+ throw new Error(`Concurrent state change detected: contract "${row.id}" was modified while extending its TTL.`);
666
+ }
667
+ const extendCount = row.extend_count + 1;
668
+ const expiresAt = nowMs + row.exec_ttl_sec * 1000;
669
+ return {
670
+ ok: true,
671
+ contract_id: row.id,
672
+ status: 'CLAIMED',
673
+ claimed_at: nowMs,
674
+ expires_at: expiresAt,
675
+ extend_count: extendCount,
676
+ max_extends: MAX_TTL_EXTENSIONS,
677
+ message: `Execution window for contract "${row.id}" renewed for ${row.exec_ttl_sec}s (extension ${extendCount}/${MAX_TTL_EXTENSIONS}).`,
678
+ };
679
+ }
680
+ /**
681
+ * CLAIMED -> FAILED (terminal). Lets the holder close the loop explicitly instead of
682
+ * leaving the requester waiting for the execution TTL to expire.
683
+ */
684
+ failContract(input, nowMs = Date.now()) {
685
+ const agentId = this.resolveAgentIdentity(input.agent_id, 'agent_id');
686
+ const reason = (input.reason ?? '').trim();
687
+ if (!reason) {
688
+ throw new Error('reason must not be empty when failing a contract.');
689
+ }
690
+ const row = this.loadClaimedContractForHolder(input.contract_id, agentId, 'fail', input.claim_token);
691
+ // A late failure report still loses to the TTL, but the worker's reason is preserved for the requester
692
+ if (this.expireIfExecTtlElapsed(row, nowMs, `EXEC_TTL_EXPIRED (late failure report: ${reason})`)) {
693
+ return {
694
+ ok: false,
695
+ contract_id: row.id,
696
+ status: 'TIMED_OUT',
697
+ message: `Contract "${row.id}" had already exceeded execution TTL (${row.exec_ttl_sec}s); marked TIMED_OUT with the failure reason attached.`,
698
+ };
699
+ }
700
+ const res = this.db
701
+ .prepare(`UPDATE contracts
702
+ SET status = 'FAILED', error_reason = ?, completed_at = ?
703
+ WHERE id = ? AND status = 'CLAIMED' AND target_agent = ?`)
704
+ .run(`WORKER_FAILED: ${reason}`, nowMs, row.id, agentId);
705
+ if (Number(res.changes) !== 1) {
706
+ throw new Error(`Concurrent state change detected: contract "${row.id}" is no longer in 'CLAIMED' state.`);
707
+ }
708
+ return {
709
+ ok: true,
710
+ contract_id: row.id,
711
+ status: 'FAILED',
712
+ message: `Contract "${row.id}" marked FAILED by "${agentId}".`,
713
+ };
714
+ }
715
+ /**
716
+ * Read-only pre-flight verification (ADR-0004): checks whether on-disk pointer files
717
+ * still match the contract's dispatched SHA-256 baseline without mutating any state.
718
+ */
719
+ verifyContract(input) {
720
+ const row = this.db
721
+ .prepare(`SELECT * FROM contracts WHERE id = ?`)
722
+ .get(input.contract_id);
723
+ if (!row) {
724
+ throw new Error(`Contract "${input.contract_id}" not found.`);
725
+ }
726
+ const fingerprints = JSON.parse(row.pointers_json);
727
+ const check = verifyAllPointers(row.workspace_root, fingerprints);
728
+ const toctouStrike = row.toctou_strike ?? 0;
729
+ const callerAgent = input.agent_id?.trim();
730
+ const holderNote = callerAgent && callerAgent !== row.target_agent
731
+ ? ` (Note: contract is assigned to "${row.target_agent}", verified by "${callerAgent}".)`
732
+ : '';
733
+ if (check.valid) {
734
+ const fulfillable = row.status === 'CLAIMED';
735
+ return {
736
+ ok: true,
737
+ valid: true,
738
+ contract_id: row.id,
739
+ status: row.status,
740
+ fulfillable,
741
+ target_agent: row.target_agent,
742
+ toctou_strike: toctouStrike,
743
+ max_toctou_strikes: MAX_TOCTOU_STRIKES,
744
+ dirty_files: [],
745
+ message: fulfillable
746
+ ? `All ${fingerprints.length} pointer file(s) for contract "${row.id}" match the dispatched SHA-256 baseline. Safe to fulfill.${holderNote}`
747
+ : `All ${fingerprints.length} pointer file(s) for contract "${row.id}" match the baseline, but contract status is '${row.status}' and it can no longer be fulfilled. Ask the requester to re-dispatch with fresh fingerprints.${holderNote}`,
748
+ };
749
+ }
750
+ const dirtyPaths = check.dirtyFiles.map((d) => d.path).join(', ');
751
+ return {
752
+ ok: true,
753
+ valid: false,
754
+ contract_id: row.id,
755
+ status: row.status,
756
+ fulfillable: false,
757
+ target_agent: row.target_agent,
758
+ toctou_strike: toctouStrike,
759
+ max_toctou_strikes: MAX_TOCTOU_STRIKES,
760
+ dirty_files: check.dirtyFiles,
761
+ message: `Pointer drift detected in ${check.dirtyFiles.length} file(s) [${dirtyPaths}] for contract "${row.id}". Restore/stash the modified file(s) before calling fulfill_contract, and deliver any proposed edits inside structured_output.fixes.${holderNote}`,
762
+ };
763
+ }
764
+ /**
765
+ * Strictly read-only inspection: never mutates the SQLite database.
766
+ * Computes `is_logically_expired` dynamically on read.
767
+ */
768
+ inspectMeshState(input = {}, nowMs = Date.now()) {
769
+ let rows;
770
+ const limit = Math.min(Math.max(input.limit ?? 10, 1), 100);
771
+ const targetFilter = input.target_agent?.trim();
772
+ if (input.contract_id) {
773
+ const single = this.db
774
+ .prepare(`SELECT * FROM contracts WHERE id = ?`)
775
+ .get(input.contract_id);
776
+ rows = single && (!targetFilter || single.target_agent === targetFilter) ? [single] : [];
777
+ }
778
+ else {
779
+ const conditions = [];
780
+ const params = [];
781
+ if (input.status && input.status !== 'ALL') {
782
+ if (input.status === 'ACTIVE') {
783
+ conditions.push(`status IN ('SUBMITTED', 'CLAIMED')`);
784
+ }
785
+ else {
786
+ conditions.push(`status = ?`);
787
+ params.push(input.status);
788
+ }
789
+ }
790
+ if (targetFilter) {
791
+ conditions.push(`target_agent = ?`);
792
+ params.push(targetFilter);
793
+ }
794
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
795
+ params.push(limit);
796
+ rows = this.db
797
+ .prepare(`SELECT * FROM contracts ${whereClause} ORDER BY created_at DESC LIMIT ?`)
798
+ .all(...params);
799
+ }
800
+ const formatted = rows.map((r) => {
801
+ const queueStart = r.queued_at ?? r.created_at;
802
+ const isLogicallyExpired = (r.status === 'SUBMITTED' && nowMs > queueStart + r.queue_ttl_sec * 1000) ||
803
+ (r.status === 'CLAIMED' &&
804
+ r.claimed_at !== null &&
805
+ nowMs > r.claimed_at + r.exec_ttl_sec * 1000);
806
+ return {
807
+ contract_id: r.id,
808
+ requester_agent: r.requester_agent,
809
+ target_agent: r.target_agent,
810
+ task_type: r.task_type,
811
+ title: r.title,
812
+ context_summary: r.context_summary,
813
+ workspace_root: r.workspace_root,
814
+ status: r.status,
815
+ is_logically_expired: isLogicallyExpired,
816
+ retry_count: r.retry_count,
817
+ max_retries: r.max_retries,
818
+ extend_count: r.extend_count ?? 0,
819
+ release_count: r.release_count ?? 0,
820
+ toctou_strike: r.toctou_strike ?? 0,
821
+ pointers: JSON.parse(r.pointers_json),
822
+ contract_schema: r.contract_schema_json ? JSON.parse(r.contract_schema_json) : null,
823
+ result_summary: r.result_summary,
824
+ structured_output: r.structured_output_json ? JSON.parse(r.structured_output_json) : null,
825
+ artifacts_pointers: r.artifacts_pointers_json
826
+ ? JSON.parse(r.artifacts_pointers_json)
827
+ : null,
828
+ verified_sha256: r.verified_sha256_json ? JSON.parse(r.verified_sha256_json) : null,
829
+ salvaged_result: r.salvaged_result_json ? JSON.parse(r.salvaged_result_json) : null,
830
+ error_reason: r.error_reason,
831
+ created_at: r.created_at,
832
+ queued_at: queueStart,
833
+ claimed_at: r.claimed_at,
834
+ exec_expires_at: r.status === 'CLAIMED' && r.claimed_at !== null
835
+ ? r.claimed_at + r.exec_ttl_sec * 1000
836
+ : null,
837
+ completed_at: r.completed_at,
838
+ };
839
+ });
840
+ return {
841
+ total_returned: formatted.length,
842
+ contracts: formatted,
843
+ };
844
+ }
845
+ close() {
846
+ this.db.close();
847
+ }
848
+ }
849
+ //# sourceMappingURL=engine.js.map