@hasna/loops 0.4.9 → 0.4.11
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 +45 -0
- package/dist/api/index.js +1 -1
- package/dist/cli/index.js +265 -44
- package/dist/daemon/daemon.d.ts +18 -0
- package/dist/daemon/index.js +99 -24
- package/dist/index.js +65 -15
- package/dist/lib/mode.js +1 -1
- package/dist/lib/route/pr-review.d.ts +24 -1
- package/dist/lib/scheduler.d.ts +13 -0
- package/dist/lib/storage/index.js +30 -11
- package/dist/lib/storage/sqlite.js +30 -11
- package/dist/lib/store.js +30 -11
- package/dist/mcp/index.js +65 -15
- package/dist/runner/index.js +22 -4
- package/dist/sdk/index.js +65 -15
- package/package.json +1 -1
package/dist/daemon/daemon.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { type SchedulerLane } from "../lib/scheduler.js";
|
|
1
2
|
import { Store } from "../lib/store.js";
|
|
2
3
|
import type { ExecutorResult, Loop, LoopRun } from "../types.js";
|
|
3
4
|
export interface RunDaemonOptions {
|
|
4
5
|
intervalMs?: number;
|
|
5
6
|
leaseTtlMs?: number;
|
|
7
|
+
/** Legacy single-pool knob; back-compat alias for the agent/workflow lane budget. */
|
|
6
8
|
concurrency?: number;
|
|
9
|
+
/** Claim budget for command-target loops (fast loops). Default 4. */
|
|
10
|
+
commandConcurrency?: number;
|
|
11
|
+
/** Claim budget for agent/workflow-target loops (long workers). Default 8; `concurrency`/LOOPS_DAEMON_CONCURRENCY still set it. */
|
|
12
|
+
agentConcurrency?: number;
|
|
7
13
|
store?: Store;
|
|
8
14
|
pidPath?: string;
|
|
9
15
|
execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
|
|
@@ -13,6 +19,18 @@ export interface RunDaemonOptions {
|
|
|
13
19
|
signal?: AbortSignal;
|
|
14
20
|
reapGraceMs?: number;
|
|
15
21
|
}
|
|
22
|
+
export declare const DEFAULT_COMMAND_CONCURRENCY = 4;
|
|
23
|
+
export declare const DEFAULT_AGENT_CONCURRENCY = 8;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the two separated concurrency lanes. Command loops (fast) and
|
|
26
|
+
* agent/workflow loops (long workers) get independent budgets so a saturated
|
|
27
|
+
* agent lane cannot starve command loops. Precedence per lane is explicit opt >
|
|
28
|
+
* lane env > legacy knob > default. The legacy `concurrency` opt and
|
|
29
|
+
* `LOOPS_DAEMON_CONCURRENCY` env stay wired to the agent lane (the dominant
|
|
30
|
+
* consumer / historical "total" knob), so existing deployments keep their
|
|
31
|
+
* heavy-lane capacity and simply gain a reserved command lane on top.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveLaneConcurrency(opts?: Pick<RunDaemonOptions, "concurrency" | "commandConcurrency" | "agentConcurrency">): Record<SchedulerLane, number>;
|
|
16
34
|
export declare const DAEMON_LOG_MAX_BYTES: number;
|
|
17
35
|
export declare const DAEMON_LOG_KEEP = 2;
|
|
18
36
|
export declare function daemonLogLine(message: string): string;
|
package/dist/daemon/index.js
CHANGED
|
@@ -56,8 +56,12 @@ function nowIso() {
|
|
|
56
56
|
import { mkdirSync } from "fs";
|
|
57
57
|
import { homedir } from "os";
|
|
58
58
|
import { join } from "path";
|
|
59
|
+
function homeDir() {
|
|
60
|
+
const home = process.env.HOME?.trim();
|
|
61
|
+
return home ? home : homedir();
|
|
62
|
+
}
|
|
59
63
|
function dataDir() {
|
|
60
|
-
return process.env.LOOPS_DATA_DIR || join(
|
|
64
|
+
return process.env.LOOPS_DATA_DIR || join(homeDir(), ".hasna", "loops");
|
|
61
65
|
}
|
|
62
66
|
function ensureDataDir() {
|
|
63
67
|
const dir = dataDir();
|
|
@@ -74,10 +78,10 @@ function daemonLogPath() {
|
|
|
74
78
|
return join(dataDir(), "daemon.log");
|
|
75
79
|
}
|
|
76
80
|
function systemdServicePath() {
|
|
77
|
-
return join(
|
|
81
|
+
return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
|
|
78
82
|
}
|
|
79
83
|
function launchdPlistPath() {
|
|
80
|
-
return join(
|
|
84
|
+
return join(homeDir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
// src/lib/process-identity.ts
|
|
@@ -1474,6 +1478,21 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
|
|
|
1474
1478
|
function scrubbedOrNull(value) {
|
|
1475
1479
|
return value == null ? null : scrubSecrets(value);
|
|
1476
1480
|
}
|
|
1481
|
+
var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
|
|
1482
|
+
function clampPersistedRunOutput(value) {
|
|
1483
|
+
if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
|
|
1484
|
+
return value;
|
|
1485
|
+
const half = Math.floor(MAX_PERSISTED_RUN_OUTPUT_CHARS / 2);
|
|
1486
|
+
const head = value.slice(0, half);
|
|
1487
|
+
const tail = value.slice(value.length - half);
|
|
1488
|
+
const omitted = value.length - head.length - tail.length;
|
|
1489
|
+
return `${head}
|
|
1490
|
+
\u2026[${omitted} chars truncated by loops run-output retention]\u2026
|
|
1491
|
+
${tail}`;
|
|
1492
|
+
}
|
|
1493
|
+
function persistedRunOutput(value) {
|
|
1494
|
+
return clampPersistedRunOutput(scrubbedOrNull(value));
|
|
1495
|
+
}
|
|
1477
1496
|
function chmodIfExists(path, mode) {
|
|
1478
1497
|
try {
|
|
1479
1498
|
if (existsSync(path))
|
|
@@ -3220,8 +3239,8 @@ class Store {
|
|
|
3220
3239
|
))`).run({
|
|
3221
3240
|
$workflowRunId: workflowRunId,
|
|
3222
3241
|
$stepId: stepId,
|
|
3223
|
-
$stdout: progress.stdout === undefined ? null :
|
|
3224
|
-
$stderr: progress.stderr === undefined ? null :
|
|
3242
|
+
$stdout: progress.stdout === undefined ? null : persistedRunOutput(progress.stdout),
|
|
3243
|
+
$stderr: progress.stderr === undefined ? null : persistedRunOutput(progress.stderr),
|
|
3225
3244
|
$updated: now,
|
|
3226
3245
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
3227
3246
|
$now: now
|
|
@@ -3282,8 +3301,8 @@ class Store {
|
|
|
3282
3301
|
$finished: finishedAt,
|
|
3283
3302
|
$exitCode: patch.exitCode ?? null,
|
|
3284
3303
|
$durationMs: patch.durationMs ?? null,
|
|
3285
|
-
$stdout:
|
|
3286
|
-
$stderr:
|
|
3304
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3305
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3287
3306
|
$error: error ?? null,
|
|
3288
3307
|
$updated: finishedAt,
|
|
3289
3308
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
@@ -3661,8 +3680,8 @@ class Store {
|
|
|
3661
3680
|
$pid: patch.pid ?? null,
|
|
3662
3681
|
$exitCode: patch.exitCode ?? null,
|
|
3663
3682
|
$durationMs: patch.durationMs ?? null,
|
|
3664
|
-
$stdout:
|
|
3665
|
-
$stderr:
|
|
3683
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3684
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3666
3685
|
$error: error ?? null,
|
|
3667
3686
|
$updated: finishedAt,
|
|
3668
3687
|
$claimedBy: opts.claimedBy ?? null,
|
|
@@ -4059,8 +4078,8 @@ class Store {
|
|
|
4059
4078
|
$processStartedAt: run.processStartedAt ?? null,
|
|
4060
4079
|
$exitCode: run.exitCode ?? null,
|
|
4061
4080
|
$durationMs: run.durationMs ?? null,
|
|
4062
|
-
$stdout:
|
|
4063
|
-
$stderr:
|
|
4081
|
+
$stdout: persistedRunOutput(run.stdout),
|
|
4082
|
+
$stderr: persistedRunOutput(run.stderr),
|
|
4064
4083
|
$error: scrubbedOrNull(run.error),
|
|
4065
4084
|
$goalRunId: run.goalRunId ?? null,
|
|
4066
4085
|
$created: run.createdAt,
|
|
@@ -5099,6 +5118,20 @@ function notifySpawn(pid, opts) {
|
|
|
5099
5118
|
function shellQuote(value) {
|
|
5100
5119
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
5101
5120
|
}
|
|
5121
|
+
function codewithProfileCandidateFromLine(line) {
|
|
5122
|
+
const cols = line.trim().split(/\s+/);
|
|
5123
|
+
if (cols[0] === "*")
|
|
5124
|
+
return cols[1];
|
|
5125
|
+
return cols[0];
|
|
5126
|
+
}
|
|
5127
|
+
function codewithProfileForError(profile) {
|
|
5128
|
+
return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
|
|
5129
|
+
}
|
|
5130
|
+
function assertCodewithAuthProfileSupported(profile) {
|
|
5131
|
+
if (profile.includes("\x00")) {
|
|
5132
|
+
throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5102
5135
|
function metadataEnv(metadata) {
|
|
5103
5136
|
const env = {};
|
|
5104
5137
|
if (metadata.loopId)
|
|
@@ -5165,6 +5198,9 @@ function commandSpec(target, opts) {
|
|
|
5165
5198
|
};
|
|
5166
5199
|
}
|
|
5167
5200
|
const agentTarget = target;
|
|
5201
|
+
if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
|
|
5202
|
+
assertCodewithAuthProfileSupported(agentTarget.authProfile);
|
|
5203
|
+
}
|
|
5168
5204
|
const adapter = providerAdapter(agentTarget.provider);
|
|
5169
5205
|
const invocation = adapter.buildInvocation(agentTarget);
|
|
5170
5206
|
return {
|
|
@@ -5337,7 +5373,8 @@ function remotePreflightScript(spec, metadata) {
|
|
|
5337
5373
|
lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
|
|
5338
5374
|
}
|
|
5339
5375
|
if (spec.nativeAuthProfile?.provider === "codewith") {
|
|
5340
|
-
|
|
5376
|
+
const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
|
|
5377
|
+
lines.push(`__OPENLOOPS_CODEWITH_PROFILE=${shellQuote(spec.nativeAuthProfile.profile)}`, "export __OPENLOOPS_CODEWITH_PROFILE", `__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { candidate = ($1 == "*" ? $2 : $1); if (candidate == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1 } END { exit(found ? 0 : 1) }'; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${profileForError}`)} >&2`, " exit 1", "fi");
|
|
5341
5378
|
}
|
|
5342
5379
|
return lines.join(`
|
|
5343
5380
|
`);
|
|
@@ -5362,9 +5399,9 @@ function assertCodewithProfileListed(profile, result) {
|
|
|
5362
5399
|
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
5363
5400
|
throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
|
|
5364
5401
|
}
|
|
5365
|
-
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(
|
|
5402
|
+
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
|
|
5366
5403
|
if (!profiles.has(profile)) {
|
|
5367
|
-
throw new Error(`codewith auth profile not found: ${profile}`);
|
|
5404
|
+
throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
|
|
5368
5405
|
}
|
|
5369
5406
|
}
|
|
5370
5407
|
function preflightNativeAuthProfileSync(spec, env) {
|
|
@@ -6668,6 +6705,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
6668
6705
|
}
|
|
6669
6706
|
|
|
6670
6707
|
// src/lib/scheduler.ts
|
|
6708
|
+
function loopLane(loop) {
|
|
6709
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
6710
|
+
}
|
|
6671
6711
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
6672
6712
|
if (loop.archivedAt)
|
|
6673
6713
|
return now.toISOString();
|
|
@@ -7060,14 +7100,23 @@ function claimDueRuns(deps) {
|
|
|
7060
7100
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7061
7101
|
if (maxClaims === 0)
|
|
7062
7102
|
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
7103
|
+
const laneLimits = deps.laneLimits;
|
|
7104
|
+
const laneClaims = { command: 0, agent: 0 };
|
|
7105
|
+
const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
|
|
7106
|
+
const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
|
|
7063
7107
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7064
7108
|
if (claims.length >= maxClaims)
|
|
7065
7109
|
break;
|
|
7110
|
+
const lane = loopLane(loop);
|
|
7111
|
+
if (laneFull(lane))
|
|
7112
|
+
continue;
|
|
7066
7113
|
const plan = dueSlots(loop, now);
|
|
7067
7114
|
let loopSkips = 0;
|
|
7068
7115
|
for (const slot of plan.slots) {
|
|
7069
7116
|
if (claims.length >= maxClaims)
|
|
7070
7117
|
break;
|
|
7118
|
+
if (laneFull(lane))
|
|
7119
|
+
break;
|
|
7071
7120
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7072
7121
|
break;
|
|
7073
7122
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7076,6 +7125,7 @@ function claimDueRuns(deps) {
|
|
|
7076
7125
|
if ("loop" in run) {
|
|
7077
7126
|
claims.push(run);
|
|
7078
7127
|
claimed.push(run.run);
|
|
7128
|
+
laneClaims[lane] += 1;
|
|
7079
7129
|
} else if (run.status === "skipped") {
|
|
7080
7130
|
skipped.push(run);
|
|
7081
7131
|
loopSkips += 1;
|
|
@@ -7344,13 +7394,23 @@ function intervalFromEnv() {
|
|
|
7344
7394
|
const value = Number(raw);
|
|
7345
7395
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
7346
7396
|
}
|
|
7347
|
-
function
|
|
7348
|
-
const raw = process.env
|
|
7397
|
+
function positiveIntEnv(name) {
|
|
7398
|
+
const raw = process.env[name];
|
|
7349
7399
|
if (!raw)
|
|
7350
7400
|
return;
|
|
7351
7401
|
const value = Number(raw);
|
|
7352
7402
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
7353
7403
|
}
|
|
7404
|
+
function concurrencyFromEnv() {
|
|
7405
|
+
return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
|
|
7406
|
+
}
|
|
7407
|
+
var DEFAULT_COMMAND_CONCURRENCY = 4;
|
|
7408
|
+
var DEFAULT_AGENT_CONCURRENCY = 8;
|
|
7409
|
+
function resolveLaneConcurrency(opts = {}) {
|
|
7410
|
+
const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
|
|
7411
|
+
const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
|
|
7412
|
+
return { command, agent };
|
|
7413
|
+
}
|
|
7354
7414
|
var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
|
7355
7415
|
var DAEMON_LOG_KEEP = 2;
|
|
7356
7416
|
function daemonLogLine(message) {
|
|
@@ -7396,7 +7456,7 @@ async function runDaemon(opts = {}) {
|
|
|
7396
7456
|
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
7397
7457
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
7398
7458
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
7399
|
-
const
|
|
7459
|
+
const laneConcurrency = resolveLaneConcurrency(opts);
|
|
7400
7460
|
const log = opts.log ?? defaultDaemonLog;
|
|
7401
7461
|
const sleep = opts.sleep ?? realSleep;
|
|
7402
7462
|
const lease = store.acquireDaemonLease({
|
|
@@ -7408,12 +7468,13 @@ async function runDaemon(opts = {}) {
|
|
|
7408
7468
|
if (!lease)
|
|
7409
7469
|
throw new Error("another loops daemon holds the database lease");
|
|
7410
7470
|
writePid(process.pid, pidPath);
|
|
7411
|
-
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
|
|
7471
|
+
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
|
|
7412
7472
|
let stopFlag = false;
|
|
7413
7473
|
let leaseLost = false;
|
|
7414
7474
|
let leaseEpoch = 0;
|
|
7415
7475
|
let runAbort = new AbortController;
|
|
7416
7476
|
const activeRuns = new Map;
|
|
7477
|
+
const activeByLane = { command: 0, agent: 0 };
|
|
7417
7478
|
const requestStop = (message) => {
|
|
7418
7479
|
stopFlag = true;
|
|
7419
7480
|
if (!runAbort.signal.aborted)
|
|
@@ -7506,7 +7567,12 @@ async function runDaemon(opts = {}) {
|
|
|
7506
7567
|
log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
|
|
7507
7568
|
};
|
|
7508
7569
|
const startClaim = (claim) => {
|
|
7509
|
-
const
|
|
7570
|
+
const lane = loopLane(claim.loop);
|
|
7571
|
+
activeByLane[lane] += 1;
|
|
7572
|
+
const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
|
|
7573
|
+
activeRuns.delete(claim.run.id);
|
|
7574
|
+
activeByLane[lane] -= 1;
|
|
7575
|
+
});
|
|
7510
7576
|
activeRuns.set(claim.run.id, task);
|
|
7511
7577
|
};
|
|
7512
7578
|
try {
|
|
@@ -7542,19 +7608,23 @@ async function runDaemon(opts = {}) {
|
|
|
7542
7608
|
onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
|
|
7543
7609
|
tickFn: async () => {
|
|
7544
7610
|
ensureLease();
|
|
7545
|
-
const
|
|
7611
|
+
const laneLimits = {
|
|
7612
|
+
command: Math.max(0, laneConcurrency.command - activeByLane.command),
|
|
7613
|
+
agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
|
|
7614
|
+
};
|
|
7546
7615
|
const result = claimDueRuns({
|
|
7547
7616
|
store,
|
|
7548
7617
|
runnerId,
|
|
7549
7618
|
daemonLeaseId: leaseId,
|
|
7550
7619
|
beforeRun: () => ensureLease(),
|
|
7551
|
-
maxClaims:
|
|
7620
|
+
maxClaims: laneLimits.command + laneLimits.agent,
|
|
7621
|
+
laneLimits
|
|
7552
7622
|
});
|
|
7553
7623
|
for (const claim of result.claims)
|
|
7554
7624
|
startClaim(claim);
|
|
7555
7625
|
const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
7556
7626
|
if (changed > 0) {
|
|
7557
|
-
log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
|
|
7627
|
+
log(`tick claimed=${result.claims.length} active=${activeRuns.size} (command=${activeByLane.command} agent=${activeByLane.agent}) skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
|
|
7558
7628
|
}
|
|
7559
7629
|
await reapAbandoned(result.recovered);
|
|
7560
7630
|
}
|
|
@@ -7715,7 +7785,7 @@ function enableStartup(result) {
|
|
|
7715
7785
|
// package.json
|
|
7716
7786
|
var package_default = {
|
|
7717
7787
|
name: "@hasna/loops",
|
|
7718
|
-
version: "0.4.
|
|
7788
|
+
version: "0.4.11",
|
|
7719
7789
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7720
7790
|
type: "module",
|
|
7721
7791
|
main: "dist/index.js",
|
|
@@ -7846,7 +7916,12 @@ function packageVersion() {
|
|
|
7846
7916
|
// src/daemon/index.ts
|
|
7847
7917
|
var program = new Command;
|
|
7848
7918
|
program.name("loops-daemon").description("OpenLoops daemon helper").version(packageVersion());
|
|
7849
|
-
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "
|
|
7919
|
+
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "legacy total knob; sets the agent/workflow lane budget", (value) => Number(value)).option("--command-concurrency <n>", "claim budget for command-target loops (default 4)", (value) => Number(value)).option("--agent-concurrency <n>", "claim budget for agent/workflow-target loops (default 8)", (value) => Number(value)).action(async (opts) => runDaemon({
|
|
7920
|
+
intervalMs: opts.intervalMs,
|
|
7921
|
+
concurrency: opts.concurrency,
|
|
7922
|
+
commandConcurrency: opts.commandConcurrency,
|
|
7923
|
+
agentConcurrency: opts.agentConcurrency
|
|
7924
|
+
}));
|
|
7850
7925
|
program.command("start").action(async () => {
|
|
7851
7926
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|
|
7852
7927
|
console.log(JSON.stringify(result, null, 2));
|
package/dist/index.js
CHANGED
|
@@ -54,8 +54,12 @@ function nowIso() {
|
|
|
54
54
|
import { mkdirSync } from "fs";
|
|
55
55
|
import { homedir } from "os";
|
|
56
56
|
import { join } from "path";
|
|
57
|
+
function homeDir() {
|
|
58
|
+
const home = process.env.HOME?.trim();
|
|
59
|
+
return home ? home : homedir();
|
|
60
|
+
}
|
|
57
61
|
function dataDir() {
|
|
58
|
-
return process.env.LOOPS_DATA_DIR || join(
|
|
62
|
+
return process.env.LOOPS_DATA_DIR || join(homeDir(), ".hasna", "loops");
|
|
59
63
|
}
|
|
60
64
|
function ensureDataDir() {
|
|
61
65
|
const dir = dataDir();
|
|
@@ -72,10 +76,10 @@ function daemonLogPath() {
|
|
|
72
76
|
return join(dataDir(), "daemon.log");
|
|
73
77
|
}
|
|
74
78
|
function systemdServicePath() {
|
|
75
|
-
return join(
|
|
79
|
+
return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
|
|
76
80
|
}
|
|
77
81
|
function launchdPlistPath() {
|
|
78
|
-
return join(
|
|
82
|
+
return join(homeDir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// src/lib/process-identity.ts
|
|
@@ -1472,6 +1476,21 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
|
|
|
1472
1476
|
function scrubbedOrNull(value) {
|
|
1473
1477
|
return value == null ? null : scrubSecrets(value);
|
|
1474
1478
|
}
|
|
1479
|
+
var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
|
|
1480
|
+
function clampPersistedRunOutput(value) {
|
|
1481
|
+
if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
|
|
1482
|
+
return value;
|
|
1483
|
+
const half = Math.floor(MAX_PERSISTED_RUN_OUTPUT_CHARS / 2);
|
|
1484
|
+
const head = value.slice(0, half);
|
|
1485
|
+
const tail = value.slice(value.length - half);
|
|
1486
|
+
const omitted = value.length - head.length - tail.length;
|
|
1487
|
+
return `${head}
|
|
1488
|
+
\u2026[${omitted} chars truncated by loops run-output retention]\u2026
|
|
1489
|
+
${tail}`;
|
|
1490
|
+
}
|
|
1491
|
+
function persistedRunOutput(value) {
|
|
1492
|
+
return clampPersistedRunOutput(scrubbedOrNull(value));
|
|
1493
|
+
}
|
|
1475
1494
|
function chmodIfExists(path, mode) {
|
|
1476
1495
|
try {
|
|
1477
1496
|
if (existsSync(path))
|
|
@@ -3218,8 +3237,8 @@ class Store {
|
|
|
3218
3237
|
))`).run({
|
|
3219
3238
|
$workflowRunId: workflowRunId,
|
|
3220
3239
|
$stepId: stepId,
|
|
3221
|
-
$stdout: progress.stdout === undefined ? null :
|
|
3222
|
-
$stderr: progress.stderr === undefined ? null :
|
|
3240
|
+
$stdout: progress.stdout === undefined ? null : persistedRunOutput(progress.stdout),
|
|
3241
|
+
$stderr: progress.stderr === undefined ? null : persistedRunOutput(progress.stderr),
|
|
3223
3242
|
$updated: now,
|
|
3224
3243
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
3225
3244
|
$now: now
|
|
@@ -3280,8 +3299,8 @@ class Store {
|
|
|
3280
3299
|
$finished: finishedAt,
|
|
3281
3300
|
$exitCode: patch.exitCode ?? null,
|
|
3282
3301
|
$durationMs: patch.durationMs ?? null,
|
|
3283
|
-
$stdout:
|
|
3284
|
-
$stderr:
|
|
3302
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3303
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3285
3304
|
$error: error ?? null,
|
|
3286
3305
|
$updated: finishedAt,
|
|
3287
3306
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
@@ -3659,8 +3678,8 @@ class Store {
|
|
|
3659
3678
|
$pid: patch.pid ?? null,
|
|
3660
3679
|
$exitCode: patch.exitCode ?? null,
|
|
3661
3680
|
$durationMs: patch.durationMs ?? null,
|
|
3662
|
-
$stdout:
|
|
3663
|
-
$stderr:
|
|
3681
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3682
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3664
3683
|
$error: error ?? null,
|
|
3665
3684
|
$updated: finishedAt,
|
|
3666
3685
|
$claimedBy: opts.claimedBy ?? null,
|
|
@@ -4057,8 +4076,8 @@ class Store {
|
|
|
4057
4076
|
$processStartedAt: run.processStartedAt ?? null,
|
|
4058
4077
|
$exitCode: run.exitCode ?? null,
|
|
4059
4078
|
$durationMs: run.durationMs ?? null,
|
|
4060
|
-
$stdout:
|
|
4061
|
-
$stderr:
|
|
4079
|
+
$stdout: persistedRunOutput(run.stdout),
|
|
4080
|
+
$stderr: persistedRunOutput(run.stderr),
|
|
4062
4081
|
$error: scrubbedOrNull(run.error),
|
|
4063
4082
|
$goalRunId: run.goalRunId ?? null,
|
|
4064
4083
|
$created: run.createdAt,
|
|
@@ -4216,7 +4235,7 @@ class Store {
|
|
|
4216
4235
|
// package.json
|
|
4217
4236
|
var package_default = {
|
|
4218
4237
|
name: "@hasna/loops",
|
|
4219
|
-
version: "0.4.
|
|
4238
|
+
version: "0.4.11",
|
|
4220
4239
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4221
4240
|
type: "module",
|
|
4222
4241
|
main: "dist/index.js",
|
|
@@ -5045,6 +5064,20 @@ function notifySpawn(pid, opts) {
|
|
|
5045
5064
|
function shellQuote(value) {
|
|
5046
5065
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
5047
5066
|
}
|
|
5067
|
+
function codewithProfileCandidateFromLine(line) {
|
|
5068
|
+
const cols = line.trim().split(/\s+/);
|
|
5069
|
+
if (cols[0] === "*")
|
|
5070
|
+
return cols[1];
|
|
5071
|
+
return cols[0];
|
|
5072
|
+
}
|
|
5073
|
+
function codewithProfileForError(profile) {
|
|
5074
|
+
return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
|
|
5075
|
+
}
|
|
5076
|
+
function assertCodewithAuthProfileSupported(profile) {
|
|
5077
|
+
if (profile.includes("\x00")) {
|
|
5078
|
+
throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
|
|
5079
|
+
}
|
|
5080
|
+
}
|
|
5048
5081
|
function metadataEnv(metadata) {
|
|
5049
5082
|
const env = {};
|
|
5050
5083
|
if (metadata.loopId)
|
|
@@ -5111,6 +5144,9 @@ function commandSpec(target, opts) {
|
|
|
5111
5144
|
};
|
|
5112
5145
|
}
|
|
5113
5146
|
const agentTarget = target;
|
|
5147
|
+
if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
|
|
5148
|
+
assertCodewithAuthProfileSupported(agentTarget.authProfile);
|
|
5149
|
+
}
|
|
5114
5150
|
const adapter = providerAdapter(agentTarget.provider);
|
|
5115
5151
|
const invocation = adapter.buildInvocation(agentTarget);
|
|
5116
5152
|
return {
|
|
@@ -5283,7 +5319,8 @@ function remotePreflightScript(spec, metadata) {
|
|
|
5283
5319
|
lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
|
|
5284
5320
|
}
|
|
5285
5321
|
if (spec.nativeAuthProfile?.provider === "codewith") {
|
|
5286
|
-
|
|
5322
|
+
const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
|
|
5323
|
+
lines.push(`__OPENLOOPS_CODEWITH_PROFILE=${shellQuote(spec.nativeAuthProfile.profile)}`, "export __OPENLOOPS_CODEWITH_PROFILE", `__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { candidate = ($1 == "*" ? $2 : $1); if (candidate == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1 } END { exit(found ? 0 : 1) }'; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${profileForError}`)} >&2`, " exit 1", "fi");
|
|
5287
5324
|
}
|
|
5288
5325
|
return lines.join(`
|
|
5289
5326
|
`);
|
|
@@ -5308,9 +5345,9 @@ function assertCodewithProfileListed(profile, result) {
|
|
|
5308
5345
|
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
5309
5346
|
throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
|
|
5310
5347
|
}
|
|
5311
|
-
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(
|
|
5348
|
+
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
|
|
5312
5349
|
if (!profiles.has(profile)) {
|
|
5313
|
-
throw new Error(`codewith auth profile not found: ${profile}`);
|
|
5350
|
+
throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
|
|
5314
5351
|
}
|
|
5315
5352
|
}
|
|
5316
5353
|
function preflightNativeAuthProfileSync(spec, env) {
|
|
@@ -7249,6 +7286,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
7249
7286
|
}
|
|
7250
7287
|
|
|
7251
7288
|
// src/lib/scheduler.ts
|
|
7289
|
+
function loopLane(loop) {
|
|
7290
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
7291
|
+
}
|
|
7252
7292
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
7253
7293
|
if (loop.archivedAt)
|
|
7254
7294
|
return now.toISOString();
|
|
@@ -7641,14 +7681,23 @@ function claimDueRuns(deps) {
|
|
|
7641
7681
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7642
7682
|
if (maxClaims === 0)
|
|
7643
7683
|
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
7684
|
+
const laneLimits = deps.laneLimits;
|
|
7685
|
+
const laneClaims = { command: 0, agent: 0 };
|
|
7686
|
+
const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
|
|
7687
|
+
const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
|
|
7644
7688
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7645
7689
|
if (claims.length >= maxClaims)
|
|
7646
7690
|
break;
|
|
7691
|
+
const lane = loopLane(loop);
|
|
7692
|
+
if (laneFull(lane))
|
|
7693
|
+
continue;
|
|
7647
7694
|
const plan = dueSlots(loop, now);
|
|
7648
7695
|
let loopSkips = 0;
|
|
7649
7696
|
for (const slot of plan.slots) {
|
|
7650
7697
|
if (claims.length >= maxClaims)
|
|
7651
7698
|
break;
|
|
7699
|
+
if (laneFull(lane))
|
|
7700
|
+
break;
|
|
7652
7701
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7653
7702
|
break;
|
|
7654
7703
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7657,6 +7706,7 @@ function claimDueRuns(deps) {
|
|
|
7657
7706
|
if ("loop" in run) {
|
|
7658
7707
|
claims.push(run);
|
|
7659
7708
|
claimed.push(run.run);
|
|
7709
|
+
laneClaims[lane] += 1;
|
|
7660
7710
|
} else if (run.status === "skipped") {
|
|
7661
7711
|
skipped.push(run);
|
|
7662
7712
|
loopSkips += 1;
|
package/dist/lib/mode.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// package.json
|
|
3
3
|
var package_default = {
|
|
4
4
|
name: "@hasna/loops",
|
|
5
|
-
version: "0.4.
|
|
5
|
+
version: "0.4.11",
|
|
6
6
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7
7
|
type: "module",
|
|
8
8
|
main: "dist/index.js",
|
|
@@ -7,6 +7,10 @@ export interface PrReviewRoutingDecision {
|
|
|
7
7
|
reviewers: string[];
|
|
8
8
|
selectedReviewer?: string;
|
|
9
9
|
signals: string[];
|
|
10
|
+
/** Set when the route was skipped because the PR is definitively MERGED/CLOSED. */
|
|
11
|
+
freshnessSkip?: boolean;
|
|
12
|
+
/** Resolved PR lifecycle state (e.g. MERGED/CLOSED) when the freshness gate fired. */
|
|
13
|
+
prState?: string;
|
|
10
14
|
}
|
|
11
15
|
/** A GitHub PR reference resolvable to `owner/repo#number`. */
|
|
12
16
|
export interface PrReference {
|
|
@@ -16,6 +20,25 @@ export interface PrReference {
|
|
|
16
20
|
}
|
|
17
21
|
/** Resolves a PR author login from a concrete `owner/repo#number` reference. */
|
|
18
22
|
export type PrAuthorResolver = (ref: PrReference) => string | undefined;
|
|
23
|
+
/** Live PR lifecycle state from a concrete `owner/repo#number` reference. */
|
|
24
|
+
export interface PrLiveState {
|
|
25
|
+
state?: string;
|
|
26
|
+
mergeStateStatus?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Resolves live PR state; returns undefined when it cannot be determined. */
|
|
29
|
+
export type PrStateResolver = (ref: PrReference) => PrLiveState | undefined;
|
|
19
30
|
/** Extracts a concrete owner/repo/number PR reference from route evidence text. */
|
|
20
31
|
export declare function prReferenceFrom(text: string): PrReference | undefined;
|
|
21
|
-
|
|
32
|
+
/** Canonical `owner/repo#number`, lowercased for case-stable dedupe. */
|
|
33
|
+
export declare function prFingerprint(ref: PrReference): string;
|
|
34
|
+
/**
|
|
35
|
+
* Stable GitHub `owner/repo#number` fingerprint for a PR-subject task. The repos
|
|
36
|
+
* registry maps several local checkouts to one GitHub repo, so a single PR is
|
|
37
|
+
* minted as 2-3 duplicate todos tasks (distinct ids + distinct checkout paths).
|
|
38
|
+
* Keying dedupe on this fingerprint collapses them to one work item instead of
|
|
39
|
+
* burning a full worker cycle per checkout. Prefers an explicit PR fingerprint
|
|
40
|
+
* field, then a canonical PR URL / `github-pr:` handle in route evidence;
|
|
41
|
+
* returns undefined for tasks with no concrete PR reference (keep path/id keying).
|
|
42
|
+
*/
|
|
43
|
+
export declare function prFingerprintFromTask(data: Record<string, unknown>, metadata: Record<string, unknown>): string | undefined;
|
|
44
|
+
export declare function prReviewRoutingDecision(data: Record<string, unknown>, metadata: Record<string, unknown>, opts: TodosTaskRouteOptions, resolveAuthor?: PrAuthorResolver, resolveState?: PrStateResolver): PrReviewRoutingDecision;
|
package/dist/lib/scheduler.d.ts
CHANGED
|
@@ -29,6 +29,18 @@ export interface ClaimedLoopRun {
|
|
|
29
29
|
export interface ClaimDueRunsResult extends TickResult {
|
|
30
30
|
claims: ClaimedLoopRun[];
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Scheduler concurrency lanes. Command-target loops are typically fast
|
|
34
|
+
* (monitors, digests, syncs); agent/workflow-target loops are long-running
|
|
35
|
+
* headless workers (minutes to over an hour). They draw from separate claim
|
|
36
|
+
* budgets so a saturated agent lane cannot starve fast command loops (and vice
|
|
37
|
+
* versa) — the single shared pool let long workers monopolize every slot.
|
|
38
|
+
*/
|
|
39
|
+
export type SchedulerLane = "command" | "agent";
|
|
40
|
+
/** The concurrency lane a loop's target belongs to. */
|
|
41
|
+
export declare function loopLane(loop: Loop): SchedulerLane;
|
|
42
|
+
/** Remaining claim budget per lane for a single `claimDueRuns` pass. */
|
|
43
|
+
export type LaneLimits = Partial<Record<SchedulerLane, number>>;
|
|
32
44
|
export declare function manualRunScheduledFor(loop: Loop, now?: Date): string;
|
|
33
45
|
export declare function shouldAdvanceManualRun(loop: Loop, scheduledFor: string, now?: Date): boolean;
|
|
34
46
|
export type ManualRunSource = "ad_hoc" | "due_slot" | "retry_slot";
|
|
@@ -121,5 +133,6 @@ export declare function executeClaimedRun(deps: {
|
|
|
121
133
|
}): Promise<LoopRun>;
|
|
122
134
|
export declare function claimDueRuns(deps: SchedulerDeps & {
|
|
123
135
|
maxClaims?: number;
|
|
136
|
+
laneLimits?: LaneLimits;
|
|
124
137
|
}): ClaimDueRunsResult;
|
|
125
138
|
export declare function tick(deps: SchedulerDeps): Promise<TickResult>;
|