@nanhara/hara 0.122.3 → 0.122.5
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/CHANGELOG.md +117 -0
- package/README.md +15 -2
- package/SECURITY.md +4 -0
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +28 -14
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +3 -3
- package/runtime-bootstrap.cjs +22 -0
package/dist/cron/runner.js
CHANGED
|
@@ -7,11 +7,12 @@ import { appendFileSync, chmodSync, closeSync, existsSync, fsyncSync, lstatSync,
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { platform } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { loadJobs, recordRun,
|
|
10
|
+
import { loadJobs, recordRun, recordRunStart, recoverInterruptedRuns, enqueueOutcomeNotifications, listPendingNotifications, acknowledgePendingNotification, deferPendingNotification, findJob, cronDir, logPath, } from "./store.js";
|
|
11
11
|
import { isDue } from "./schedule.js";
|
|
12
12
|
import { shellCommand } from "../sandbox.js";
|
|
13
13
|
import { sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
14
14
|
import { createToolOutputLineRedactor, redactToolSubprocessOutput, terminateSubprocessTree, toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
15
|
+
import { compareProcessIdentity, defaultProcessIdentity } from "../process-identity.js";
|
|
15
16
|
/** Jobs that are enabled AND due at `nowMs` (pure — for the tick and for testing). */
|
|
16
17
|
export function dueJobs(jobs, nowMs) {
|
|
17
18
|
return jobs.filter((j) => j.enabled && isDue(j, nowMs));
|
|
@@ -19,24 +20,80 @@ export function dueJobs(jobs, nowMs) {
|
|
|
19
20
|
/** How to invoke hara again. Under node, argv[1] is the entry to hand back to node — either `dist/index.js`
|
|
20
21
|
* OR the installed `hara` bin symlink (node runs both); as a compiled single-binary, execPath itself IS hara
|
|
21
22
|
* (argv[1] is a user arg), so re-invoke the binary directly. Used by the cron tick + the chat gateway.
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
* Node and ordinary Bun scripts retain argv[1] (the bin symlink need not end in `.js`); only Bun's
|
|
24
|
+
* `/$bunfs/…` compile-time virtual entry is omitted because execPath is already the standalone Hara. */
|
|
25
|
+
export function selfArgvFor(exec, entry, versions) {
|
|
25
26
|
const underNode = /(^|[\\/])node(\.exe)?$/i.test(exec);
|
|
26
|
-
|
|
27
|
+
const bunScript = typeof versions.bun === "string" && !!entry && !entry.replace(/\\/g, "/").startsWith("/$bunfs/");
|
|
28
|
+
return entry && (underNode || bunScript) ? [exec, entry] : [exec];
|
|
29
|
+
}
|
|
30
|
+
export function selfArgv() {
|
|
31
|
+
return selfArgvFor(process.execPath, process.argv[1], process.versions);
|
|
32
|
+
}
|
|
33
|
+
/** Add user-facing arguments to the runtime-aware self command. Exported separately so Node and compiled
|
|
34
|
+
* argv construction can be unit-tested without launching a second CLI. */
|
|
35
|
+
export function selfInvocation(args) {
|
|
36
|
+
const [command, ...entryArgs] = selfArgv();
|
|
37
|
+
return { command, args: [...entryArgs, ...args] };
|
|
38
|
+
}
|
|
39
|
+
/** Re-enter Hara as an attached foreground child without blocking this process's event loop. Inheriting all
|
|
40
|
+
* stdio keeps readline/Ink attached to the real terminal; this is required by `hara resume <id>`. */
|
|
41
|
+
export function runSelfAttached(args, cwd = process.cwd()) {
|
|
42
|
+
const invocation = selfInvocation(args);
|
|
43
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
44
|
+
let settled = false;
|
|
45
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
46
|
+
cwd,
|
|
47
|
+
env: process.env,
|
|
48
|
+
stdio: "inherit",
|
|
49
|
+
});
|
|
50
|
+
child.once("error", (error) => {
|
|
51
|
+
if (settled)
|
|
52
|
+
return;
|
|
53
|
+
settled = true;
|
|
54
|
+
rejectRun(error);
|
|
55
|
+
});
|
|
56
|
+
child.once("close", (code, signal) => {
|
|
57
|
+
if (settled)
|
|
58
|
+
return;
|
|
59
|
+
settled = true;
|
|
60
|
+
resolveRun({ code, signal });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
27
63
|
}
|
|
28
64
|
const lockPath = () => join(cronDir(), ".tick.lock");
|
|
29
65
|
const takeoverPath = () => join(cronDir(), ".tick.lock.takeover");
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
66
|
+
// Malformed records have no process identity to verify. Keep a long poison window for the primary and a
|
|
67
|
+
// shorter one for the synchronous transition guard; valid live/legacy records are never reclaimed by age.
|
|
68
|
+
const MALFORMED_LOCK_POISON_MS = 6 * 60 * 60_000;
|
|
69
|
+
const MALFORMED_GUARD_POISON_MS = 5 * 60_000;
|
|
70
|
+
export const DEFAULT_CRON_JOB_TIMEOUT_MS = 30 * 60_000;
|
|
71
|
+
export const DEFAULT_CRON_TICK_TIMEOUT_MS = 60 * 60_000;
|
|
72
|
+
export const MAX_CRON_JOB_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
73
|
+
// Keep normal scheduler ownership bounded even though a valid live owner is never reclaimed by wall-clock age.
|
|
74
|
+
export const MAX_CRON_TICK_TIMEOUT_MS = 5 * 60 * 60_000;
|
|
37
75
|
const DEFAULT_RUN_LOG_BYTES = 1_000_000;
|
|
38
76
|
const TERMINATE_GRACE_MS = 2_000;
|
|
77
|
+
const ABORT_SETTLE_MS = 750;
|
|
78
|
+
const FINAL_DELIVERY_TIMEOUT_MS = 5_000;
|
|
79
|
+
function configuredTimeoutMs(explicit, envName, fallback, maximum) {
|
|
80
|
+
const envValue = process.env[envName]?.trim();
|
|
81
|
+
const candidate = explicit ?? (envValue ? Number(envValue) : fallback);
|
|
82
|
+
return Number.isFinite(candidate)
|
|
83
|
+
? Math.min(Math.max(100, Math.trunc(candidate)), maximum)
|
|
84
|
+
: fallback;
|
|
85
|
+
}
|
|
86
|
+
/** Public for diagnostics/tests; scheduler deployments can override with HARA_CRON_JOB_TIMEOUT_MS. */
|
|
87
|
+
export function cronJobTimeoutMs(explicit) {
|
|
88
|
+
return configuredTimeoutMs(explicit, "HARA_CRON_JOB_TIMEOUT_MS", DEFAULT_CRON_JOB_TIMEOUT_MS, MAX_CRON_JOB_TIMEOUT_MS);
|
|
89
|
+
}
|
|
90
|
+
/** Total scheduler-tick watchdog; configurable in milliseconds and hard-capped to bound normal ownership. */
|
|
91
|
+
export function cronTickTimeoutMs(explicit) {
|
|
92
|
+
return configuredTimeoutMs(explicit, "HARA_CRON_TICK_TIMEOUT_MS", DEFAULT_CRON_TICK_TIMEOUT_MS, MAX_CRON_TICK_TIMEOUT_MS);
|
|
93
|
+
}
|
|
39
94
|
const MAX_LOCK_RECORD_BYTES = 512;
|
|
95
|
+
const TICK_LOCK_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
96
|
+
const CURRENT_PROCESS_BIRTH_IDENTITY = defaultProcessIdentity(process.pid);
|
|
40
97
|
function processIsAlive(pid) {
|
|
41
98
|
try {
|
|
42
99
|
process.kill(pid, 0);
|
|
@@ -69,11 +126,34 @@ function readTickLockFileSnapshot(path) {
|
|
|
69
126
|
function parseTickLockSnapshot(file) {
|
|
70
127
|
if (!file?.raw)
|
|
71
128
|
return null;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(file.raw);
|
|
131
|
+
if (!parsed
|
|
132
|
+
|| typeof parsed !== "object"
|
|
133
|
+
|| !Number.isSafeInteger(parsed.pid)
|
|
134
|
+
|| parsed.pid <= 0
|
|
135
|
+
|| typeof parsed.token !== "string"
|
|
136
|
+
|| !TICK_LOCK_UUID.test(parsed.token)
|
|
137
|
+
|| (parsed.birthIdentity !== undefined
|
|
138
|
+
&& (typeof parsed.birthIdentity !== "string" || !/^[\x20-\x7e]{1,256}$/.test(parsed.birthIdentity))))
|
|
139
|
+
return null;
|
|
140
|
+
return {
|
|
141
|
+
...file,
|
|
142
|
+
raw: file.raw,
|
|
143
|
+
pid: parsed.pid,
|
|
144
|
+
token: parsed.token,
|
|
145
|
+
...(typeof parsed.birthIdentity === "string" ? { birthIdentity: parsed.birthIdentity } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Backward compatibility for the unpublished identity-less lock format. A live legacy PID is unknown,
|
|
150
|
+
// never stale evidence; a proven-dead PID remains safe to reclaim.
|
|
151
|
+
const match = /^(\d+):([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/iu.exec(file.raw);
|
|
152
|
+
const pid = Number(match?.[1]);
|
|
153
|
+
if (!match || !Number.isSafeInteger(pid) || pid <= 0)
|
|
154
|
+
return null;
|
|
155
|
+
return { ...file, raw: file.raw, pid, token: match[2] };
|
|
156
|
+
}
|
|
77
157
|
}
|
|
78
158
|
function readTickLockSnapshot(path) {
|
|
79
159
|
return parseTickLockSnapshot(readTickLockFileSnapshot(path));
|
|
@@ -90,12 +170,27 @@ function sameTickLockFile(left, right) {
|
|
|
90
170
|
function sameTickLock(left, right) {
|
|
91
171
|
return sameTickLockFile(left, right) && left.pid === right?.pid;
|
|
92
172
|
}
|
|
93
|
-
|
|
94
|
-
|
|
173
|
+
/** Only proof may retire a valid owner: dead PID, or same identity scheme with a different birth value.
|
|
174
|
+
* A live same/unknown/legacy owner fails closed even after host sleep or event-loop suspension. */
|
|
175
|
+
function reclaimableTickLock(snapshot) {
|
|
176
|
+
if (!processIsAlive(snapshot.pid))
|
|
177
|
+
return true;
|
|
178
|
+
if (!snapshot.birthIdentity)
|
|
179
|
+
return false;
|
|
180
|
+
const current = snapshot.pid === process.pid
|
|
181
|
+
? CURRENT_PROCESS_BIRTH_IDENTITY
|
|
182
|
+
: defaultProcessIdentity(snapshot.pid);
|
|
183
|
+
return compareProcessIdentity(snapshot.birthIdentity, current) === "different";
|
|
95
184
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
185
|
+
function newTickLockToken() {
|
|
186
|
+
return JSON.stringify({
|
|
187
|
+
pid: process.pid,
|
|
188
|
+
token: randomUUID(),
|
|
189
|
+
...(CURRENT_PROCESS_BIRTH_IDENTITY ? { birthIdentity: CURRENT_PROCESS_BIRTH_IDENTITY } : {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/** Recover only a complete, stable guard whose owner is proven dead/reused. Age applies only to malformed
|
|
193
|
+
* poison, never to a valid live/legacy record. Recovery deliberately ends this tick. */
|
|
99
194
|
function prepareTakeoverGuard(path, nowMs) {
|
|
100
195
|
if (!existsSync(path))
|
|
101
196
|
return "clear";
|
|
@@ -103,8 +198,8 @@ function prepareTakeoverGuard(path, nowMs) {
|
|
|
103
198
|
if (!observedFile)
|
|
104
199
|
return "active"; // unreadable/non-regular/unstable is not evidence that deletion is safe
|
|
105
200
|
const observed = parseTickLockSnapshot(observedFile);
|
|
106
|
-
const staleRecord = (snapshot) =>
|
|
107
|
-
const staleMalformed = (snapshot) => nowMs - snapshot.mtimeMs >=
|
|
201
|
+
const staleRecord = (snapshot) => reclaimableTickLock(snapshot);
|
|
202
|
+
const staleMalformed = (snapshot) => nowMs - snapshot.mtimeMs >= MALFORMED_GUARD_POISON_MS;
|
|
108
203
|
if (observed ? !staleRecord(observed) : !staleMalformed(observedFile))
|
|
109
204
|
return "active";
|
|
110
205
|
const currentFile = readTickLockFileSnapshot(path);
|
|
@@ -168,10 +263,10 @@ function removeOwnedLock(path, token) {
|
|
|
168
263
|
}
|
|
169
264
|
catch { /* best effort */ }
|
|
170
265
|
}
|
|
171
|
-
/** Release the primary lock under the same guard used for stale takeover. Without this, a
|
|
266
|
+
/** Release the primary lock under the same guard used for stale takeover. Without this, a paused owner could
|
|
172
267
|
* read its token, get descheduled while a reaper installs a successor, then unlink that successor by name. */
|
|
173
268
|
function releasePrimaryTickLock(lock, takeover, token) {
|
|
174
|
-
const releaseToken =
|
|
269
|
+
const releaseToken = newTickLockToken();
|
|
175
270
|
if (!writeExclusive(takeover, releaseToken))
|
|
176
271
|
return; // an active reaper owns the transition
|
|
177
272
|
try {
|
|
@@ -200,8 +295,11 @@ function capLog(log) {
|
|
|
200
295
|
/** Run one job's task in a fresh hara process (full-auto, no prompts), appending output to its log.
|
|
201
296
|
* Exported so `hara cron run <id>` can fire a job on demand, ignoring its schedule. */
|
|
202
297
|
export function runJobOnce(job, options = {}) {
|
|
298
|
+
if (options.signal?.aborted) {
|
|
299
|
+
return Promise.resolve({ ok: false, error: "interrupted before cron job start by agent run deadline or cancellation", output: "", interrupted: true });
|
|
300
|
+
}
|
|
203
301
|
return new Promise((resolve) => {
|
|
204
|
-
const timeoutMs =
|
|
302
|
+
const timeoutMs = cronJobTimeoutMs(options.timeoutMs);
|
|
205
303
|
const maxLogBytes = Math.min(Math.max(4_096, options.maxLogBytes ?? DEFAULT_RUN_LOG_BYTES), 16 * 1024 * 1024);
|
|
206
304
|
mkdirSync(join(cronDir(), "logs"), { recursive: true, mode: 0o700 });
|
|
207
305
|
try {
|
|
@@ -257,6 +355,10 @@ export function runJobOnce(job, options = {}) {
|
|
|
257
355
|
? toolSubprocessEnv(process.env, { HARA_CRON: "1" })
|
|
258
356
|
: { ...process.env, HARA_CRON: "1", HARA_CRON_NAME: job.name };
|
|
259
357
|
const processGroup = platform() !== "win32";
|
|
358
|
+
if (options.signal?.aborted) {
|
|
359
|
+
resolve({ ok: false, error: "interrupted before cron job start by agent run deadline or cancellation", output: "", interrupted: true });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
260
362
|
let child;
|
|
261
363
|
try {
|
|
262
364
|
child = spawn(cmd, argv, { cwd: job.cwd, env, detached: processGroup });
|
|
@@ -275,6 +377,8 @@ export function runJobOnce(job, options = {}) {
|
|
|
275
377
|
let logCapped = false;
|
|
276
378
|
let done = false;
|
|
277
379
|
let timedOut = false;
|
|
380
|
+
let aborted = false;
|
|
381
|
+
let abortFallback;
|
|
278
382
|
let forceIssued = false;
|
|
279
383
|
let closeBeforeForce = false;
|
|
280
384
|
let cancelTermination;
|
|
@@ -309,6 +413,9 @@ export function runJobOnce(job, options = {}) {
|
|
|
309
413
|
return;
|
|
310
414
|
done = true;
|
|
311
415
|
clearTimeout(timeoutTimer);
|
|
416
|
+
if (abortFallback)
|
|
417
|
+
clearTimeout(abortFallback);
|
|
418
|
+
options.signal?.removeEventListener("abort", abortRun);
|
|
312
419
|
// Keep a timeout-triggered group KILL scheduled even if the direct child closed on TERM. The default
|
|
313
420
|
// cancellation removes only the hard-settle fallback; it deliberately does not cancel escalation.
|
|
314
421
|
cancelTermination?.();
|
|
@@ -320,6 +427,19 @@ export function runJobOnce(job, options = {}) {
|
|
|
320
427
|
output: tail,
|
|
321
428
|
});
|
|
322
429
|
};
|
|
430
|
+
const abortRun = () => {
|
|
431
|
+
if (done || timedOut || aborted)
|
|
432
|
+
return;
|
|
433
|
+
aborted = true;
|
|
434
|
+
// A parent run deadline is already the hard boundary: kill immediately rather than adding the normal
|
|
435
|
+
// cron timeout grace. The owned process group includes shell/node descendants.
|
|
436
|
+
terminateSubprocessTree(child, { force: true, processGroup });
|
|
437
|
+
abortFallback = setTimeout(() => {
|
|
438
|
+
child.stdout?.destroy();
|
|
439
|
+
child.stderr?.destroy();
|
|
440
|
+
settle({ ok: false, error: "interrupted by agent run deadline or cancellation", output: tail, interrupted: true });
|
|
441
|
+
}, 750);
|
|
442
|
+
};
|
|
323
443
|
const timeoutTimer = setTimeout(() => {
|
|
324
444
|
if (done)
|
|
325
445
|
return;
|
|
@@ -331,79 +451,255 @@ export function runJobOnce(job, options = {}) {
|
|
|
331
451
|
onForce: () => {
|
|
332
452
|
forceIssued = true;
|
|
333
453
|
if (closeBeforeForce)
|
|
334
|
-
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail });
|
|
454
|
+
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail, timedOut: true });
|
|
335
455
|
},
|
|
336
456
|
onFallback: () => {
|
|
337
457
|
child.stdout?.destroy();
|
|
338
458
|
child.stderr?.destroy();
|
|
339
|
-
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail });
|
|
459
|
+
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail, timedOut: true });
|
|
340
460
|
},
|
|
341
461
|
});
|
|
342
462
|
}, timeoutMs);
|
|
343
|
-
|
|
463
|
+
options.signal?.addEventListener("abort", abortRun, { once: true });
|
|
464
|
+
if (options.signal?.aborted)
|
|
465
|
+
abortRun();
|
|
466
|
+
child.stdout?.on("data", (d) => { if (!done && !timedOut && !aborted)
|
|
344
467
|
stdout.push(d.toString()); });
|
|
345
|
-
child.stderr?.on("data", (d) => { if (!done && !timedOut)
|
|
468
|
+
child.stderr?.on("data", (d) => { if (!done && !timedOut && !aborted)
|
|
346
469
|
stderr.push(d.toString()); });
|
|
347
470
|
child.on("error", (e) => {
|
|
471
|
+
if (aborted) {
|
|
472
|
+
settle({ ok: false, error: "interrupted by agent run deadline or cancellation", output: tail, interrupted: true });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
348
475
|
if (timedOut && !forceIssued) {
|
|
349
476
|
closeBeforeForce = true;
|
|
350
477
|
return;
|
|
351
478
|
}
|
|
352
479
|
settle(timedOut
|
|
353
|
-
? { ok: false, error: `timed out after ${timeoutMs}ms`, output: tail }
|
|
480
|
+
? { ok: false, error: `timed out after ${timeoutMs}ms`, output: tail, timedOut: true }
|
|
354
481
|
: { ok: false, error: String(e?.message ?? e), output: tail });
|
|
355
482
|
});
|
|
356
483
|
child.on("close", (code) => {
|
|
484
|
+
if (aborted) {
|
|
485
|
+
settle({ ok: false, error: "interrupted by agent run deadline or cancellation", output: tail, interrupted: true });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
357
488
|
if (timedOut && !forceIssued) {
|
|
358
489
|
closeBeforeForce = true;
|
|
359
490
|
return;
|
|
360
491
|
}
|
|
361
492
|
if (timedOut)
|
|
362
|
-
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail });
|
|
493
|
+
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail, timedOut: true });
|
|
363
494
|
else
|
|
364
495
|
settle(code === 0 ? { ok: true, output: tail } : { ok: false, error: `exited ${code}`, output: tail });
|
|
365
496
|
});
|
|
366
497
|
});
|
|
367
498
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
499
|
+
function terminalStatus(result) {
|
|
500
|
+
return result.ok ? "ok" : result.timedOut ? "timed_out" : "error";
|
|
501
|
+
}
|
|
502
|
+
function safeRunFailure(error) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
error: redactToolSubprocessOutput(error instanceof Error ? error.message : String(error)),
|
|
506
|
+
output: "",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
/** Manual-run lifecycle wrapper: persistence happens before the child starts and is always closed out. */
|
|
510
|
+
export async function runJobTracked(job, options = {}) {
|
|
511
|
+
const startedAt = Date.now();
|
|
512
|
+
let runningToken;
|
|
513
|
+
try {
|
|
514
|
+
runningToken = recordRunStart(job.id, startedAt);
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
return { ok: false, error: `failed to persist cron running state: ${safeRunFailure(error).error}` };
|
|
518
|
+
}
|
|
519
|
+
if (!runningToken) {
|
|
520
|
+
let current;
|
|
378
521
|
try {
|
|
379
|
-
|
|
522
|
+
current = findJob(job.id);
|
|
523
|
+
if (current?.lastStatus === "running") {
|
|
524
|
+
// A dead parent may still have a detached child. Persist the same fail-closed recovery used by ticks,
|
|
525
|
+
// but do not continue into a new run: the operator must inspect and retry explicitly.
|
|
526
|
+
const recovered = recoverInterruptedRuns(startedAt, job.id).find((entry) => entry.job.id === job.id);
|
|
527
|
+
if (recovered) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
error: `previous cron attempt was interrupted and the job was disabled; Hara refused to overlap a possible orphaned child. Inspect the process/workspace, then run \`hara cron run ${job.id}\` again explicitly`,
|
|
531
|
+
output: "",
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
current = findJob(job.id);
|
|
535
|
+
if (current?.lastStatus === "running") {
|
|
536
|
+
const pid = current.runningPid ? ` (owner pid ${current.runningPid})` : "";
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
error: `cron job already has an unresolved running attempt${pid}; Hara refused to overlap it. Wait for it to finish or stop/recover that owner before retrying`,
|
|
540
|
+
output: "",
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
380
544
|
}
|
|
381
|
-
catch {
|
|
382
|
-
|
|
545
|
+
catch (error) {
|
|
546
|
+
return { ok: false, error: `failed to inspect/recover cron running state: ${safeRunFailure(error).error}`, output: "" };
|
|
383
547
|
}
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
error: current?.lastError
|
|
551
|
+
? `cron job could not start: ${current.lastError}`
|
|
552
|
+
: "cron job could not start because it no longer exists or its state changed concurrently",
|
|
553
|
+
output: "",
|
|
554
|
+
};
|
|
384
555
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
556
|
+
let result;
|
|
557
|
+
try {
|
|
558
|
+
result = await runJobOnce(job, options);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
result = safeRunFailure(error);
|
|
562
|
+
}
|
|
563
|
+
const finishedAt = Date.now();
|
|
564
|
+
recordRun(job.id, finishedAt, terminalStatus(result), result.error, finishedAt - startedAt, runningToken);
|
|
565
|
+
return result;
|
|
566
|
+
}
|
|
567
|
+
/** Attempt a bounded slice of the durable queue. Transport failure updates backoff but never removes the
|
|
568
|
+
* effect; confirmed success atomically acknowledges it (and starts alert cooldown). */
|
|
569
|
+
export async function deliverPendingNotifications(deliver = deliverResult, nowMs = Date.now(), signal, options = {}) {
|
|
570
|
+
const pending = listPendingNotifications(nowMs, options.limit ?? 8, options.jobId)
|
|
571
|
+
.filter((notification) => !options.ids || options.ids.has(notification.id));
|
|
572
|
+
let acknowledged = 0;
|
|
573
|
+
for (const notification of pending) {
|
|
574
|
+
if (signal?.aborted)
|
|
575
|
+
break;
|
|
576
|
+
let error;
|
|
577
|
+
try {
|
|
578
|
+
error = await deliver(notification.target, notification.text, signal, notification.id);
|
|
579
|
+
}
|
|
580
|
+
catch (cause) {
|
|
581
|
+
error = `delivery failed: ${safeRunFailure(cause).error ?? "transport request failed"}`;
|
|
582
|
+
}
|
|
583
|
+
if (!error) {
|
|
584
|
+
acknowledgePendingNotification(notification.jobId, notification.id, Date.now());
|
|
585
|
+
acknowledged++;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
deferPendingNotification(notification.jobId, notification.id, error, Date.now());
|
|
589
|
+
try {
|
|
590
|
+
appendFileSync(logPath(notification.jobId), `\n[${notification.kind === "alert" ? "alert" : "deliver"}] ${error}\n`);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
/* the durable queue is authoritative; the human-readable log is best effort */
|
|
393
594
|
}
|
|
394
595
|
}
|
|
596
|
+
return acknowledged;
|
|
597
|
+
}
|
|
598
|
+
/** Public one-off wrapper retained for embedders/tests. Unlike the old best-effort implementation, intent is
|
|
599
|
+
* persisted before transport and keeps the same idempotency key until confirmed. Production ticks enqueue
|
|
600
|
+
* atomically inside recordRun and call `deliverPendingNotifications` directly. */
|
|
601
|
+
export async function deliverOutcome(job, r, deliver = deliverResult, nowMs = Date.now(), signal) {
|
|
602
|
+
const ids = enqueueOutcomeNotifications(job.id, r, nowMs);
|
|
603
|
+
if (!ids.length)
|
|
604
|
+
return;
|
|
605
|
+
await deliverPendingNotifications(deliver, nowMs, signal, {
|
|
606
|
+
limit: Math.min(64, ids.length),
|
|
607
|
+
jobId: job.id,
|
|
608
|
+
ids: new Set(ids),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/** Hard-race a runner as well as passing it a signal. The race is intentional: a buggy/custom runner that
|
|
612
|
+
* ignores AbortSignal must not retain the global tick lock forever. The production runner owns a detached
|
|
613
|
+
* process group and force-kills it synchronously when this controller aborts. */
|
|
614
|
+
async function runOneWithinTick(job, run, jobTimeoutMs, tickTimeoutMs, tickSignal, tickWatchdogSignal) {
|
|
615
|
+
const jobDeadline = new AbortController();
|
|
616
|
+
const signal = AbortSignal.any([tickSignal, jobDeadline.signal]);
|
|
617
|
+
let jobTimer;
|
|
618
|
+
let settleTimer;
|
|
619
|
+
let onTickAbort;
|
|
620
|
+
let jobTimedOut = false;
|
|
621
|
+
const boundary = new Promise((resolve) => {
|
|
622
|
+
let settled = false;
|
|
623
|
+
const finish = (outcome) => {
|
|
624
|
+
if (settled)
|
|
625
|
+
return;
|
|
626
|
+
settled = true;
|
|
627
|
+
resolve(outcome);
|
|
628
|
+
};
|
|
629
|
+
jobTimer = setTimeout(() => {
|
|
630
|
+
const error = `timed out after ${jobTimeoutMs}ms`;
|
|
631
|
+
jobTimedOut = true;
|
|
632
|
+
jobDeadline.abort(new Error(error));
|
|
633
|
+
// Production runJobOnce force-kills its owned process group synchronously, then settles on close. Give
|
|
634
|
+
// that cleanup a short bounded window before starting the next due job; a runner that ignores both the
|
|
635
|
+
// signal and the hard race still cannot retain the tick forever.
|
|
636
|
+
settleTimer = setTimeout(() => {
|
|
637
|
+
finish({ result: { ok: false, error, output: "", timedOut: true } });
|
|
638
|
+
}, ABORT_SETTLE_MS);
|
|
639
|
+
}, jobTimeoutMs);
|
|
640
|
+
onTickAbort = () => {
|
|
641
|
+
const watchdog = tickWatchdogSignal.aborted;
|
|
642
|
+
const error = watchdog ? `tick watchdog timed out after ${tickTimeoutMs}ms` : "tick cancelled by caller";
|
|
643
|
+
finish({
|
|
644
|
+
result: {
|
|
645
|
+
ok: false,
|
|
646
|
+
error,
|
|
647
|
+
output: "",
|
|
648
|
+
...(watchdog ? { timedOut: true } : { interrupted: true }),
|
|
649
|
+
},
|
|
650
|
+
stopTick: watchdog ? "watchdog" : "cancelled",
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
tickSignal.addEventListener("abort", onTickAbort, { once: true });
|
|
654
|
+
if (tickSignal.aborted)
|
|
655
|
+
onTickAbort();
|
|
656
|
+
});
|
|
657
|
+
const execution = Promise.resolve()
|
|
658
|
+
.then(() => run(job, { timeoutMs: jobTimeoutMs, signal }))
|
|
659
|
+
.then((result) => jobTimedOut && !tickSignal.aborted
|
|
660
|
+
? { result: { ...result, ok: false, error: `timed out after ${jobTimeoutMs}ms`, timedOut: true, interrupted: undefined } }
|
|
661
|
+
: { result }, (error) => jobTimedOut && !tickSignal.aborted
|
|
662
|
+
? { result: { ok: false, error: `timed out after ${jobTimeoutMs}ms`, output: "", timedOut: true } }
|
|
663
|
+
: { result: safeRunFailure(error) });
|
|
664
|
+
const outcome = await Promise.race([execution, boundary]);
|
|
665
|
+
if (jobTimer)
|
|
666
|
+
clearTimeout(jobTimer);
|
|
667
|
+
if (settleTimer)
|
|
668
|
+
clearTimeout(settleTimer);
|
|
669
|
+
if (onTickAbort)
|
|
670
|
+
tickSignal.removeEventListener("abort", onTickAbort);
|
|
671
|
+
return outcome;
|
|
672
|
+
}
|
|
673
|
+
/** Await auxiliary work without allowing a non-cooperative delivery adapter to outlive the tick lock. */
|
|
674
|
+
async function completesBeforeTick(work, tickSignal) {
|
|
675
|
+
const completed = Promise.resolve(work).then(() => true, () => true);
|
|
676
|
+
if (tickSignal.aborted) {
|
|
677
|
+
void completed;
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
let onAbort;
|
|
681
|
+
const aborted = new Promise((resolve) => {
|
|
682
|
+
onAbort = () => resolve(false);
|
|
683
|
+
tickSignal.addEventListener("abort", onAbort, { once: true });
|
|
684
|
+
if (tickSignal.aborted)
|
|
685
|
+
onAbort();
|
|
686
|
+
});
|
|
687
|
+
const result = await Promise.race([completed, aborted]);
|
|
688
|
+
if (onAbort)
|
|
689
|
+
tickSignal.removeEventListener("abort", onAbort);
|
|
690
|
+
return result;
|
|
395
691
|
}
|
|
396
692
|
/** One scheduler tick: run every due job (sequentially), recording each outcome. Lock-guarded so an
|
|
397
693
|
* overlapping tick (launchd fires every 60s; a job may run longer) skips instead of double-firing.
|
|
398
694
|
* `run` is injectable for tests. Returns the job ids that ran. */
|
|
399
|
-
export async function runTick(nowMs, run = runJobOnce) {
|
|
695
|
+
export async function runTick(nowMs, run = runJobOnce, options = {}) {
|
|
400
696
|
mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
|
|
401
697
|
try {
|
|
402
698
|
chmodSync(cronDir(), 0o700);
|
|
403
699
|
}
|
|
404
700
|
catch { /* best effort */ }
|
|
405
701
|
const lock = lockPath();
|
|
406
|
-
const token =
|
|
702
|
+
const token = newTickLockToken();
|
|
407
703
|
const takeover = takeoverPath();
|
|
408
704
|
let acquired = false;
|
|
409
705
|
const guardState = prepareTakeoverGuard(takeover, nowMs);
|
|
@@ -429,11 +725,11 @@ export async function runTick(nowMs, run = runJobOnce) {
|
|
|
429
725
|
if (!observedFile)
|
|
430
726
|
return { ran: [], skipped: "another tick is in progress" };
|
|
431
727
|
const observed = parseTickLockSnapshot(observedFile);
|
|
432
|
-
const malformedStale = (snapshot) => nowMs - snapshot.mtimeMs >=
|
|
433
|
-
if (observed ? !
|
|
728
|
+
const malformedStale = (snapshot) => nowMs - snapshot.mtimeMs >= MALFORMED_LOCK_POISON_MS;
|
|
729
|
+
if (observed ? !reclaimableTickLock(observed) : !malformedStale(observedFile)) {
|
|
434
730
|
return { ran: [], skipped: "another tick is in progress" };
|
|
435
731
|
}
|
|
436
|
-
const takeoverToken =
|
|
732
|
+
const takeoverToken = newTickLockToken();
|
|
437
733
|
if (!writeExclusive(takeover, takeoverToken))
|
|
438
734
|
return { ran: [], skipped: "another tick is taking over a stale lock" };
|
|
439
735
|
try {
|
|
@@ -447,7 +743,7 @@ export async function runTick(nowMs, run = runJobOnce) {
|
|
|
447
743
|
}
|
|
448
744
|
const current = parseTickLockSnapshot(currentFile);
|
|
449
745
|
if (observed) {
|
|
450
|
-
if (!sameTickLock(observed, current) || !current || !
|
|
746
|
+
if (!sameTickLock(observed, current) || !current || !reclaimableTickLock(current)) {
|
|
451
747
|
return { ran: [], skipped: "the tick lock changed during stale takeover" };
|
|
452
748
|
}
|
|
453
749
|
}
|
|
@@ -468,18 +764,80 @@ export async function runTick(nowMs, run = runJobOnce) {
|
|
|
468
764
|
chmodSync(lock, 0o600);
|
|
469
765
|
}
|
|
470
766
|
catch { /* best effort */ }
|
|
767
|
+
const tickTimeoutMs = cronTickTimeoutMs(options.tickTimeoutMs);
|
|
768
|
+
const jobTimeoutMs = Math.min(cronJobTimeoutMs(options.jobTimeoutMs), tickTimeoutMs);
|
|
769
|
+
const deliver = options.deliver ?? deliverResult;
|
|
770
|
+
const tickDeadline = new AbortController();
|
|
771
|
+
const tickTimer = setTimeout(() => {
|
|
772
|
+
tickDeadline.abort(new Error(`tick watchdog timed out after ${tickTimeoutMs}ms`));
|
|
773
|
+
}, tickTimeoutMs);
|
|
774
|
+
const tickSignal = options.signal ? AbortSignal.any([options.signal, tickDeadline.signal]) : tickDeadline.signal;
|
|
471
775
|
try {
|
|
472
|
-
|
|
776
|
+
// A prior scheduler/manual process may have died after persisting `running`. Recovery atomically records
|
|
777
|
+
// terminal state + durable notifications before selecting due work. Live, non-expired owners are preserved.
|
|
778
|
+
let due;
|
|
779
|
+
try {
|
|
780
|
+
recoverInterruptedRuns(nowMs);
|
|
781
|
+
due = dueJobs(loadJobs(), nowMs);
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
return { ran: [], stopped: `cron store unavailable: ${safeRunFailure(error).error}` };
|
|
785
|
+
}
|
|
786
|
+
// Every OS tick retries a bounded alert-first slice, including disabled/orphaned/one-shot jobs which may
|
|
787
|
+
// never run again. A transport failure remains queued with backoff instead of disappearing into a log.
|
|
788
|
+
const drained = await completesBeforeTick(deliverPendingNotifications(deliver, nowMs, tickSignal, { limit: 8 }), tickSignal);
|
|
789
|
+
if (!drained || tickSignal.aborted) {
|
|
790
|
+
return {
|
|
791
|
+
ran: [],
|
|
792
|
+
stopped: tickDeadline.signal.aborted
|
|
793
|
+
? `tick watchdog timed out after ${tickTimeoutMs}ms`
|
|
794
|
+
: "tick cancelled by caller",
|
|
795
|
+
};
|
|
796
|
+
}
|
|
473
797
|
const ran = [];
|
|
798
|
+
let stopped;
|
|
474
799
|
for (const job of due) {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
800
|
+
if (tickSignal.aborted) {
|
|
801
|
+
stopped = tickDeadline.signal.aborted
|
|
802
|
+
? `tick watchdog timed out after ${tickTimeoutMs}ms`
|
|
803
|
+
: "tick cancelled by caller";
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
const startedAt = Date.now();
|
|
807
|
+
let runningToken;
|
|
808
|
+
try {
|
|
809
|
+
// Re-check enabled/existence under the store mutex. A disable/remove racing the earlier due snapshot
|
|
810
|
+
// wins cleanly and is never overwritten by this tick.
|
|
811
|
+
runningToken = recordRunStart(job.id, startedAt, true);
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
stopped = `could not persist running state for ${job.id}: ${safeRunFailure(error).error}`;
|
|
815
|
+
break; // fail closed: never launch a job whose running state was not durably recorded
|
|
816
|
+
}
|
|
817
|
+
if (!runningToken)
|
|
818
|
+
continue;
|
|
819
|
+
const bounded = await runOneWithinTick(job, run, jobTimeoutMs, tickTimeoutMs, tickSignal, tickDeadline.signal);
|
|
820
|
+
const r = bounded.result;
|
|
821
|
+
const finishedAt = Date.now();
|
|
822
|
+
const recorded = recordRun(job.id, finishedAt, terminalStatus(r), r.error, finishedAt - startedAt, runningToken, r);
|
|
478
823
|
ran.push(job.id);
|
|
824
|
+
// Even the total watchdog/caller cancellation must produce the promised visible failure alert. Give
|
|
825
|
+
// that final delivery its own small hard boundary instead of skipping it or extending the tick forever.
|
|
826
|
+
const deliverySignal = tickSignal.aborted ? AbortSignal.timeout(FINAL_DELIVERY_TIMEOUT_MS) : tickSignal;
|
|
827
|
+
const delivered = !recorded
|
|
828
|
+
? true // a newer attempt/removal owns state now; do not send this stale attempt's outcome
|
|
829
|
+
: await completesBeforeTick(deliverPendingNotifications(deliver, finishedAt, deliverySignal, { limit: 8, jobId: job.id }), deliverySignal);
|
|
830
|
+
if (bounded.stopTick || !delivered || tickSignal.aborted) {
|
|
831
|
+
stopped = tickDeadline.signal.aborted
|
|
832
|
+
? `tick watchdog timed out after ${tickTimeoutMs}ms`
|
|
833
|
+
: "tick cancelled by caller";
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
479
836
|
}
|
|
480
|
-
return { ran };
|
|
837
|
+
return { ran, ...(stopped ? { stopped } : {}) };
|
|
481
838
|
}
|
|
482
839
|
finally {
|
|
840
|
+
clearTimeout(tickTimer);
|
|
483
841
|
// Remove only the lock instance we created; never unlink a successor after a stale-lock takeover.
|
|
484
842
|
releasePrimaryTickLock(lock, takeover, token);
|
|
485
843
|
}
|