@nanhara/hara 0.122.3 → 0.122.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +15 -2
  3. package/dist/agent/limits.js +51 -0
  4. package/dist/agent/loop.js +367 -112
  5. package/dist/agent/repeat-guard.js +49 -12
  6. package/dist/checkpoints.js +9 -0
  7. package/dist/cli.js +2 -1
  8. package/dist/config.js +10 -2
  9. package/dist/context/agents-md.js +21 -2
  10. package/dist/context/mentions.js +84 -1
  11. package/dist/context/workspace-scope.js +49 -0
  12. package/dist/cron/deliver.js +61 -38
  13. package/dist/cron/runner.js +423 -65
  14. package/dist/cron/schedule.js +23 -7
  15. package/dist/cron/store.js +709 -43
  16. package/dist/fs-walk.js +279 -7
  17. package/dist/fs-write.js +9 -0
  18. package/dist/gateway/feishu.js +351 -64
  19. package/dist/gateway/flows-pending.js +28 -14
  20. package/dist/gateway/flows.js +306 -31
  21. package/dist/gateway/outbound-files.js +20 -6
  22. package/dist/gateway/runtime-state.js +1485 -0
  23. package/dist/gateway/serve.js +550 -242
  24. package/dist/gateway/telegram.js +279 -29
  25. package/dist/gateway/tts.js +182 -38
  26. package/dist/hooks.js +22 -22
  27. package/dist/index.js +466 -158
  28. package/dist/memory/store.js +8 -5
  29. package/dist/org/planner.js +11 -6
  30. package/dist/process-identity.js +52 -0
  31. package/dist/providers/bounded-turn.js +42 -0
  32. package/dist/recall.js +69 -1
  33. package/dist/runtime.js +24 -0
  34. package/dist/sandbox.js +54 -4
  35. package/dist/search/embed.js +13 -2
  36. package/dist/search/hybrid.js +6 -3
  37. package/dist/search/semindex.js +87 -5
  38. package/dist/security/guardian.js +3 -15
  39. package/dist/security/permissions.js +11 -0
  40. package/dist/serve/server.js +70 -42
  41. package/dist/serve/sessions.js +4 -3
  42. package/dist/tools/agent.js +1 -1
  43. package/dist/tools/ask_user.js +5 -1
  44. package/dist/tools/builtin.js +5 -2
  45. package/dist/tools/codebase.js +67 -18
  46. package/dist/tools/computer.js +149 -68
  47. package/dist/tools/cron.js +72 -18
  48. package/dist/tools/edit.js +3 -2
  49. package/dist/tools/external_agent.js +66 -12
  50. package/dist/tools/memory.js +25 -7
  51. package/dist/tools/patch.js +11 -2
  52. package/dist/tools/registry.js +16 -1
  53. package/dist/tools/search.js +68 -9
  54. package/dist/tools/send.js +1 -1
  55. package/dist/tools/skill.js +1 -1
  56. package/dist/tools/web.js +43 -13
  57. package/dist/tui/App.js +93 -25
  58. package/dist/vision.js +5 -6
  59. package/package.json +2 -2
  60. package/runtime-bootstrap.cjs +22 -0
@@ -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, recordAlert, findJob, cronDir, logPath } from "./store.js";
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
- * Discriminator is whether execPath is node — NOT argv[1]'s extension (the bin symlink has no `.js`). */
23
- export function selfArgv() {
24
- const exec = process.execPath;
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
- return underNode && process.argv[1] ? [exec, process.argv[1]] : [exec];
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
- // Generous: a live-PID owner is respected this long (so a genuinely long job isn't double-fired); past it
31
- // we assume PID reuse and take over. A *dead* owner is taken over within one tick regardless (see below).
32
- const LOCK_STALE_MS = 6 * 60 * 60_000;
33
- // A takeover/release guard protects only synchronous filesystem operations and should live for milliseconds.
34
- // Five minutes tolerates long host pauses while still recovering a guard left by a crash or PID reuse.
35
- const TAKEOVER_GUARD_STALE_MS = 5 * 60_000;
36
- const DEFAULT_JOB_TIMEOUT_MS = 30 * 60_000;
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
- 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);
73
- const pid = Number(match?.[1]);
74
- if (!match || !Number.isSafeInteger(pid) || pid <= 0)
75
- return null;
76
- return { ...file, raw: file.raw, pid };
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
- function staleTickLock(snapshot, nowMs) {
94
- return nowMs - snapshot.mtimeMs >= LOCK_STALE_MS || !processIsAlive(snapshot.pid);
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
- /** Recover only a complete, stable guard whose owner is proven dead or whose tiny synchronous lease is
97
- * far past its lifetime. Recovery deliberately ends this tick: a later invocation acquires a fresh guard,
98
- * keeping stale-guard deletion and new-guard creation out of the same contender's race window. */
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) => nowMs - snapshot.mtimeMs >= TAKEOVER_GUARD_STALE_MS || !processIsAlive(snapshot.pid);
107
- const staleMalformed = (snapshot) => nowMs - snapshot.mtimeMs >= TAKEOVER_GUARD_STALE_MS;
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 >6h owner could
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 = `${process.pid}:${randomUUID()}`;
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 = Math.min(Math.max(100, options.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS), 24 * 60 * 60_000);
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
- child.stdout?.on("data", (d) => { if (!done && !timedOut)
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
- /** After a run: push the outcome to the job's deliver channel, and — on repeated failures — a 🚨 alert
369
- * (threshold `alertAfter` (default 3), 6h cooldown). Best-effort: a delivery error only hits the log.
370
- * `deliver` + `nowMs` injectable for tests. */
371
- export async function deliverOutcome(job, r, deliver = deliverResult, nowMs = Date.now()) {
372
- if (!job.deliver)
373
- return;
374
- const snippet = (r.output ?? "").trim().slice(-1_500);
375
- const head = r.ok ? `⏰ ${job.name} ✓` : `⏰ ${job.name} ✗ ${r.error ?? "failed"}`;
376
- const err = await deliver(job.deliver, snippet ? `${head}\n${snippet}` : head);
377
- if (err) {
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
- appendFileSync(logPath(job.id), `\n[deliver] ${err}\n`);
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
- /* best-effort */
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
- if (!r.ok) {
386
- const fresh = findJob(job.id); // recordRun already bumped consecutiveErrors
387
- const count = fresh?.consecutiveErrors ?? 0;
388
- const threshold = job.alertAfter ?? 3;
389
- const cooled = !fresh?.lastAlertAt || nowMs - fresh.lastAlertAt > 6 * 3_600_000;
390
- if (count >= threshold && cooled) {
391
- await deliver(job.deliver, `🚨 ${job.name} has failed ${count}× in a row — latest: ${r.error ?? "unknown"}. Log: ${logPath(job.id)}`);
392
- recordAlert(job.id, nowMs);
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 = `${process.pid}:${randomUUID()}`;
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 >= LOCK_STALE_MS;
433
- if (observed ? !staleTickLock(observed, nowMs) : !malformedStale(observedFile)) {
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 = `${process.pid}:${randomUUID()}`;
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 || !staleTickLock(current, nowMs)) {
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
- const due = dueJobs(loadJobs(), nowMs);
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
- const r = await run(job);
476
- recordRun(job.id, nowMs, r.ok ? "ok" : "error", r.error);
477
- await deliverOutcome(job, r);
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
  }