@hasna/loops 0.4.5 → 0.4.6

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.
@@ -25,7 +25,9 @@ export interface RunRunnerOnceOptions {
25
25
  now?: Date;
26
26
  heartbeatIntervalMs?: number;
27
27
  fetchImpl?: typeof fetch;
28
- execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
28
+ execute?: (loop: Loop, run: LoopRun, opts?: {
29
+ signal?: AbortSignal;
30
+ }) => Promise<ExecutorResult>;
29
31
  env?: NodeJS.ProcessEnv;
30
32
  }
31
33
  export declare function runRunnerOnce(opts?: RunRunnerOnceOptions): Promise<RunnerOnceResult>;
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.5",
6
+ version: "0.4.6",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
@@ -272,7 +272,7 @@ function deploymentStatusLine(status) {
272
272
  import { Command } from "commander";
273
273
 
274
274
  // src/lib/executor.ts
275
- import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
275
+ import { spawn as spawn2, spawnSync as spawnSync3 } from "child_process";
276
276
  import { randomBytes as randomBytes2 } from "crypto";
277
277
  import { once } from "events";
278
278
  import { lstatSync, mkdirSync, realpathSync } from "fs";
@@ -919,6 +919,86 @@ function refreshLoopMachine(machine) {
919
919
  return resolveLoopMachine(machine.id);
920
920
  }
921
921
 
922
+ // src/lib/process-identity.ts
923
+ import { readFileSync } from "fs";
924
+ import { spawnSync as spawnSync2 } from "child_process";
925
+ var START_TIME_TOLERANCE_MS = 5000;
926
+ var clockTicksCache;
927
+ function clockTicksPerSecond() {
928
+ if (clockTicksCache !== undefined)
929
+ return clockTicksCache;
930
+ try {
931
+ const run = spawnSync2("getconf", ["CLK_TCK"], { encoding: "utf8" });
932
+ const value = Number(run.stdout.trim());
933
+ clockTicksCache = run.status === 0 && Number.isFinite(value) && value > 0 ? value : 100;
934
+ } catch {
935
+ clockTicksCache = 100;
936
+ }
937
+ return clockTicksCache;
938
+ }
939
+ var bootTimeCache;
940
+ var bootTimeResolved = false;
941
+ function bootTimeMs() {
942
+ if (bootTimeResolved)
943
+ return bootTimeCache;
944
+ bootTimeResolved = true;
945
+ try {
946
+ const match = readFileSync("/proc/stat", "utf8").match(/^btime (\d+)$/m);
947
+ bootTimeCache = match ? Number(match[1]) * 1000 : undefined;
948
+ } catch {
949
+ bootTimeCache = undefined;
950
+ }
951
+ return bootTimeCache;
952
+ }
953
+ function procStatFields(path) {
954
+ try {
955
+ const stat = readFileSync(path, "utf8");
956
+ const closeParen = stat.lastIndexOf(")");
957
+ if (closeParen < 0)
958
+ return;
959
+ return stat.slice(closeParen + 2).split(" ");
960
+ } catch {
961
+ return;
962
+ }
963
+ }
964
+ function processStartTimeMs(pid) {
965
+ if (!Number.isInteger(pid) || pid <= 0)
966
+ return;
967
+ if (process.platform === "linux") {
968
+ const fields = procStatFields(`/proc/${pid}/stat`);
969
+ const startTicks = fields ? Number(fields[19]) : Number.NaN;
970
+ const boot = bootTimeMs();
971
+ if (Number.isFinite(startTicks) && boot !== undefined) {
972
+ return boot + Math.round(startTicks / clockTicksPerSecond() * 1000);
973
+ }
974
+ }
975
+ try {
976
+ const run = spawnSync2("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
977
+ if (run.status === 0) {
978
+ const parsed = Date.parse(run.stdout.trim());
979
+ if (Number.isFinite(parsed))
980
+ return parsed;
981
+ }
982
+ } catch {}
983
+ return;
984
+ }
985
+ function sameProcessStart(recorded, actualMs, toleranceMs = START_TIME_TOLERANCE_MS) {
986
+ if (recorded === undefined || actualMs === undefined)
987
+ return true;
988
+ const recordedMs = typeof recorded === "number" ? recorded : Date.parse(recorded);
989
+ if (!Number.isFinite(recordedMs))
990
+ return true;
991
+ return Math.abs(recordedMs - actualMs) <= toleranceMs;
992
+ }
993
+ function verifiedProcessStart(recorded, actualMs, toleranceMs = START_TIME_TOLERANCE_MS) {
994
+ if (recorded === undefined || actualMs === undefined)
995
+ return false;
996
+ const recordedMs = typeof recorded === "number" ? recorded : Date.parse(recorded);
997
+ if (!Number.isFinite(recordedMs))
998
+ return false;
999
+ return Math.abs(recordedMs - actualMs) <= toleranceMs;
1000
+ }
1001
+
922
1002
  // src/lib/executor.ts
923
1003
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
924
1004
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -993,7 +1073,12 @@ function notifySpawn(pid, opts) {
993
1073
  if (!pid)
994
1074
  return;
995
1075
  opts.onSpawn?.(pid);
996
- opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
1076
+ const startedMs = processStartTimeMs(pid);
1077
+ opts.onSpawnProcess?.({
1078
+ pid,
1079
+ pgid: pid,
1080
+ processStartedAt: startedMs !== undefined ? new Date(startedMs).toISOString() : nowIso()
1081
+ });
997
1082
  }
998
1083
  function shellQuote(value) {
999
1084
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -1285,7 +1370,7 @@ function assertCodewithProfileListed(profile, result) {
1285
1370
  function preflightNativeAuthProfileSync(spec, env) {
1286
1371
  if (spec.nativeAuthProfile?.provider !== "codewith")
1287
1372
  return;
1288
- const result = spawnSync2(spec.command, ["profile", "list"], {
1373
+ const result = spawnSync3(spec.command, ["profile", "list"], {
1289
1374
  encoding: "utf8",
1290
1375
  env,
1291
1376
  stdio: ["ignore", "pipe", "pipe"],
@@ -1348,6 +1433,13 @@ function numberField(value, key) {
1348
1433
  function codewithAgentStatus(readJson) {
1349
1434
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
1350
1435
  }
1436
+ function codewithAgentLastEventSeq(readJson) {
1437
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
1438
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
1439
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
1440
+ return Math.max(agentSeq, snapshotSeq);
1441
+ return agentSeq ?? snapshotSeq;
1442
+ }
1351
1443
  function codewithAgentEvidence(startJson, readJson, logsJson) {
1352
1444
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
1353
1445
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -1378,6 +1470,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
1378
1470
  events
1379
1471
  }, null, 2);
1380
1472
  }
1473
+ function codewithAgentProgress(readJson) {
1474
+ const agent = recordField(readJson, "agent");
1475
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
1476
+ return {
1477
+ provider: "codewith",
1478
+ agentId: stringField(agent, "agentId"),
1479
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
1480
+ summary: stringField(statusSnapshot, "summary"),
1481
+ statusReason: stringField(agent, "statusReason"),
1482
+ threadId: stringField(agent, "threadId"),
1483
+ rolloutPath: stringField(agent, "rolloutPath"),
1484
+ pid: numberField(agent, "pid"),
1485
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
1486
+ };
1487
+ }
1381
1488
  function sleep(ms) {
1382
1489
  return new Promise((resolve2) => setTimeout(resolve2, ms));
1383
1490
  }
@@ -1412,6 +1519,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
1412
1519
  if (!agentId) {
1413
1520
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
1414
1521
  }
1522
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
1415
1523
  const stopAgent = async () => {
1416
1524
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
1417
1525
  cwd: spec.cwd,
@@ -1454,10 +1562,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
1454
1562
  });
1455
1563
  }
1456
1564
  const status = codewithAgentStatus(lastReadJson);
1457
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
1565
+ const fingerprint = JSON.stringify({
1566
+ status,
1567
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
1568
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
1569
+ });
1458
1570
  if (fingerprint !== lastFingerprint) {
1459
1571
  lastFingerprint = fingerprint;
1460
1572
  lastProgressAt = Date.now();
1573
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
1461
1574
  }
1462
1575
  if (status === "completed" || status === "failed" || status === "cancelled") {
1463
1576
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -1595,7 +1708,7 @@ function worktreeFallbackSpec(target, opts, fallbackCwd) {
1595
1708
  }
1596
1709
  function preflightRemoteSpec(spec, machine, metadata, opts) {
1597
1710
  const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
1598
- const result = spawnSync2(plan.command, plan.args, {
1711
+ const result = spawnSync3(plan.command, plan.args, {
1599
1712
  encoding: "utf8",
1600
1713
  env: transportEnv(opts),
1601
1714
  input: remotePreflightScript(spec, metadata),
@@ -1869,6 +1982,7 @@ async function executeLoop(loop, run, opts = {}) {
1869
1982
  var program = new Command;
1870
1983
  var DEFAULT_RUNNER_ID = `runner:${process.pid}`;
1871
1984
  var MIN_RUNNER_LEASE_MS = 1000;
1985
+ var MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 3;
1872
1986
  program.name("loops-runner").description("OpenLoops control-plane runner foundation").version(packageVersion()).option("-j, --json", "print JSON");
1873
1987
  function configuredApiUrl(env = process.env) {
1874
1988
  return env.LOOPS_API_URL?.trim() || env.HASNA_LOOPS_API_URL?.trim() || env.LOOPS_CLOUD_API_URL?.trim() || env.HASNA_LOOPS_CLOUD_API_URL?.trim();
@@ -1972,15 +2086,20 @@ async function executeClaimWithHeartbeat(fetchImpl, config, claim, opts) {
1972
2086
  });
1973
2087
  };
1974
2088
  await heartbeat();
1975
- let lastHeartbeatError;
2089
+ const controller = new AbortController;
2090
+ let consecutiveFailures = 0;
1976
2091
  const timer = setInterval(() => {
1977
- heartbeat().catch((error) => {
1978
- lastHeartbeatError = error;
2092
+ heartbeat().then(() => {
2093
+ consecutiveFailures = 0;
2094
+ }, () => {
2095
+ consecutiveFailures += 1;
2096
+ if (consecutiveFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES && !controller.signal.aborted) {
2097
+ controller.abort();
2098
+ }
1979
2099
  });
1980
2100
  }, heartbeatIntervalMs);
1981
2101
  try {
1982
- const result = await execute(claim.loop, claim.run);
1983
- return result;
2102
+ return await execute(claim.loop, claim.run, { signal: controller.signal });
1984
2103
  } finally {
1985
2104
  clearInterval(timer);
1986
2105
  }