@hasna/loops 0.4.10 → 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 +114 -13
- package/dist/daemon/daemon.d.ts +18 -0
- package/dist/daemon/index.js +48 -10
- package/dist/index.js +14 -1
- package/dist/lib/mode.js +1 -1
- package/dist/lib/route/pr-review.d.ts +16 -0
- package/dist/lib/scheduler.d.ts +13 -0
- package/dist/mcp/index.js +14 -1
- package/dist/runner/index.js +1 -1
- package/dist/sdk/index.js +14 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ documented in this file. Version entries are generated from the
|
|
|
5
5
|
conventional-commit git history; one commit maps to one released patch version
|
|
6
6
|
unless noted.
|
|
7
7
|
|
|
8
|
+
## 0.4.11 (2026-07-05)
|
|
9
|
+
|
|
10
|
+
Scheduler fairness and PR-route hygiene: fast command loops no longer starve
|
|
11
|
+
behind long agent workers, PR tasks dedupe by GitHub identity, and merged/closed
|
|
12
|
+
PR routes close themselves out of the queue.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Daemon scheduler — separated concurrency lanes:** the daemon shared a single
|
|
17
|
+
concurrency pool, so long agent/workflow workers (minutes to over an hour) could
|
|
18
|
+
occupy every slot and starve fast command loops (monitors, digests, syncs) —
|
|
19
|
+
even the merge router starved. Command-target and agent/workflow-target loops
|
|
20
|
+
now draw from independent claim budgets. New env `LOOPS_DAEMON_COMMAND_CONCURRENCY`
|
|
21
|
+
(default 4) and `LOOPS_DAEMON_AGENT_CONCURRENCY` (default 8); the legacy
|
|
22
|
+
`LOOPS_DAEMON_CONCURRENCY` / `--concurrency` still work as the agent-lane knob for
|
|
23
|
+
back-compat, and new `--command-concurrency` / `--agent-concurrency` daemon flags
|
|
24
|
+
are added. A saturated lane no longer consumes the other lane's budget.
|
|
25
|
+
- **Todos-task routing — PR fingerprint dedupe:** the repos registry maps several
|
|
26
|
+
local checkouts to one GitHub repo, so a single PR was minted as 2-3 duplicate
|
|
27
|
+
todos tasks (distinct ids + checkout paths) that each dispatched a full worker.
|
|
28
|
+
PR-subject tasks now dedupe by a stable `owner/repo#number` fingerprint (from an
|
|
29
|
+
explicit PR fingerprint field or a canonical PR URL / `github-pr:` handle,
|
|
30
|
+
lowercased) instead of the `(source-path, task-id)` key, collapsing the
|
|
31
|
+
duplicates to one work item. Non-PR tasks keep the path/id key (no false dedupe).
|
|
32
|
+
- **Todos-task routing — freshness skip closes the task:** 0.4.10's freshness gate
|
|
33
|
+
stopped dispatching a worker for a merged/closed PR but left the task pending +
|
|
34
|
+
route-opted-in, so every drain tick re-skipped it forever. On a definitive
|
|
35
|
+
MERGED/CLOSED freshness skip the drain now marks the source task done and strips
|
|
36
|
+
its `auto:route`/`route:enabled` opt-in so it leaves the queue. Only fires when
|
|
37
|
+
gh/metadata definitively reports MERGED/CLOSED; never on dry-run.
|
|
38
|
+
|
|
39
|
+
## 0.4.10 (2026-07-05)
|
|
40
|
+
|
|
41
|
+
Hotfix bundle for the PR-merge drain pipeline.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **Todos-task drain:** re-admit a terminal work item whose todos task is still
|
|
46
|
+
actionable (bounded by a redispatch cap + per-attempt backoff) instead of
|
|
47
|
+
deduping it away forever, so real task work keeps dispatching.
|
|
48
|
+
- **PR review gate:** freshness gate skips merge/review routes for already
|
|
49
|
+
merged/closed PRs; normalize `app/<slug>` and `<slug>[bot]` bot logins so
|
|
50
|
+
bot-authored PRs resolve an author instead of hard-failing the format check.
|
|
51
|
+
- **Runtime/store:** cap persisted run stdout/stderr; project path resolution fix.
|
|
52
|
+
|
|
8
53
|
## 0.4.9 (2026-07-04)
|
|
9
54
|
|
|
10
55
|
Unblock the PR-merge pipeline: task-lifecycle/route workers now dispatch real
|
package/dist/api/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.11",
|
|
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",
|
package/dist/cli/index.js
CHANGED
|
@@ -4237,7 +4237,7 @@ class Store {
|
|
|
4237
4237
|
// package.json
|
|
4238
4238
|
var package_default = {
|
|
4239
4239
|
name: "@hasna/loops",
|
|
4240
|
-
version: "0.4.
|
|
4240
|
+
version: "0.4.11",
|
|
4241
4241
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4242
4242
|
type: "module",
|
|
4243
4243
|
main: "dist/index.js",
|
|
@@ -6969,6 +6969,9 @@ function buildHealthReport(store, opts = {}) {
|
|
|
6969
6969
|
}
|
|
6970
6970
|
|
|
6971
6971
|
// src/lib/scheduler.ts
|
|
6972
|
+
function loopLane(loop) {
|
|
6973
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
6974
|
+
}
|
|
6972
6975
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
6973
6976
|
if (loop.archivedAt)
|
|
6974
6977
|
return now.toISOString();
|
|
@@ -7361,14 +7364,23 @@ function claimDueRuns(deps) {
|
|
|
7361
7364
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7362
7365
|
if (maxClaims === 0)
|
|
7363
7366
|
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
7367
|
+
const laneLimits = deps.laneLimits;
|
|
7368
|
+
const laneClaims = { command: 0, agent: 0 };
|
|
7369
|
+
const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
|
|
7370
|
+
const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
|
|
7364
7371
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7365
7372
|
if (claims.length >= maxClaims)
|
|
7366
7373
|
break;
|
|
7374
|
+
const lane = loopLane(loop);
|
|
7375
|
+
if (laneFull(lane))
|
|
7376
|
+
continue;
|
|
7367
7377
|
const plan = dueSlots(loop, now);
|
|
7368
7378
|
let loopSkips = 0;
|
|
7369
7379
|
for (const slot of plan.slots) {
|
|
7370
7380
|
if (claims.length >= maxClaims)
|
|
7371
7381
|
break;
|
|
7382
|
+
if (laneFull(lane))
|
|
7383
|
+
break;
|
|
7372
7384
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7373
7385
|
break;
|
|
7374
7386
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7377,6 +7389,7 @@ function claimDueRuns(deps) {
|
|
|
7377
7389
|
if ("loop" in run) {
|
|
7378
7390
|
claims.push(run);
|
|
7379
7391
|
claimed.push(run.run);
|
|
7392
|
+
laneClaims[lane] += 1;
|
|
7380
7393
|
} else if (run.status === "skipped") {
|
|
7381
7394
|
skipped.push(run);
|
|
7382
7395
|
loopSkips += 1;
|
|
@@ -7648,13 +7661,23 @@ function intervalFromEnv() {
|
|
|
7648
7661
|
const value = Number(raw);
|
|
7649
7662
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
7650
7663
|
}
|
|
7651
|
-
function
|
|
7652
|
-
const raw = process.env
|
|
7664
|
+
function positiveIntEnv(name) {
|
|
7665
|
+
const raw = process.env[name];
|
|
7653
7666
|
if (!raw)
|
|
7654
7667
|
return;
|
|
7655
7668
|
const value = Number(raw);
|
|
7656
7669
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
7657
7670
|
}
|
|
7671
|
+
function concurrencyFromEnv() {
|
|
7672
|
+
return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
|
|
7673
|
+
}
|
|
7674
|
+
var DEFAULT_COMMAND_CONCURRENCY = 4;
|
|
7675
|
+
var DEFAULT_AGENT_CONCURRENCY = 8;
|
|
7676
|
+
function resolveLaneConcurrency(opts = {}) {
|
|
7677
|
+
const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
|
|
7678
|
+
const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
|
|
7679
|
+
return { command, agent };
|
|
7680
|
+
}
|
|
7658
7681
|
var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
|
7659
7682
|
var DAEMON_LOG_KEEP = 2;
|
|
7660
7683
|
function daemonLogLine(message) {
|
|
@@ -7700,7 +7723,7 @@ async function runDaemon(opts = {}) {
|
|
|
7700
7723
|
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
7701
7724
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
7702
7725
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
7703
|
-
const
|
|
7726
|
+
const laneConcurrency = resolveLaneConcurrency(opts);
|
|
7704
7727
|
const log = opts.log ?? defaultDaemonLog;
|
|
7705
7728
|
const sleep = opts.sleep ?? realSleep;
|
|
7706
7729
|
const lease = store.acquireDaemonLease({
|
|
@@ -7712,12 +7735,13 @@ async function runDaemon(opts = {}) {
|
|
|
7712
7735
|
if (!lease)
|
|
7713
7736
|
throw new Error("another loops daemon holds the database lease");
|
|
7714
7737
|
writePid(process.pid, pidPath);
|
|
7715
|
-
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
|
|
7738
|
+
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
|
|
7716
7739
|
let stopFlag = false;
|
|
7717
7740
|
let leaseLost = false;
|
|
7718
7741
|
let leaseEpoch = 0;
|
|
7719
7742
|
let runAbort = new AbortController;
|
|
7720
7743
|
const activeRuns = new Map;
|
|
7744
|
+
const activeByLane = { command: 0, agent: 0 };
|
|
7721
7745
|
const requestStop = (message) => {
|
|
7722
7746
|
stopFlag = true;
|
|
7723
7747
|
if (!runAbort.signal.aborted)
|
|
@@ -7810,7 +7834,12 @@ async function runDaemon(opts = {}) {
|
|
|
7810
7834
|
log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
|
|
7811
7835
|
};
|
|
7812
7836
|
const startClaim = (claim) => {
|
|
7813
|
-
const
|
|
7837
|
+
const lane = loopLane(claim.loop);
|
|
7838
|
+
activeByLane[lane] += 1;
|
|
7839
|
+
const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
|
|
7840
|
+
activeRuns.delete(claim.run.id);
|
|
7841
|
+
activeByLane[lane] -= 1;
|
|
7842
|
+
});
|
|
7814
7843
|
activeRuns.set(claim.run.id, task);
|
|
7815
7844
|
};
|
|
7816
7845
|
try {
|
|
@@ -7846,19 +7875,23 @@ async function runDaemon(opts = {}) {
|
|
|
7846
7875
|
onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
|
|
7847
7876
|
tickFn: async () => {
|
|
7848
7877
|
ensureLease();
|
|
7849
|
-
const
|
|
7878
|
+
const laneLimits = {
|
|
7879
|
+
command: Math.max(0, laneConcurrency.command - activeByLane.command),
|
|
7880
|
+
agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
|
|
7881
|
+
};
|
|
7850
7882
|
const result = claimDueRuns({
|
|
7851
7883
|
store,
|
|
7852
7884
|
runnerId,
|
|
7853
7885
|
daemonLeaseId: leaseId,
|
|
7854
7886
|
beforeRun: () => ensureLease(),
|
|
7855
|
-
maxClaims:
|
|
7887
|
+
maxClaims: laneLimits.command + laneLimits.agent,
|
|
7888
|
+
laneLimits
|
|
7856
7889
|
});
|
|
7857
7890
|
for (const claim of result.claims)
|
|
7858
7891
|
startClaim(claim);
|
|
7859
7892
|
const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
7860
7893
|
if (changed > 0) {
|
|
7861
|
-
log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
|
|
7894
|
+
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}`);
|
|
7862
7895
|
}
|
|
7863
7896
|
await reapAbandoned(result.recovered);
|
|
7864
7897
|
}
|
|
@@ -11513,6 +11546,37 @@ function prReferenceFrom(text) {
|
|
|
11513
11546
|
return { owner: short[1], repo: short[2], number: Number(short[3]) };
|
|
11514
11547
|
return;
|
|
11515
11548
|
}
|
|
11549
|
+
var PR_FINGERPRINT_FIELDS = [
|
|
11550
|
+
"pr_fingerprint",
|
|
11551
|
+
"prFingerprint",
|
|
11552
|
+
"github_pr",
|
|
11553
|
+
"githubPr",
|
|
11554
|
+
"github_pr_fingerprint",
|
|
11555
|
+
"githubPrFingerprint",
|
|
11556
|
+
"pull_request_fingerprint",
|
|
11557
|
+
"pullRequestFingerprint"
|
|
11558
|
+
];
|
|
11559
|
+
function prFingerprint(ref) {
|
|
11560
|
+
return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`;
|
|
11561
|
+
}
|
|
11562
|
+
function prFingerprintFromField(value) {
|
|
11563
|
+
if (!value)
|
|
11564
|
+
return;
|
|
11565
|
+
const match = /([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)/.exec(value);
|
|
11566
|
+
return match ? prFingerprint({ owner: match[1], repo: match[2], number: Number(match[3]) }) : undefined;
|
|
11567
|
+
}
|
|
11568
|
+
function prFingerprintFromTask(data, metadata) {
|
|
11569
|
+
const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
|
|
11570
|
+
for (const field of PR_FINGERPRINT_FIELDS) {
|
|
11571
|
+
for (const value of routeFieldValues(records, field)) {
|
|
11572
|
+
const fingerprint = prFingerprintFromField(value);
|
|
11573
|
+
if (fingerprint)
|
|
11574
|
+
return fingerprint;
|
|
11575
|
+
}
|
|
11576
|
+
}
|
|
11577
|
+
const ref = prReferenceFrom(routeEvidenceText(records));
|
|
11578
|
+
return ref ? prFingerprint(ref) : undefined;
|
|
11579
|
+
}
|
|
11516
11580
|
function ghAuthorResolver(ref) {
|
|
11517
11581
|
const result = spawnSync7("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "author", "-q", ".author.login"], { encoding: "utf8", timeout: 20000 });
|
|
11518
11582
|
if (result.error || result.status !== 0)
|
|
@@ -11588,7 +11652,9 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
|
|
|
11588
11652
|
allowed: false,
|
|
11589
11653
|
reason: `PR is already ${prState.toLowerCase()}; skipping merge/review route (freshness gate)`,
|
|
11590
11654
|
reviewers: [],
|
|
11591
|
-
signals: [...signals, "pr-not-open"]
|
|
11655
|
+
signals: [...signals, "pr-not-open"],
|
|
11656
|
+
freshnessSkip: true,
|
|
11657
|
+
prState
|
|
11592
11658
|
};
|
|
11593
11659
|
}
|
|
11594
11660
|
const reviewers = githubLogins([
|
|
@@ -12045,7 +12111,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
12045
12111
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
12046
12112
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
12047
12113
|
const sourceProjectIdempotencyPrefix = sourceTodosProjectPath ? normalizeRoutePath(sourceTodosProjectPath) ?? resolve7(sourceTodosProjectPath) : undefined;
|
|
12048
|
-
const
|
|
12114
|
+
const prFingerprint2 = prFingerprintFromTask(data, metadata);
|
|
12115
|
+
const idempotencyKey = prFingerprint2 ? `todos-task:pr:${prFingerprint2}` : sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
|
|
12049
12116
|
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
12050
12117
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
12051
12118
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
@@ -12077,7 +12144,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
12077
12144
|
event,
|
|
12078
12145
|
taskId,
|
|
12079
12146
|
routeError: true,
|
|
12080
|
-
prReviewRouting
|
|
12147
|
+
prReviewRouting,
|
|
12148
|
+
...prReviewRouting.freshnessSkip ? { freshnessSkip: true, prState: prReviewRouting.prState } : {}
|
|
12081
12149
|
},
|
|
12082
12150
|
human: `skipped task ${taskId}: ${prReviewRouting.reason}`
|
|
12083
12151
|
};
|
|
@@ -12638,6 +12706,31 @@ function markInvalidDrainTaskNonRouteable(sourceTodosProject, task, reason) {
|
|
|
12638
12706
|
untagAutoRoute: todosMutationSummary(untagResult)
|
|
12639
12707
|
};
|
|
12640
12708
|
}
|
|
12709
|
+
function isFreshnessSkip(result) {
|
|
12710
|
+
return result.kind === "skipped" && result.value.freshnessSkip === true;
|
|
12711
|
+
}
|
|
12712
|
+
function closeFreshnessSkippedTask(sourceTodosProject, task, reason) {
|
|
12713
|
+
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
12714
|
+
if (!taskId)
|
|
12715
|
+
return { attempted: false, reason: "task id missing" };
|
|
12716
|
+
const comment = `OpenLoops freshness gate closed this task: ${reason}. The referenced PR is already merged/closed, so the ` + `merge/review route will not dispatch a worker. Marked done and removed auto:route/route:enabled so drains stop re-skipping it.`;
|
|
12717
|
+
const commentResult = runLocalCommand("todos", ["--project", sourceTodosProject, "comment", taskId, comment], { timeoutMs: 30000 });
|
|
12718
|
+
const doneResult = runLocalCommand("todos", ["--project", sourceTodosProject, "done", taskId, "--notes", "PR already merged/closed; closed by OpenLoops freshness gate"], { timeoutMs: 30000 });
|
|
12719
|
+
const untagAutoRoute = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
|
|
12720
|
+
const untagRouteEnabled = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "route:enabled"], { timeoutMs: 30000 });
|
|
12721
|
+
const leftQueue = doneResult.ok || untagAutoRoute.ok || untagRouteEnabled.ok;
|
|
12722
|
+
return {
|
|
12723
|
+
ok: leftQueue,
|
|
12724
|
+
attempted: true,
|
|
12725
|
+
taskId,
|
|
12726
|
+
action: "freshness-close",
|
|
12727
|
+
error: leftQueue ? undefined : "task could not be closed or untagged; inspect per-command results",
|
|
12728
|
+
comment: todosMutationSummary(commentResult),
|
|
12729
|
+
done: todosMutationSummary(doneResult),
|
|
12730
|
+
untagAutoRoute: todosMutationSummary(untagAutoRoute),
|
|
12731
|
+
untagRouteEnabled: todosMutationSummary(untagRouteEnabled)
|
|
12732
|
+
};
|
|
12733
|
+
}
|
|
12641
12734
|
function drainTodosTaskRoutes(opts) {
|
|
12642
12735
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
12643
12736
|
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
@@ -12679,6 +12772,12 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12679
12772
|
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { fatal: true });
|
|
12680
12773
|
}
|
|
12681
12774
|
}
|
|
12775
|
+
if (!opts.dryRun && isFreshnessSkip(result)) {
|
|
12776
|
+
const sourceTaskProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) ?? todosProject : todosProject;
|
|
12777
|
+
const reason = stringField(result.value.reason) ?? "PR already merged/closed (freshness gate)";
|
|
12778
|
+
const sourceTaskUpdate = closeFreshnessSkippedTask(sourceTaskProject, task, reason);
|
|
12779
|
+
result = { ...result, value: { ...result.value, sourceTaskUpdate } };
|
|
12780
|
+
}
|
|
12682
12781
|
results.push(result);
|
|
12683
12782
|
if (result.kind === "created")
|
|
12684
12783
|
created += 1;
|
|
@@ -12704,6 +12803,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12704
12803
|
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
12705
12804
|
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
12706
12805
|
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
12806
|
+
freshnessClosed: results.filter((result) => isFreshnessSkip(result) && result.value.sourceTaskUpdate?.attempted === true).length,
|
|
12707
12807
|
fatal: results.filter((result) => result.value.fatal === true).length,
|
|
12708
12808
|
maxDispatch,
|
|
12709
12809
|
source: "todos ready",
|
|
@@ -12732,6 +12832,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12732
12832
|
deduped: report.deduped,
|
|
12733
12833
|
throttled: report.throttled,
|
|
12734
12834
|
skipped: report.skipped,
|
|
12835
|
+
freshnessClosed: report.freshnessClosed,
|
|
12735
12836
|
fatal: report.fatal,
|
|
12736
12837
|
maxDispatch: report.maxDispatch,
|
|
12737
12838
|
source: report.source,
|
|
@@ -12741,7 +12842,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12741
12842
|
} : { ...report, evidencePath };
|
|
12742
12843
|
return {
|
|
12743
12844
|
value,
|
|
12744
|
-
human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped} fatal=${report.fatal}`
|
|
12845
|
+
human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped} freshnessClosed=${report.freshnessClosed} fatal=${report.fatal}`
|
|
12745
12846
|
};
|
|
12746
12847
|
}
|
|
12747
12848
|
// src/lib/route/route-tasks.ts
|
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
|
@@ -6705,6 +6705,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
6705
6705
|
}
|
|
6706
6706
|
|
|
6707
6707
|
// src/lib/scheduler.ts
|
|
6708
|
+
function loopLane(loop) {
|
|
6709
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
6710
|
+
}
|
|
6708
6711
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
6709
6712
|
if (loop.archivedAt)
|
|
6710
6713
|
return now.toISOString();
|
|
@@ -7097,14 +7100,23 @@ function claimDueRuns(deps) {
|
|
|
7097
7100
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7098
7101
|
if (maxClaims === 0)
|
|
7099
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);
|
|
7100
7107
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7101
7108
|
if (claims.length >= maxClaims)
|
|
7102
7109
|
break;
|
|
7110
|
+
const lane = loopLane(loop);
|
|
7111
|
+
if (laneFull(lane))
|
|
7112
|
+
continue;
|
|
7103
7113
|
const plan = dueSlots(loop, now);
|
|
7104
7114
|
let loopSkips = 0;
|
|
7105
7115
|
for (const slot of plan.slots) {
|
|
7106
7116
|
if (claims.length >= maxClaims)
|
|
7107
7117
|
break;
|
|
7118
|
+
if (laneFull(lane))
|
|
7119
|
+
break;
|
|
7108
7120
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7109
7121
|
break;
|
|
7110
7122
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7113,6 +7125,7 @@ function claimDueRuns(deps) {
|
|
|
7113
7125
|
if ("loop" in run) {
|
|
7114
7126
|
claims.push(run);
|
|
7115
7127
|
claimed.push(run.run);
|
|
7128
|
+
laneClaims[lane] += 1;
|
|
7116
7129
|
} else if (run.status === "skipped") {
|
|
7117
7130
|
skipped.push(run);
|
|
7118
7131
|
loopSkips += 1;
|
|
@@ -7381,13 +7394,23 @@ function intervalFromEnv() {
|
|
|
7381
7394
|
const value = Number(raw);
|
|
7382
7395
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
7383
7396
|
}
|
|
7384
|
-
function
|
|
7385
|
-
const raw = process.env
|
|
7397
|
+
function positiveIntEnv(name) {
|
|
7398
|
+
const raw = process.env[name];
|
|
7386
7399
|
if (!raw)
|
|
7387
7400
|
return;
|
|
7388
7401
|
const value = Number(raw);
|
|
7389
7402
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
7390
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
|
+
}
|
|
7391
7414
|
var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
|
7392
7415
|
var DAEMON_LOG_KEEP = 2;
|
|
7393
7416
|
function daemonLogLine(message) {
|
|
@@ -7433,7 +7456,7 @@ async function runDaemon(opts = {}) {
|
|
|
7433
7456
|
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
7434
7457
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
7435
7458
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
7436
|
-
const
|
|
7459
|
+
const laneConcurrency = resolveLaneConcurrency(opts);
|
|
7437
7460
|
const log = opts.log ?? defaultDaemonLog;
|
|
7438
7461
|
const sleep = opts.sleep ?? realSleep;
|
|
7439
7462
|
const lease = store.acquireDaemonLease({
|
|
@@ -7445,12 +7468,13 @@ async function runDaemon(opts = {}) {
|
|
|
7445
7468
|
if (!lease)
|
|
7446
7469
|
throw new Error("another loops daemon holds the database lease");
|
|
7447
7470
|
writePid(process.pid, pidPath);
|
|
7448
|
-
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}`);
|
|
7449
7472
|
let stopFlag = false;
|
|
7450
7473
|
let leaseLost = false;
|
|
7451
7474
|
let leaseEpoch = 0;
|
|
7452
7475
|
let runAbort = new AbortController;
|
|
7453
7476
|
const activeRuns = new Map;
|
|
7477
|
+
const activeByLane = { command: 0, agent: 0 };
|
|
7454
7478
|
const requestStop = (message) => {
|
|
7455
7479
|
stopFlag = true;
|
|
7456
7480
|
if (!runAbort.signal.aborted)
|
|
@@ -7543,7 +7567,12 @@ async function runDaemon(opts = {}) {
|
|
|
7543
7567
|
log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
|
|
7544
7568
|
};
|
|
7545
7569
|
const startClaim = (claim) => {
|
|
7546
|
-
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
|
+
});
|
|
7547
7576
|
activeRuns.set(claim.run.id, task);
|
|
7548
7577
|
};
|
|
7549
7578
|
try {
|
|
@@ -7579,19 +7608,23 @@ async function runDaemon(opts = {}) {
|
|
|
7579
7608
|
onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
|
|
7580
7609
|
tickFn: async () => {
|
|
7581
7610
|
ensureLease();
|
|
7582
|
-
const
|
|
7611
|
+
const laneLimits = {
|
|
7612
|
+
command: Math.max(0, laneConcurrency.command - activeByLane.command),
|
|
7613
|
+
agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
|
|
7614
|
+
};
|
|
7583
7615
|
const result = claimDueRuns({
|
|
7584
7616
|
store,
|
|
7585
7617
|
runnerId,
|
|
7586
7618
|
daemonLeaseId: leaseId,
|
|
7587
7619
|
beforeRun: () => ensureLease(),
|
|
7588
|
-
maxClaims:
|
|
7620
|
+
maxClaims: laneLimits.command + laneLimits.agent,
|
|
7621
|
+
laneLimits
|
|
7589
7622
|
});
|
|
7590
7623
|
for (const claim of result.claims)
|
|
7591
7624
|
startClaim(claim);
|
|
7592
7625
|
const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
7593
7626
|
if (changed > 0) {
|
|
7594
|
-
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}`);
|
|
7595
7628
|
}
|
|
7596
7629
|
await reapAbandoned(result.recovered);
|
|
7597
7630
|
}
|
|
@@ -7752,7 +7785,7 @@ function enableStartup(result) {
|
|
|
7752
7785
|
// package.json
|
|
7753
7786
|
var package_default = {
|
|
7754
7787
|
name: "@hasna/loops",
|
|
7755
|
-
version: "0.4.
|
|
7788
|
+
version: "0.4.11",
|
|
7756
7789
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7757
7790
|
type: "module",
|
|
7758
7791
|
main: "dist/index.js",
|
|
@@ -7883,7 +7916,12 @@ function packageVersion() {
|
|
|
7883
7916
|
// src/daemon/index.ts
|
|
7884
7917
|
var program = new Command;
|
|
7885
7918
|
program.name("loops-daemon").description("OpenLoops daemon helper").version(packageVersion());
|
|
7886
|
-
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
|
+
}));
|
|
7887
7925
|
program.command("start").action(async () => {
|
|
7888
7926
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|
|
7889
7927
|
console.log(JSON.stringify(result, null, 2));
|
package/dist/index.js
CHANGED
|
@@ -4235,7 +4235,7 @@ class Store {
|
|
|
4235
4235
|
// package.json
|
|
4236
4236
|
var package_default = {
|
|
4237
4237
|
name: "@hasna/loops",
|
|
4238
|
-
version: "0.4.
|
|
4238
|
+
version: "0.4.11",
|
|
4239
4239
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4240
4240
|
type: "module",
|
|
4241
4241
|
main: "dist/index.js",
|
|
@@ -7286,6 +7286,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
7286
7286
|
}
|
|
7287
7287
|
|
|
7288
7288
|
// src/lib/scheduler.ts
|
|
7289
|
+
function loopLane(loop) {
|
|
7290
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
7291
|
+
}
|
|
7289
7292
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
7290
7293
|
if (loop.archivedAt)
|
|
7291
7294
|
return now.toISOString();
|
|
@@ -7678,14 +7681,23 @@ function claimDueRuns(deps) {
|
|
|
7678
7681
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7679
7682
|
if (maxClaims === 0)
|
|
7680
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);
|
|
7681
7688
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7682
7689
|
if (claims.length >= maxClaims)
|
|
7683
7690
|
break;
|
|
7691
|
+
const lane = loopLane(loop);
|
|
7692
|
+
if (laneFull(lane))
|
|
7693
|
+
continue;
|
|
7684
7694
|
const plan = dueSlots(loop, now);
|
|
7685
7695
|
let loopSkips = 0;
|
|
7686
7696
|
for (const slot of plan.slots) {
|
|
7687
7697
|
if (claims.length >= maxClaims)
|
|
7688
7698
|
break;
|
|
7699
|
+
if (laneFull(lane))
|
|
7700
|
+
break;
|
|
7689
7701
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7690
7702
|
break;
|
|
7691
7703
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7694,6 +7706,7 @@ function claimDueRuns(deps) {
|
|
|
7694
7706
|
if ("loop" in run) {
|
|
7695
7707
|
claims.push(run);
|
|
7696
7708
|
claimed.push(run.run);
|
|
7709
|
+
laneClaims[lane] += 1;
|
|
7697
7710
|
} else if (run.status === "skipped") {
|
|
7698
7711
|
skipped.push(run);
|
|
7699
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 {
|
|
@@ -25,4 +29,16 @@ export interface PrLiveState {
|
|
|
25
29
|
export type PrStateResolver = (ref: PrReference) => PrLiveState | undefined;
|
|
26
30
|
/** Extracts a concrete owner/repo/number PR reference from route evidence text. */
|
|
27
31
|
export declare function prReferenceFrom(text: string): PrReference | undefined;
|
|
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;
|
|
28
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>;
|
package/dist/mcp/index.js
CHANGED
|
@@ -7021,6 +7021,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
7021
7021
|
}
|
|
7022
7022
|
|
|
7023
7023
|
// src/lib/scheduler.ts
|
|
7024
|
+
function loopLane(loop) {
|
|
7025
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
7026
|
+
}
|
|
7024
7027
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
7025
7028
|
if (loop.archivedAt)
|
|
7026
7029
|
return now.toISOString();
|
|
@@ -7413,14 +7416,23 @@ function claimDueRuns(deps) {
|
|
|
7413
7416
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7414
7417
|
if (maxClaims === 0)
|
|
7415
7418
|
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
7419
|
+
const laneLimits = deps.laneLimits;
|
|
7420
|
+
const laneClaims = { command: 0, agent: 0 };
|
|
7421
|
+
const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
|
|
7422
|
+
const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
|
|
7416
7423
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7417
7424
|
if (claims.length >= maxClaims)
|
|
7418
7425
|
break;
|
|
7426
|
+
const lane = loopLane(loop);
|
|
7427
|
+
if (laneFull(lane))
|
|
7428
|
+
continue;
|
|
7419
7429
|
const plan = dueSlots(loop, now);
|
|
7420
7430
|
let loopSkips = 0;
|
|
7421
7431
|
for (const slot of plan.slots) {
|
|
7422
7432
|
if (claims.length >= maxClaims)
|
|
7423
7433
|
break;
|
|
7434
|
+
if (laneFull(lane))
|
|
7435
|
+
break;
|
|
7424
7436
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7425
7437
|
break;
|
|
7426
7438
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7429,6 +7441,7 @@ function claimDueRuns(deps) {
|
|
|
7429
7441
|
if ("loop" in run) {
|
|
7430
7442
|
claims.push(run);
|
|
7431
7443
|
claimed.push(run.run);
|
|
7444
|
+
laneClaims[lane] += 1;
|
|
7432
7445
|
} else if (run.status === "skipped") {
|
|
7433
7446
|
skipped.push(run);
|
|
7434
7447
|
loopSkips += 1;
|
|
@@ -7468,7 +7481,7 @@ async function tick(deps) {
|
|
|
7468
7481
|
// package.json
|
|
7469
7482
|
var package_default = {
|
|
7470
7483
|
name: "@hasna/loops",
|
|
7471
|
-
version: "0.4.
|
|
7484
|
+
version: "0.4.11",
|
|
7472
7485
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7473
7486
|
type: "module",
|
|
7474
7487
|
main: "dist/index.js",
|
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.11",
|
|
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",
|
package/dist/sdk/index.js
CHANGED
|
@@ -4235,7 +4235,7 @@ class Store {
|
|
|
4235
4235
|
// package.json
|
|
4236
4236
|
var package_default = {
|
|
4237
4237
|
name: "@hasna/loops",
|
|
4238
|
-
version: "0.4.
|
|
4238
|
+
version: "0.4.11",
|
|
4239
4239
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4240
4240
|
type: "module",
|
|
4241
4241
|
main: "dist/index.js",
|
|
@@ -7792,6 +7792,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
7792
7792
|
}
|
|
7793
7793
|
|
|
7794
7794
|
// src/lib/scheduler.ts
|
|
7795
|
+
function loopLane(loop) {
|
|
7796
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
7797
|
+
}
|
|
7795
7798
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
7796
7799
|
if (loop.archivedAt)
|
|
7797
7800
|
return now.toISOString();
|
|
@@ -8184,14 +8187,23 @@ function claimDueRuns(deps) {
|
|
|
8184
8187
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
8185
8188
|
if (maxClaims === 0)
|
|
8186
8189
|
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
8190
|
+
const laneLimits = deps.laneLimits;
|
|
8191
|
+
const laneClaims = { command: 0, agent: 0 };
|
|
8192
|
+
const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
|
|
8193
|
+
const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
|
|
8187
8194
|
for (const loop of deps.store.dueLoops(now)) {
|
|
8188
8195
|
if (claims.length >= maxClaims)
|
|
8189
8196
|
break;
|
|
8197
|
+
const lane = loopLane(loop);
|
|
8198
|
+
if (laneFull(lane))
|
|
8199
|
+
continue;
|
|
8190
8200
|
const plan = dueSlots(loop, now);
|
|
8191
8201
|
let loopSkips = 0;
|
|
8192
8202
|
for (const slot of plan.slots) {
|
|
8193
8203
|
if (claims.length >= maxClaims)
|
|
8194
8204
|
break;
|
|
8205
|
+
if (laneFull(lane))
|
|
8206
|
+
break;
|
|
8195
8207
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
8196
8208
|
break;
|
|
8197
8209
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -8200,6 +8212,7 @@ function claimDueRuns(deps) {
|
|
|
8200
8212
|
if ("loop" in run) {
|
|
8201
8213
|
claims.push(run);
|
|
8202
8214
|
claimed.push(run.run);
|
|
8215
|
+
laneClaims[lane] += 1;
|
|
8203
8216
|
} else if (run.status === "skipped") {
|
|
8204
8217
|
skipped.push(run);
|
|
8205
8218
|
loopSkips += 1;
|
package/package.json
CHANGED