@hasna/loops 0.4.5 → 0.4.7

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.7",
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";
@@ -510,7 +510,7 @@ function buildAgentInvocation(target) {
510
510
  if (target.model)
511
511
  args.push("--model", target.model);
512
512
  args.push(...target.extraArgs ?? []);
513
- args.push("agent", "start");
513
+ args.push("agent", "start", "--json");
514
514
  if (target.cwd)
515
515
  args.push("--cwd", target.cwd);
516
516
  args.push(target.prompt);
@@ -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"],
@@ -1318,18 +1403,60 @@ function codewithAgentControlArgs(target, command, agentId) {
1318
1403
  "agent",
1319
1404
  command,
1320
1405
  ...command === "logs" ? ["--limit", "20"] : [],
1406
+ "--json",
1321
1407
  agentId
1322
1408
  ];
1323
1409
  }
1410
+ function extractFirstJsonObject(text) {
1411
+ let depth = 0;
1412
+ let start = -1;
1413
+ let inString = false;
1414
+ let escaped = false;
1415
+ for (let i = 0;i < text.length; i += 1) {
1416
+ const ch = text[i];
1417
+ if (inString) {
1418
+ if (escaped)
1419
+ escaped = false;
1420
+ else if (ch === "\\")
1421
+ escaped = true;
1422
+ else if (ch === '"')
1423
+ inString = false;
1424
+ continue;
1425
+ }
1426
+ if (ch === '"') {
1427
+ inString = true;
1428
+ } else if (ch === "{") {
1429
+ if (depth === 0)
1430
+ start = i;
1431
+ depth += 1;
1432
+ } else if (ch === "}") {
1433
+ if (depth > 0) {
1434
+ depth -= 1;
1435
+ if (depth === 0 && start !== -1)
1436
+ return text.slice(start, i + 1);
1437
+ }
1438
+ }
1439
+ }
1440
+ return;
1441
+ }
1324
1442
  function parseJsonOutput(stdout, label) {
1325
- try {
1326
- const value = JSON.parse(stdout || "{}");
1327
- if (!value || typeof value !== "object" || Array.isArray(value))
1328
- throw new Error("not an object");
1329
- return value;
1330
- } catch (error) {
1331
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
1443
+ const raw = stdout || "{}";
1444
+ const candidates = [raw.trim()];
1445
+ const scanned = extractFirstJsonObject(raw);
1446
+ if (scanned && scanned !== raw.trim())
1447
+ candidates.push(scanned);
1448
+ let lastError;
1449
+ for (const candidate of candidates) {
1450
+ try {
1451
+ const value = JSON.parse(candidate);
1452
+ if (!value || typeof value !== "object" || Array.isArray(value))
1453
+ throw new Error("not an object");
1454
+ return value;
1455
+ } catch (error) {
1456
+ lastError = error;
1457
+ }
1332
1458
  }
1459
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
1333
1460
  }
1334
1461
  function recordField(value, key) {
1335
1462
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -1348,6 +1475,13 @@ function numberField(value, key) {
1348
1475
  function codewithAgentStatus(readJson) {
1349
1476
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
1350
1477
  }
1478
+ function codewithAgentLastEventSeq(readJson) {
1479
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
1480
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
1481
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
1482
+ return Math.max(agentSeq, snapshotSeq);
1483
+ return agentSeq ?? snapshotSeq;
1484
+ }
1351
1485
  function codewithAgentEvidence(startJson, readJson, logsJson) {
1352
1486
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
1353
1487
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -1378,6 +1512,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
1378
1512
  events
1379
1513
  }, null, 2);
1380
1514
  }
1515
+ function codewithAgentProgress(readJson) {
1516
+ const agent = recordField(readJson, "agent");
1517
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
1518
+ return {
1519
+ provider: "codewith",
1520
+ agentId: stringField(agent, "agentId"),
1521
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
1522
+ summary: stringField(statusSnapshot, "summary"),
1523
+ statusReason: stringField(agent, "statusReason"),
1524
+ threadId: stringField(agent, "threadId"),
1525
+ rolloutPath: stringField(agent, "rolloutPath"),
1526
+ pid: numberField(agent, "pid"),
1527
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
1528
+ };
1529
+ }
1381
1530
  function sleep(ms) {
1382
1531
  return new Promise((resolve2) => setTimeout(resolve2, ms));
1383
1532
  }
@@ -1412,6 +1561,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
1412
1561
  if (!agentId) {
1413
1562
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
1414
1563
  }
1564
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
1415
1565
  const stopAgent = async () => {
1416
1566
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
1417
1567
  cwd: spec.cwd,
@@ -1454,10 +1604,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
1454
1604
  });
1455
1605
  }
1456
1606
  const status = codewithAgentStatus(lastReadJson);
1457
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
1607
+ const fingerprint = JSON.stringify({
1608
+ status,
1609
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
1610
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
1611
+ });
1458
1612
  if (fingerprint !== lastFingerprint) {
1459
1613
  lastFingerprint = fingerprint;
1460
1614
  lastProgressAt = Date.now();
1615
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
1461
1616
  }
1462
1617
  if (status === "completed" || status === "failed" || status === "cancelled") {
1463
1618
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -1595,7 +1750,7 @@ function worktreeFallbackSpec(target, opts, fallbackCwd) {
1595
1750
  }
1596
1751
  function preflightRemoteSpec(spec, machine, metadata, opts) {
1597
1752
  const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
1598
- const result = spawnSync2(plan.command, plan.args, {
1753
+ const result = spawnSync3(plan.command, plan.args, {
1599
1754
  encoding: "utf8",
1600
1755
  env: transportEnv(opts),
1601
1756
  input: remotePreflightScript(spec, metadata),
@@ -1869,6 +2024,7 @@ async function executeLoop(loop, run, opts = {}) {
1869
2024
  var program = new Command;
1870
2025
  var DEFAULT_RUNNER_ID = `runner:${process.pid}`;
1871
2026
  var MIN_RUNNER_LEASE_MS = 1000;
2027
+ var MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 3;
1872
2028
  program.name("loops-runner").description("OpenLoops control-plane runner foundation").version(packageVersion()).option("-j, --json", "print JSON");
1873
2029
  function configuredApiUrl(env = process.env) {
1874
2030
  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 +2128,20 @@ async function executeClaimWithHeartbeat(fetchImpl, config, claim, opts) {
1972
2128
  });
1973
2129
  };
1974
2130
  await heartbeat();
1975
- let lastHeartbeatError;
2131
+ const controller = new AbortController;
2132
+ let consecutiveFailures = 0;
1976
2133
  const timer = setInterval(() => {
1977
- heartbeat().catch((error) => {
1978
- lastHeartbeatError = error;
2134
+ heartbeat().then(() => {
2135
+ consecutiveFailures = 0;
2136
+ }, () => {
2137
+ consecutiveFailures += 1;
2138
+ if (consecutiveFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES && !controller.signal.aborted) {
2139
+ controller.abort();
2140
+ }
1979
2141
  });
1980
2142
  }, heartbeatIntervalMs);
1981
2143
  try {
1982
- const result = await execute(claim.loop, claim.run);
1983
- return result;
2144
+ return await execute(claim.loop, claim.run, { signal: controller.signal });
1984
2145
  } finally {
1985
2146
  clearInterval(timer);
1986
2147
  }