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/README.md +97 -14
- package/README_zh.md +93 -14
- package/bin/ferrycat.js +20 -1
- package/dist/cli.js +196 -14
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +137 -1
- package/dist/daemon.js +673 -10
- package/dist/daemon.js.map +1 -1
- package/dist/delivery.d.ts +48 -0
- package/dist/delivery.js +158 -0
- package/dist/delivery.js.map +1 -0
- package/dist/demo.d.ts +36 -0
- package/dist/demo.js +343 -0
- package/dist/demo.js.map +1 -0
- package/dist/engine.d.ts +15 -1
- package/dist/engine.js +287 -29
- package/dist/engine.js.map +1 -1
- package/dist/fingerprint.d.ts +60 -4
- package/dist/fingerprint.js +438 -9
- package/dist/fingerprint.js.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +62 -0
- package/dist/init.js +356 -0
- package/dist/init.js.map +1 -0
- package/dist/schemas.d.ts +64 -0
- package/dist/schemas.js +30 -0
- package/dist/schemas.js.map +1 -1
- package/dist/supervisor.d.ts +10 -0
- package/dist/supervisor.js +34 -26
- package/dist/supervisor.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/package.json +16 -4
- package/server.json +3 -3
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
|
-
|
|
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, '&')
|
|
89
|
+
.replace(/</g, '<')
|
|
90
|
+
.replace(/>/g, '>')
|
|
91
|
+
.replace(/"/g, '"')
|
|
92
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
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) {
|