@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.
- package/CHANGELOG.md +40 -0
- package/README.md +2 -2
- package/dist/api/index.js +19 -10
- package/dist/cli/index.js +613 -230
- package/dist/daemon/index.js +367 -170
- package/dist/index.js +413 -182
- package/dist/lib/executor.d.ts +14 -1
- package/dist/lib/mode.js +1 -1
- package/dist/lib/route/types.d.ts +6 -0
- package/dist/lib/storage/index.js +57 -5
- package/dist/lib/storage/sqlite.js +57 -5
- package/dist/lib/store.d.ts +7 -0
- package/dist/lib/store.js +57 -5
- package/dist/lib/template-kit.d.ts +6 -0
- package/dist/lib/templates.d.ts +10 -0
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +371 -173
- package/dist/runner/index.d.ts +3 -1
- package/dist/runner/index.js +180 -19
- package/dist/sdk/index.js +370 -174
- 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.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
|
|
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
|
-
|
|
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"],
|
|
@@ -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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
2131
|
+
const controller = new AbortController;
|
|
2132
|
+
let consecutiveFailures = 0;
|
|
1976
2133
|
const timer = setInterval(() => {
|
|
1977
|
-
heartbeat().
|
|
1978
|
-
|
|
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
|
-
|
|
1983
|
-
return result;
|
|
2144
|
+
return await execute(claim.loop, claim.run, { signal: controller.signal });
|
|
1984
2145
|
} finally {
|
|
1985
2146
|
clearInterval(timer);
|
|
1986
2147
|
}
|