@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.
- package/CHANGELOG.md +27 -0
- package/README.md +2 -2
- package/dist/api/index.js +19 -10
- package/dist/cli/index.js +368 -192
- package/dist/daemon/index.js +317 -162
- package/dist/index.js +339 -174
- package/dist/lib/executor.d.ts +14 -1
- package/dist/lib/mode.js +1 -1
- package/dist/lib/storage/index.js +56 -4
- package/dist/lib/storage/sqlite.js +56 -4
- package/dist/lib/store.d.ts +7 -0
- package/dist/lib/store.js +56 -4
- package/dist/lib/template-kit.d.ts +6 -0
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +321 -165
- package/dist/runner/index.d.ts +3 -1
- package/dist/runner/index.js +130 -11
- package/dist/sdk/index.js +320 -166
- package/docs/USAGE.md +2 -2
- package/package.json +1 -1
package/dist/runner/index.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/runner/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@hasna/loops",
|
|
6
|
-
version: "0.4.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
2089
|
+
const controller = new AbortController;
|
|
2090
|
+
let consecutiveFailures = 0;
|
|
1976
2091
|
const timer = setInterval(() => {
|
|
1977
|
-
heartbeat().
|
|
1978
|
-
|
|
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
|
-
|
|
1983
|
-
return result;
|
|
2102
|
+
return await execute(claim.loop, claim.run, { signal: controller.signal });
|
|
1984
2103
|
} finally {
|
|
1985
2104
|
clearInterval(timer);
|
|
1986
2105
|
}
|