ferrycat 0.3.3 → 0.5.0

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/daemon.js CHANGED
@@ -14,9 +14,32 @@ import crypto from 'node:crypto';
14
14
  import fs from 'node:fs';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
17
18
  import { FerryCatEngine, expandTilde } from './engine.js';
18
19
  import { FerryCatConfigFileSchema, } from './schemas.js';
19
20
  import { executeTaskAndFulfill, terminateActiveChildrenSync } from './supervisor.js';
21
+ const MAX_JOURNAL_LINES_BEFORE_COMPACT = 500;
22
+ const COMPACT_RETAIN_LINES = 100;
23
+ /**
24
+ * Non-destructive OS liveness probe via `process.kill(pid, 0)`.
25
+ * Never sends a termination signal (respecting CK-05 PID-reuse safety), only checks whether
26
+ * the OS kernel still reports an active process for `pid`.
27
+ */
28
+ export function isProcessAlive(pid) {
29
+ if (!pid || typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
30
+ return false;
31
+ }
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ }
36
+ catch (err) {
37
+ if (err && typeof err === 'object' && 'code' in err && err.code === 'EPERM') {
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ }
20
43
  export function loadFerryCatConfig(customConfigPath) {
21
44
  const candidatePaths = [];
22
45
  if (customConfigPath) {
@@ -46,11 +69,144 @@ export function loadFerryCatConfig(customConfigPath) {
46
69
  resolvedPath: null,
47
70
  };
48
71
  }
49
- export function notifyOperator(title, message) {
72
+ const NOTIFY_WEBHOOK_TIMEOUT_MS = 3000;
73
+ /**
74
+ * Process-level webhook endpoint registered by the active daemon instance. Call sites deep inside
75
+ * signal handlers cannot reach `this`, so the resolved config value lives here instead of being
76
+ * threaded through every notifyOperator() call.
77
+ */
78
+ let activeWebhookUrl;
79
+ export function setNotifyWebhook(url) {
80
+ const trimmed = (url ?? '').trim();
81
+ activeWebhookUrl = trimmed.length > 0 ? trimmed : undefined;
82
+ }
83
+ export function getNotifyWebhook() {
84
+ return activeWebhookUrl;
85
+ }
86
+ function escapeXml(value) {
87
+ return value
88
+ .replace(/&/g, '&amp;')
89
+ .replace(/</g, '&lt;')
90
+ .replace(/>/g, '&gt;')
91
+ .replace(/"/g, '&quot;')
92
+ .replace(/'/g, '&apos;');
93
+ }
94
+ /**
95
+ * Resolve the platform-native notification command (FC-403).
96
+ * Windows uses a WinRT Toast driven by the PowerShell host's own AppId so no module install is
97
+ * required; macOS uses `display notification`; Linux uses libnotify's `notify-send`.
98
+ */
99
+ export function resolveNativeCommand(title, message, platform = process.platform) {
100
+ if (platform === 'win32') {
101
+ const toastXml = `<toast><visual><binding template="ToastText02">` +
102
+ `<text id="1">${escapeXml(title)}</text><text id="2">${escapeXml(message)}</text>` +
103
+ `</binding></visual></toast>`;
104
+ // Reuse PowerShell's own registered AppId so the toast renders without an installed module.
105
+ const appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe';
106
+ const script = [
107
+ "$ErrorActionPreference = 'Stop'",
108
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null',
109
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null',
110
+ `$xml = New-Object Windows.Data.Xml.Dom.XmlDocument`,
111
+ `$xml.LoadXml(@'`,
112
+ toastXml,
113
+ `'@)`,
114
+ `$toast = New-Object Windows.UI.Notifications.ToastNotification $xml`,
115
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${appId}').Show($toast)`,
116
+ ].join('\n');
117
+ return {
118
+ cmd: 'powershell',
119
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
120
+ };
121
+ }
122
+ if (platform === 'darwin') {
123
+ return {
124
+ cmd: 'osascript',
125
+ args: [
126
+ '-e',
127
+ 'on run argv',
128
+ '-e',
129
+ 'display notification (item 2 of argv) with title (item 1 of argv)',
130
+ '-e',
131
+ 'end run',
132
+ '--',
133
+ title,
134
+ message,
135
+ ],
136
+ };
137
+ }
138
+ return { cmd: 'notify-send', args: ['--app-name=FerryCat', title, message] };
139
+ }
140
+ /**
141
+ * Fire-and-forget native toast. Deliberately synchronous-returning: the child is spawned,
142
+ * immediately unref'd, and every failure path (missing binary, EPERM, sandbox denial) is
143
+ * swallowed. A notification must never block or crash the daemon event loop.
144
+ */
145
+ function dispatchNativeNotification(title, message) {
146
+ try {
147
+ const { cmd, args } = resolveNativeCommand(title, message);
148
+ const child = spawn(cmd, args, { stdio: 'ignore', windowsHide: true, detached: true });
149
+ // Without an 'error' listener, a missing binary surfaces as an uncaughtException and kills us.
150
+ child.on('error', () => { });
151
+ child.unref();
152
+ }
153
+ catch {
154
+ // Silent by contract (FC-403 defense red line).
155
+ }
156
+ }
157
+ /**
158
+ * Deliver one webhook payload. Exported so tests can await the real HTTP round-trip; production
159
+ * call sites use the fire-and-forget wrapper below.
160
+ */
161
+ export async function dispatchWebhook(url, payload, timeoutMs = NOTIFY_WEBHOOK_TIMEOUT_MS) {
162
+ const controller = new AbortController();
163
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
164
+ if (typeof timer.unref === 'function')
165
+ timer.unref();
166
+ try {
167
+ const res = await fetch(url, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json', connection: 'close' },
170
+ body: JSON.stringify(payload),
171
+ signal: controller.signal,
172
+ });
173
+ // Drain the body so the socket can be released even if the receiver wrote something.
174
+ await res.text().catch(() => '');
175
+ return res.ok;
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ finally {
181
+ clearTimeout(timer);
182
+ }
183
+ }
184
+ /** Derive a severity from the notice title so existing call sites gain levels without churn. */
185
+ function inferLevel(title) {
186
+ if (/error|fail|lost|exhausted|crash/i.test(title))
187
+ return 'error';
188
+ if (/warn|unhandled|stale|timeout|retry/i.test(title))
189
+ return 'warn';
190
+ return 'info';
191
+ }
192
+ /** Derive a stable SCREAMING_SNAKE event name (e.g. "Lease Lost" -> "LEASE_LOST"). */
193
+ function inferEvent(title) {
194
+ return title
195
+ .toUpperCase()
196
+ .replace(/[^A-Z0-9]+/g, '_')
197
+ .replace(/^_+|_+$/g, '');
198
+ }
199
+ export function notifyOperator(title, message, opts = {}) {
200
+ const level = opts.level ?? inferLevel(title);
201
+ const event = opts.event ?? inferEvent(title);
202
+ const webhookUrl = (opts.webhookUrl ??
203
+ activeWebhookUrl ??
204
+ process.env.FERRYCAT_WEBHOOK_URL ??
205
+ '').trim();
50
206
  try {
51
207
  const timestamp = new Date().toISOString();
52
208
  // Always emit structured line to stderr/stdout
53
- process.stderr.write(`[FerryCat Daemon Notice] ${timestamp} | ${title}: ${message}\n`);
209
+ process.stderr.write(`[FerryCat Daemon Notice] ${timestamp} | ${level.toUpperCase()} | ${title}: ${message}\n`);
54
210
  }
55
211
  catch (err) {
56
212
  if (err && typeof err === 'object' && 'code' in err && err.code === 'EPIPE') {
@@ -58,11 +214,26 @@ export function notifyOperator(title, message) {
58
214
  return;
59
215
  }
60
216
  }
217
+ // Native toast: skipped when explicitly disabled or when the caller is headless.
218
+ if (opts.native !== false) {
219
+ dispatchNativeNotification(title, message);
220
+ }
221
+ // Webhook: never awaited, never rejects into the caller.
222
+ if (webhookUrl) {
223
+ void dispatchWebhook(webhookUrl, {
224
+ event,
225
+ title,
226
+ message,
227
+ timestamp: new Date().toISOString(),
228
+ level,
229
+ });
230
+ }
61
231
  }
62
232
  export class FerryCatDaemon {
63
233
  engine;
64
234
  config;
65
235
  configPath;
236
+ journalPath;
66
237
  lockId;
67
238
  leaseTtlSec;
68
239
  heartbeatIntervalMs;
@@ -70,8 +241,16 @@ export class FerryCatDaemon {
70
241
  ownerToken;
71
242
  targetAgentFilter;
72
243
  notifyEnabled;
244
+ webhookUrl;
245
+ /** R3: wall-clock start of this daemon instance, journaled so a successor can disambiguate PID reuse. */
246
+ daemonStartedAt = Math.floor(Date.now() - process.uptime() * 1000);
247
+ journalDegraded = false;
248
+ lastJournalError = null;
73
249
  isRunning = false;
74
250
  isLeader = false;
251
+ journalSeq = 0;
252
+ settledSinceLastCompact = 0;
253
+ journalDegradedReported = false;
75
254
  heartbeatTimer = null;
76
255
  pollTimer = null;
77
256
  signalCleanupRegistered = false;
@@ -82,6 +261,20 @@ export class FerryCatDaemon {
82
261
  this.configPath = resolvedPath;
83
262
  this.engine = new FerryCatEngine({ dbPath: options.dbPath });
84
263
  this.lockId = options.lockId ?? config.daemon.lock_id ?? 'global';
264
+ const safeLockSuffix = this.lockId === 'global'
265
+ ? 'daemon-journal.jsonl'
266
+ : `daemon-journal-${this.lockId.replace(/[^a-zA-Z0-9_-]/g, '_')}.jsonl`;
267
+ const customJournal = options.journalPath ?? config.daemon.journal_path;
268
+ this.journalPath = customJournal
269
+ ? path.resolve(expandTilde(customJournal))
270
+ : path.join(path.dirname(this.engine.dbPath), safeLockSuffix);
271
+ // Backfill journalSeq from existing journal entries so seq stays strictly monotonic across restarts
272
+ const existingEntries = this.readJournalEntries();
273
+ for (const entry of existingEntries) {
274
+ if (typeof entry.seq === 'number' && Number.isFinite(entry.seq) && entry.seq > this.journalSeq) {
275
+ this.journalSeq = entry.seq;
276
+ }
277
+ }
85
278
  // Ensure lease TTL is at least 5 seconds
86
279
  const reqTtl = options.leaseTtlSec ?? config.daemon.lease_ttl_sec ?? 30;
87
280
  this.leaseTtlSec = Math.max(5, reqTtl);
@@ -95,6 +288,12 @@ export class FerryCatDaemon {
95
288
  this.ownerToken = `daemon-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
96
289
  this.targetAgentFilter = options.targetAgent;
97
290
  this.notifyEnabled = options.notify ?? config.daemon.notify ?? true;
291
+ this.webhookUrl = config.daemon.webhook_url;
292
+ // FC-403: expose the declarative webhook to notifyOperator() call sites when configured,
293
+ // without wiping a previously registered endpoint when a secondary instance omits webhook_url.
294
+ if (config.daemon.webhook_url !== undefined) {
295
+ setNotifyWebhook(config.daemon.webhook_url);
296
+ }
98
297
  }
99
298
  registerSignalHandlers() {
100
299
  if (this.signalCleanupRegistered)
@@ -102,7 +301,9 @@ export class FerryCatDaemon {
102
301
  this.signalCleanupRegistered = true;
103
302
  const cleanup = (sig) => {
104
303
  if (this.notifyEnabled) {
105
- notifyOperator('Shutdown', `Received ${sig}, terminating children and releasing lease...`);
304
+ notifyOperator('Shutdown', `Received ${sig}, terminating children and releasing lease...`, {
305
+ webhookUrl: this.webhookUrl,
306
+ });
106
307
  }
107
308
  this.stop();
108
309
  terminateActiveChildrenSync();
@@ -114,6 +315,7 @@ export class FerryCatDaemon {
114
315
  process.once('SIGTERM', () => cleanup('SIGTERM'));
115
316
  }
116
317
  acquireLease() {
318
+ const wasLeader = this.isLeader;
117
319
  try {
118
320
  const res = this.engine.acquireDaemonLease({
119
321
  lock_id: this.lockId,
@@ -128,6 +330,9 @@ export class FerryCatDaemon {
128
330
  });
129
331
  if (res.ok) {
130
332
  this.isLeader = true;
333
+ if (!wasLeader) {
334
+ this.recoverFromJournal();
335
+ }
131
336
  return true;
132
337
  }
133
338
  this.isLeader = false;
@@ -136,11 +341,414 @@ export class FerryCatDaemon {
136
341
  catch (err) {
137
342
  this.isLeader = false;
138
343
  if (this.notifyEnabled) {
139
- notifyOperator('Lease Error', `Acquire lease error: ${err instanceof Error ? err.message : String(err)}`);
344
+ notifyOperator('Lease Error', `Acquire lease error: ${err instanceof Error ? err.message : String(err)}`, {
345
+ webhookUrl: this.webhookUrl,
346
+ });
347
+ }
348
+ return false;
349
+ }
350
+ }
351
+ /**
352
+ * Synchronously appends a write-ahead record to `daemon-journal.jsonl` (Phase 3 Step 2).
353
+ * Guaranteed to flush before child spawn and after task completion so sudden SIGKILL / power loss
354
+ * leaves an auditable breadcrumb for the next daemon leader to self-heal.
355
+ */
356
+ appendJournalEntry(partial) {
357
+ this.journalSeq += 1;
358
+ const entry = {
359
+ seq: this.journalSeq,
360
+ timestamp: Date.now(),
361
+ daemon_owner_token: this.ownerToken,
362
+ daemon_pid: process.pid,
363
+ daemon_started_at: this.daemonStartedAt,
364
+ lock_id: this.lockId,
365
+ ...partial,
366
+ };
367
+ try {
368
+ const dir = path.dirname(this.journalPath);
369
+ if (!fs.existsSync(dir)) {
370
+ fs.mkdirSync(dir, { recursive: true });
371
+ }
372
+ let prefix = '';
373
+ if (fs.existsSync(this.journalPath)) {
374
+ const stat = fs.statSync(this.journalPath);
375
+ if (stat.size > 0) {
376
+ const fd = fs.openSync(this.journalPath, 'r');
377
+ try {
378
+ const lastByte = Buffer.alloc(1);
379
+ fs.readSync(fd, lastByte, 0, 1, stat.size - 1);
380
+ if (lastByte[0] !== 0x0a) {
381
+ prefix = '\n';
382
+ }
383
+ }
384
+ finally {
385
+ fs.closeSync(fd);
386
+ }
387
+ }
388
+ }
389
+ fs.appendFileSync(this.journalPath, prefix + JSON.stringify(entry) + '\n', 'utf8');
390
+ this.journalDegraded = false;
391
+ }
392
+ catch (err) {
393
+ // Journal write is best-effort if disk is read-only; never crash the daemon loop,
394
+ // but expose journalDegraded state and emit an operator warning on first transition.
395
+ const wasDegraded = this.journalDegraded;
396
+ this.journalDegraded = true;
397
+ this.lastJournalError = err instanceof Error ? err.message : String(err);
398
+ if (!wasDegraded && this.notifyEnabled) {
399
+ notifyOperator('Journal Degraded', `Failed to append write-ahead journal at '${this.journalPath}': ${this.lastJournalError}`, { level: 'warn', event: 'JOURNAL_DEGRADED', webhookUrl: this.webhookUrl });
400
+ }
401
+ }
402
+ return entry;
403
+ }
404
+ /**
405
+ * Reads all valid JSONL records from this daemon's journal file, tolerating partial/corrupt tail lines.
406
+ *
407
+ * R6 (upgrade compatibility): before per-lock_id journal files existed, every daemon wrote into a
408
+ * single shared `daemon-journal.jsonl`. When this instance is a non-`global` lock whose own file does
409
+ * not exist yet, fall back to that legacy file and keep only the records belonging to this lock_id,
410
+ * so in-flight contracts written by the previous version are still recovered instead of silently
411
+ * stranded as CLAIMED until the TTL sweep kills them.
412
+ */
413
+ readJournalEntries() {
414
+ let readPath = this.journalPath;
415
+ let filterLockId = null;
416
+ if (!fs.existsSync(readPath) && this.lockId !== 'global') {
417
+ const legacyPath = path.join(path.dirname(this.journalPath), 'daemon-journal.jsonl');
418
+ if (fs.existsSync(legacyPath)) {
419
+ readPath = legacyPath;
420
+ filterLockId = this.lockId;
421
+ }
422
+ }
423
+ if (!fs.existsSync(readPath)) {
424
+ return [];
425
+ }
426
+ try {
427
+ const raw = fs.readFileSync(readPath, 'utf8');
428
+ const lines = raw.split(/\r?\n/);
429
+ const entries = [];
430
+ for (const line of lines) {
431
+ const trimmed = line.trim();
432
+ if (!trimmed)
433
+ continue;
434
+ try {
435
+ const parsed = JSON.parse(trimmed);
436
+ if (parsed && typeof parsed === 'object' && parsed.contract_id && parsed.event) {
437
+ if (filterLockId && parsed.lock_id !== filterLockId)
438
+ continue;
439
+ entries.push(parsed);
440
+ }
441
+ }
442
+ catch {
443
+ // Tolerate truncated tail line from sudden power loss / crash
444
+ }
445
+ }
446
+ return entries;
447
+ }
448
+ catch {
449
+ return [];
450
+ }
451
+ }
452
+ /**
453
+ * Scans `daemon-journal.jsonl` upon acquiring leadership and heals any in-flight contracts
454
+ * orphaned by a crashed predecessor daemon instance (Phase 3 Step 2, FC-404).
455
+ *
456
+ * Safety invariants:
457
+ * - CK-05 (PID reuse safety): never sends kill signals to bare numeric PIDs from old runs.
458
+ * - Liveness quarantine: probes `isProcessAlive(child_pid)` and `isProcessAlive(daemon_pid)`.
459
+ * If the predecessor daemon or orphaned child is still alive (e.g. GC pause / lease expiry),
460
+ * quarantines the contract as `FAILED` (`QUARANTINED_ORPHAN_ALIVE`) instead of re-queueing
461
+ * to prevent concurrent double-execution on the same workspace.
462
+ * - Mutation task protection: `implementation` and `test_generation` tasks interrupted mid-flight
463
+ * are never auto-requeued (`MARKED_FAILED_MUTATION_UNSAFE`) to protect working-tree integrity.
464
+ * - Read-only tasks (`code_review` / `architecture_audit`) whose predecessor & child are confirmed
465
+ * dead are released back to the `SUBMITTED` queue (or marked `FAILED` if `MAX_RELEASES` is reached).
466
+ */
467
+ recoverFromJournal() {
468
+ const entries = this.readJournalEntries();
469
+ if (entries.length === 0)
470
+ return [];
471
+ // Group flight state by (contract_id + claim_token) for this lockId
472
+ const inFlightMap = new Map();
473
+ for (const entry of entries) {
474
+ if (entry.lock_id && entry.lock_id !== this.lockId)
475
+ continue;
476
+ const key = `${entry.contract_id}::${entry.claim_token ?? ''}`;
477
+ if (entry.event === 'TASK_DISPATCHING') {
478
+ inFlightMap.set(key, {
479
+ contract_id: entry.contract_id,
480
+ target_agent: entry.target_agent,
481
+ task_type: entry.task_type,
482
+ claim_token: entry.claim_token,
483
+ exec_file: entry.exec_file,
484
+ daemon_owner_token: entry.daemon_owner_token,
485
+ daemon_pid: entry.daemon_pid,
486
+ settled: false,
487
+ });
488
+ }
489
+ else if (entry.event === 'CHILD_SPAWNED') {
490
+ const existing = inFlightMap.get(key);
491
+ if (existing) {
492
+ existing.child_pid = entry.child_pid;
493
+ existing.child_started_at = entry.child_started_at;
494
+ }
495
+ else {
496
+ inFlightMap.set(key, {
497
+ contract_id: entry.contract_id,
498
+ target_agent: entry.target_agent,
499
+ claim_token: entry.claim_token,
500
+ daemon_owner_token: entry.daemon_owner_token,
501
+ daemon_pid: entry.daemon_pid,
502
+ child_pid: entry.child_pid,
503
+ child_started_at: entry.child_started_at,
504
+ settled: false,
505
+ });
506
+ }
507
+ }
508
+ else if (entry.event === 'TASK_SETTLED' || entry.event === 'CRASH_RECOVERED') {
509
+ const existing = inFlightMap.get(key);
510
+ if (existing) {
511
+ existing.settled = true;
512
+ }
513
+ }
514
+ }
515
+ const recovered = [];
516
+ for (const [, flight] of inFlightMap.entries()) {
517
+ if (flight.settled)
518
+ continue;
519
+ if (flight.daemon_owner_token === this.ownerToken)
520
+ continue;
521
+ let action = 'ALREADY_SETTLED';
522
+ try {
523
+ const snap = this.engine.getLeaseSnapshot(flight.contract_id);
524
+ if (!snap || snap.status !== 'CLAIMED') {
525
+ action = 'ALREADY_SETTLED';
526
+ }
527
+ else if (!flight.claim_token && snap.claim_token) {
528
+ // Journal record lacked claim_token while DB row is protected by one: fail explicitly
529
+ try {
530
+ this.engine.recordSalvage({
531
+ contract_id: flight.contract_id,
532
+ agent_id: flight.target_agent,
533
+ claim_token: snap.claim_token,
534
+ salvage: {
535
+ source: 'DAEMON_CRASH_JOURNAL_RECOVERY',
536
+ quarantine_reason: 'MISSING_CLAIM_TOKEN',
537
+ recovered_at: Date.now(),
538
+ recovered_by_daemon: this.ownerToken,
539
+ crashed_daemon_owner_token: flight.daemon_owner_token,
540
+ crashed_daemon_pid: flight.daemon_pid,
541
+ orphaned_child_pid: flight.child_pid ?? null,
542
+ },
543
+ });
544
+ }
545
+ catch {
546
+ // best-effort salvage
547
+ }
548
+ this.engine.failContract({
549
+ contract_id: flight.contract_id,
550
+ agent_id: flight.target_agent,
551
+ claim_token: snap.claim_token,
552
+ reason: `DAEMON_CRASH_MISSING_CLAIM_TOKEN: journal entry from '${flight.daemon_owner_token}' lacked claim_token; marked FAILED to prevent stuck CLAIMED zombie.`,
553
+ });
554
+ action = 'MARKED_FAILED_MISSING_CLAIM_TOKEN';
555
+ }
556
+ else if (!flight.claim_token || !snap.claim_token || snap.claim_token === flight.claim_token) {
557
+ const effectiveToken = flight.claim_token ?? snap.claim_token ?? undefined;
558
+ const effectiveTaskType = flight.task_type ?? snap.task_type;
559
+ const isMutationTask = effectiveTaskType === 'implementation' || effectiveTaskType === 'test_generation';
560
+ const childAlive = Boolean(flight.child_pid && flight.child_pid !== process.pid && isProcessAlive(flight.child_pid));
561
+ const predecessorDaemonAlive = Boolean(flight.daemon_pid && flight.daemon_pid !== process.pid && isProcessAlive(flight.daemon_pid));
562
+ // 1. Record forensic crash salvage before taking recovery action
563
+ try {
564
+ this.engine.recordSalvage({
565
+ contract_id: flight.contract_id,
566
+ agent_id: flight.target_agent,
567
+ claim_token: effectiveToken,
568
+ salvage: {
569
+ source: 'DAEMON_CRASH_JOURNAL_RECOVERY',
570
+ recovered_at: Date.now(),
571
+ recovered_by_daemon: this.ownerToken,
572
+ crashed_daemon_owner_token: flight.daemon_owner_token,
573
+ crashed_daemon_pid: flight.daemon_pid,
574
+ predecessor_daemon_alive: predecessorDaemonAlive,
575
+ orphaned_child_pid: flight.child_pid ?? null,
576
+ orphaned_child_started_at: flight.child_started_at ?? null,
577
+ orphaned_child_alive: childAlive,
578
+ task_type: effectiveTaskType,
579
+ exec_file: flight.exec_file ?? null,
580
+ },
581
+ });
582
+ }
583
+ catch {
584
+ // best-effort salvage
585
+ }
586
+ if (childAlive) {
587
+ // R4: only a live child can still be writing the workspace, so only that case is
588
+ // quarantined. A live-but-stalled predecessor with no child (or an already-exited
589
+ // child) is recoverable: it will notice its own lease loss and clean up.
590
+ this.engine.failContract({
591
+ contract_id: flight.contract_id,
592
+ agent_id: flight.target_agent,
593
+ claim_token: effectiveToken,
594
+ reason: `DAEMON_CRASH_ORPHAN_STILL_ALIVE: orphaned child (pid=${flight.child_pid ?? 'none'}) from predecessor daemon (pid=${flight.daemon_pid}, alive=${predecessorDaemonAlive}) is still running; quarantined as FAILED to prevent concurrent double execution (CK-05).`,
595
+ });
596
+ action = 'QUARANTINED_ORPHAN_ALIVE';
597
+ if (this.notifyEnabled) {
598
+ notifyOperator('Orphan Quarantined', `Quarantined contract ${flight.contract_id} as FAILED because orphaned child (pid=${flight.child_pid ?? 'none'}) is still alive.`, { level: 'error', event: 'ORPHAN_QUARANTINED', webhookUrl: this.webhookUrl });
599
+ }
600
+ }
601
+ else if (isMutationTask) {
602
+ // Mutation task protection: never auto-requeue interrupted implementation/test_generation tasks
603
+ this.engine.failContract({
604
+ contract_id: flight.contract_id,
605
+ agent_id: flight.target_agent,
606
+ claim_token: effectiveToken,
607
+ reason: `DAEMON_CRASH_MUTATION_ABORTED: mutation task (${effectiveTaskType}) interrupted by crash of '${flight.daemon_owner_token}'; auto-requeue disabled to protect workspace integrity.`,
608
+ });
609
+ action = 'MARKED_FAILED_MUTATION_UNSAFE';
610
+ if (this.notifyEnabled) {
611
+ notifyOperator('Mutation Task Aborted', `Aborted interrupted mutation contract ${flight.contract_id} (${effectiveTaskType}) after daemon crash to protect workspace files.`, { level: 'error', event: 'MUTATION_CRASH_ABORTED', webhookUrl: this.webhookUrl });
612
+ }
613
+ }
614
+ else {
615
+ // 2. Read-only task, no live child: release back to queue tail even if the predecessor
616
+ // daemon is still alive (PID reuse / GC stall) — it will self-release on lease loss,
617
+ // and re-queueing keeps the task instead of burning it as terminal FAILED.
618
+ const relRes = this.engine.releaseContract({
619
+ contract_id: flight.contract_id,
620
+ agent_id: flight.target_agent,
621
+ claim_token: effectiveToken,
622
+ reason: `DAEMON_CRASH_RECOVERED: orphaned in-flight task from ${flight.daemon_owner_token} (pid=${flight.daemon_pid}) recovered via daemon-journal.jsonl`,
623
+ });
624
+ if (relRes.ok && relRes.status === 'SUBMITTED') {
625
+ action = 'RELEASED_TO_QUEUE';
626
+ if (this.notifyEnabled) {
627
+ notifyOperator('Crash Recovered', `Recovered orphaned contract ${flight.contract_id} from crashed daemon '${flight.daemon_owner_token}' and re-queued as SUBMITTED.`, { level: 'warn', event: 'CRASH_RECOVERED', webhookUrl: this.webhookUrl });
628
+ }
629
+ }
630
+ else if (!relRes.ok && relRes.status === 'CLAIMED') {
631
+ // Release cap (MAX_RELEASES = 3) reached: fail explicitly so it never hangs as a zombie
632
+ this.engine.failContract({
633
+ contract_id: flight.contract_id,
634
+ agent_id: flight.target_agent,
635
+ claim_token: effectiveToken,
636
+ reason: `DAEMON_CRASH_RECOVERY_EXHAUSTED: contract exceeded max_releases (${relRes.max_releases}) after daemon crash (${flight.daemon_owner_token}).`,
637
+ });
638
+ action = 'MARKED_FAILED_MAX_RELEASES';
639
+ if (this.notifyEnabled) {
640
+ notifyOperator('Crash Recovery Exhausted', `Orphaned contract ${flight.contract_id} exceeded max_releases after crash of '${flight.daemon_owner_token}' and was marked FAILED.`, { level: 'error', event: 'CRASH_RECOVERY_EXHAUSTED', webhookUrl: this.webhookUrl });
641
+ }
642
+ }
643
+ }
644
+ }
645
+ }
646
+ catch {
647
+ action = 'ALREADY_SETTLED';
648
+ }
649
+ this.appendJournalEntry({
650
+ event: 'CRASH_RECOVERED',
651
+ contract_id: flight.contract_id,
652
+ target_agent: flight.target_agent,
653
+ claim_token: flight.claim_token,
654
+ previous_owner_token: flight.daemon_owner_token,
655
+ child_pid: flight.child_pid ?? null,
656
+ recovery_action: action,
657
+ });
658
+ recovered.push({
659
+ contract_id: flight.contract_id,
660
+ target_agent: flight.target_agent,
661
+ claim_token: flight.claim_token,
662
+ previous_owner_token: flight.daemon_owner_token,
663
+ previous_pid: flight.daemon_pid,
664
+ child_pid: flight.child_pid ?? null,
665
+ action,
666
+ });
667
+ }
668
+ // Compact journal if all entries are now settled and file exceeds line threshold
669
+ const totalEntriesAfter = entries.length + recovered.length;
670
+ if (totalEntriesAfter > MAX_JOURNAL_LINES_BEFORE_COMPACT) {
671
+ this.compactJournalIfSettled();
672
+ }
673
+ return recovered;
674
+ }
675
+ compactJournalIfSettled() {
676
+ if (!this.isLeader)
677
+ return false;
678
+ const entries = this.readJournalEntries();
679
+ if (entries.length <= COMPACT_RETAIN_LINES)
680
+ return false;
681
+ const settledKeys = new Set();
682
+ for (const e of entries) {
683
+ if (e.event === 'TASK_SETTLED' || e.event === 'CRASH_RECOVERED') {
684
+ settledKeys.add(`${e.lock_id ?? ''}::${e.contract_id}::${e.claim_token ?? ''}`);
685
+ }
686
+ }
687
+ for (const e of entries) {
688
+ if (e.event === 'TASK_DISPATCHING' || e.event === 'CHILD_SPAWNED') {
689
+ if (!settledKeys.has(`${e.lock_id ?? ''}::${e.contract_id}::${e.claim_token ?? ''}`)) {
690
+ // Do not compact while any flight across any lock_id is still unsettled
691
+ return false;
692
+ }
693
+ }
694
+ }
695
+ const tmpPath = `${this.journalPath}.tmp.${process.pid}.${Date.now()}`;
696
+ try {
697
+ const retained = entries.slice(-COMPACT_RETAIN_LINES);
698
+ const content = retained.map((e) => JSON.stringify(e)).join('\n') + '\n';
699
+ fs.writeFileSync(tmpPath, content, 'utf8');
700
+ fs.renameSync(tmpPath, this.journalPath);
701
+ this.settledSinceLastCompact = 0;
702
+ return true;
703
+ }
704
+ catch {
705
+ try {
706
+ if (fs.existsSync(tmpPath))
707
+ fs.unlinkSync(tmpPath);
708
+ }
709
+ catch {
710
+ // ignore cleanup failure
140
711
  }
141
712
  return false;
142
713
  }
143
714
  }
715
+ /**
716
+ * R7: operator-facing snapshot of the daemon's lease and journal health.
717
+ * Exposes `journalDegraded` / `lastJournalError`, which were previously only internal fields
718
+ * with no reader — a silently degraded write-ahead journal now shows up here and on stderr.
719
+ */
720
+ getStatus() {
721
+ const lease = this.engine.inspectDaemonLease(this.lockId);
722
+ let metadata = null;
723
+ if (lease?.metadata_json) {
724
+ try {
725
+ metadata = JSON.parse(lease.metadata_json);
726
+ }
727
+ catch {
728
+ metadata = lease.metadata_json;
729
+ }
730
+ }
731
+ return {
732
+ lock_id: this.lockId,
733
+ is_leader: this.isLeader,
734
+ owner_token: this.ownerToken,
735
+ daemon_pid: process.pid,
736
+ daemon_started_at: this.daemonStartedAt,
737
+ journal_path: this.journalPath,
738
+ journal_degraded: this.journalDegraded,
739
+ last_journal_error: this.lastJournalError,
740
+ journal_entries: this.readJournalEntries().length,
741
+ lease: lease
742
+ ? {
743
+ owner_token: lease.owner_token,
744
+ heartbeat_at: lease.heartbeat_at,
745
+ expires_at: lease.expires_at,
746
+ metadata,
747
+ }
748
+ : null,
749
+ lease_held_by_self: Boolean(lease && lease.owner_token === this.ownerToken),
750
+ };
751
+ }
144
752
  renewLease() {
145
753
  if (!this.isLeader)
146
754
  return false;
@@ -170,7 +778,7 @@ export class FerryCatDaemon {
170
778
  }
171
779
  terminateActiveChildrenSync();
172
780
  if (this.notifyEnabled) {
173
- notifyOperator('Lease Lost', `Failed to renew lease '${this.lockId}': ${res.message}. In-flight contract released and children terminated.`);
781
+ notifyOperator('Lease Lost', `Failed to renew lease '${this.lockId}': ${res.message}. In-flight contract released and children terminated.`, { webhookUrl: this.webhookUrl });
174
782
  }
175
783
  return false;
176
784
  }
@@ -195,7 +803,7 @@ export class FerryCatDaemon {
195
803
  }
196
804
  terminateActiveChildrenSync();
197
805
  if (this.notifyEnabled) {
198
- notifyOperator('Lease Error', `Heartbeat exception: ${err instanceof Error ? err.message : String(err)}. Contract released and children terminated.`);
806
+ notifyOperator('Lease Error', `Heartbeat exception: ${err instanceof Error ? err.message : String(err)}. Contract released and children terminated.`, { webhookUrl: this.webhookUrl });
199
807
  }
200
808
  return false;
201
809
  }
@@ -231,6 +839,12 @@ export class FerryCatDaemon {
231
839
  * Process a single cycle: check lease, poll task, execute configured handler if matched.
232
840
  */
233
841
  async pollAndDispatchOnce() {
842
+ // R7: a silently degraded write-ahead journal means crash recovery is not actually armed.
843
+ // Surface it once on stderr (and via getStatus()) instead of failing mute.
844
+ if (this.journalDegraded && !this.journalDegradedReported) {
845
+ this.journalDegradedReported = true;
846
+ process.stderr.write(`[FerryCat Daemon] WARN write-ahead journal degraded at "${this.journalPath}": ${this.lastJournalError ?? 'unknown error'}. Crash recovery is not armed until writes succeed again.\n`);
847
+ }
234
848
  if (!this.isLeader) {
235
849
  const acquired = this.acquireLease();
236
850
  if (!acquired) {
@@ -266,7 +880,7 @@ export class FerryCatDaemon {
266
880
  }
267
881
  }
268
882
  }
269
- const pollResult = this.engine.pollNextTask({
883
+ const pollResult = await this.engine.pollNextTask({
270
884
  agent_id: agentId,
271
885
  task_types: eligibleTaskTypes,
272
886
  });
@@ -302,15 +916,49 @@ export class FerryCatDaemon {
302
916
  target_agent: contract.target_agent,
303
917
  claim_token: contract.claim_token,
304
918
  };
919
+ // Write-ahead journal before spawning child (Phase 3 Step 2)
920
+ this.appendJournalEntry({
921
+ event: 'TASK_DISPATCHING',
922
+ contract_id: contract.contract_id,
923
+ target_agent: contract.target_agent,
924
+ task_type: contract.task_type,
925
+ claim_token: contract.claim_token,
926
+ exec_file: handler.exec_file,
927
+ });
928
+ let settledStatus = 'ABORTED';
305
929
  try {
306
930
  // Forward configured args and pass_env (contract's own exec_ttl_sec is authoritative)
307
931
  const execResult = await executeTaskAndFulfill(this.engine, contract, {
308
932
  execFile: handler.exec_file,
309
933
  passEnv: handler.pass_env,
310
934
  args: handler.args,
935
+ delivery: handler.delivery,
936
+ }, {
937
+ onEvent: (ev) => {
938
+ if (ev.event === 'CHILD_SPAWNED') {
939
+ this.appendJournalEntry({
940
+ event: 'CHILD_SPAWNED',
941
+ contract_id: contract.contract_id,
942
+ target_agent: contract.target_agent,
943
+ claim_token: contract.claim_token,
944
+ child_pid: ev.pid ?? null,
945
+ child_started_at: ev.started_at ?? Date.now(),
946
+ });
947
+ }
948
+ },
311
949
  });
950
+ settledStatus = execResult.status;
312
951
  if (this.notifyEnabled) {
313
- notifyOperator('Task Finished', `Contract ${contract.contract_id} concluded with status '${execResult.status}' (ok=${execResult.ok})`);
952
+ const finishLevel = execResult.ok
953
+ ? 'info'
954
+ : execResult.status === 'RETRY_NEEDED'
955
+ ? 'warn'
956
+ : 'error';
957
+ notifyOperator('Task Finished', `Contract ${contract.contract_id} concluded with status '${execResult.status}' (ok=${execResult.ok})`, {
958
+ level: finishLevel,
959
+ event: execResult.ok ? 'TASK_FINISHED' : 'TASK_FAILED',
960
+ webhookUrl: this.webhookUrl,
961
+ });
314
962
  }
315
963
  return {
316
964
  processed: true,
@@ -319,7 +967,18 @@ export class FerryCatDaemon {
319
967
  };
320
968
  }
321
969
  finally {
970
+ this.appendJournalEntry({
971
+ event: 'TASK_SETTLED',
972
+ contract_id: contract.contract_id,
973
+ target_agent: contract.target_agent,
974
+ claim_token: contract.claim_token,
975
+ final_status: settledStatus,
976
+ });
322
977
  this.activeInFlightContract = null;
978
+ this.settledSinceLastCompact += 1;
979
+ if (this.settledSinceLastCompact >= MAX_JOURNAL_LINES_BEFORE_COMPACT) {
980
+ this.compactJournalIfSettled();
981
+ }
323
982
  }
324
983
  }
325
984
  async start() {
@@ -334,7 +993,9 @@ export class FerryCatDaemon {
334
993
  }
335
994
  catch (err) {
336
995
  if (this.notifyEnabled) {
337
- notifyOperator('Heartbeat Ticker Error', err instanceof Error ? err.message : String(err));
996
+ notifyOperator('Heartbeat Ticker Error', err instanceof Error ? err.message : String(err), {
997
+ webhookUrl: this.webhookUrl,
998
+ });
338
999
  }
339
1000
  }
340
1001
  }, this.heartbeatIntervalMs);
@@ -347,7 +1008,9 @@ export class FerryCatDaemon {
347
1008
  }
348
1009
  catch (err) {
349
1010
  if (this.notifyEnabled) {
350
- notifyOperator('Daemon Loop Error', err instanceof Error ? err.message : String(err));
1011
+ notifyOperator('Daemon Loop Error', err instanceof Error ? err.message : String(err), {
1012
+ webhookUrl: this.webhookUrl,
1013
+ });
351
1014
  }
352
1015
  }
353
1016
  if (this.isRunning) {