@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/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
|
@@ -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,
|
|
@@ -4218,7 +4237,7 @@ class Store {
|
|
|
4218
4237
|
// package.json
|
|
4219
4238
|
var package_default = {
|
|
4220
4239
|
name: "@hasna/loops",
|
|
4221
|
-
version: "0.4.
|
|
4240
|
+
version: "0.4.11",
|
|
4222
4241
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4223
4242
|
type: "module",
|
|
4224
4243
|
main: "dist/index.js",
|
|
@@ -4938,6 +4957,20 @@ function notifySpawn(pid, opts) {
|
|
|
4938
4957
|
function shellQuote(value) {
|
|
4939
4958
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
4940
4959
|
}
|
|
4960
|
+
function codewithProfileCandidateFromLine(line) {
|
|
4961
|
+
const cols = line.trim().split(/\s+/);
|
|
4962
|
+
if (cols[0] === "*")
|
|
4963
|
+
return cols[1];
|
|
4964
|
+
return cols[0];
|
|
4965
|
+
}
|
|
4966
|
+
function codewithProfileForError(profile) {
|
|
4967
|
+
return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
|
|
4968
|
+
}
|
|
4969
|
+
function assertCodewithAuthProfileSupported(profile) {
|
|
4970
|
+
if (profile.includes("\x00")) {
|
|
4971
|
+
throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4941
4974
|
function metadataEnv(metadata) {
|
|
4942
4975
|
const env = {};
|
|
4943
4976
|
if (metadata.loopId)
|
|
@@ -5004,6 +5037,9 @@ function commandSpec(target, opts) {
|
|
|
5004
5037
|
};
|
|
5005
5038
|
}
|
|
5006
5039
|
const agentTarget = target;
|
|
5040
|
+
if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
|
|
5041
|
+
assertCodewithAuthProfileSupported(agentTarget.authProfile);
|
|
5042
|
+
}
|
|
5007
5043
|
const adapter = providerAdapter(agentTarget.provider);
|
|
5008
5044
|
const invocation = adapter.buildInvocation(agentTarget);
|
|
5009
5045
|
return {
|
|
@@ -5176,7 +5212,8 @@ function remotePreflightScript(spec, metadata) {
|
|
|
5176
5212
|
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");
|
|
5177
5213
|
}
|
|
5178
5214
|
if (spec.nativeAuthProfile?.provider === "codewith") {
|
|
5179
|
-
|
|
5215
|
+
const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
|
|
5216
|
+
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");
|
|
5180
5217
|
}
|
|
5181
5218
|
return lines.join(`
|
|
5182
5219
|
`);
|
|
@@ -5201,9 +5238,9 @@ function assertCodewithProfileListed(profile, result) {
|
|
|
5201
5238
|
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
5202
5239
|
throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
|
|
5203
5240
|
}
|
|
5204
|
-
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(
|
|
5241
|
+
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
|
|
5205
5242
|
if (!profiles.has(profile)) {
|
|
5206
|
-
throw new Error(`codewith auth profile not found: ${profile}`);
|
|
5243
|
+
throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
|
|
5207
5244
|
}
|
|
5208
5245
|
}
|
|
5209
5246
|
function preflightNativeAuthProfileSync(spec, env) {
|
|
@@ -6932,6 +6969,9 @@ function buildHealthReport(store, opts = {}) {
|
|
|
6932
6969
|
}
|
|
6933
6970
|
|
|
6934
6971
|
// src/lib/scheduler.ts
|
|
6972
|
+
function loopLane(loop) {
|
|
6973
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
6974
|
+
}
|
|
6935
6975
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
6936
6976
|
if (loop.archivedAt)
|
|
6937
6977
|
return now.toISOString();
|
|
@@ -7324,14 +7364,23 @@ function claimDueRuns(deps) {
|
|
|
7324
7364
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7325
7365
|
if (maxClaims === 0)
|
|
7326
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);
|
|
7327
7371
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7328
7372
|
if (claims.length >= maxClaims)
|
|
7329
7373
|
break;
|
|
7374
|
+
const lane = loopLane(loop);
|
|
7375
|
+
if (laneFull(lane))
|
|
7376
|
+
continue;
|
|
7330
7377
|
const plan = dueSlots(loop, now);
|
|
7331
7378
|
let loopSkips = 0;
|
|
7332
7379
|
for (const slot of plan.slots) {
|
|
7333
7380
|
if (claims.length >= maxClaims)
|
|
7334
7381
|
break;
|
|
7382
|
+
if (laneFull(lane))
|
|
7383
|
+
break;
|
|
7335
7384
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7336
7385
|
break;
|
|
7337
7386
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7340,6 +7389,7 @@ function claimDueRuns(deps) {
|
|
|
7340
7389
|
if ("loop" in run) {
|
|
7341
7390
|
claims.push(run);
|
|
7342
7391
|
claimed.push(run.run);
|
|
7392
|
+
laneClaims[lane] += 1;
|
|
7343
7393
|
} else if (run.status === "skipped") {
|
|
7344
7394
|
skipped.push(run);
|
|
7345
7395
|
loopSkips += 1;
|
|
@@ -7611,13 +7661,23 @@ function intervalFromEnv() {
|
|
|
7611
7661
|
const value = Number(raw);
|
|
7612
7662
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
7613
7663
|
}
|
|
7614
|
-
function
|
|
7615
|
-
const raw = process.env
|
|
7664
|
+
function positiveIntEnv(name) {
|
|
7665
|
+
const raw = process.env[name];
|
|
7616
7666
|
if (!raw)
|
|
7617
7667
|
return;
|
|
7618
7668
|
const value = Number(raw);
|
|
7619
7669
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
7620
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
|
+
}
|
|
7621
7681
|
var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
|
7622
7682
|
var DAEMON_LOG_KEEP = 2;
|
|
7623
7683
|
function daemonLogLine(message) {
|
|
@@ -7663,7 +7723,7 @@ async function runDaemon(opts = {}) {
|
|
|
7663
7723
|
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
7664
7724
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
7665
7725
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
7666
|
-
const
|
|
7726
|
+
const laneConcurrency = resolveLaneConcurrency(opts);
|
|
7667
7727
|
const log = opts.log ?? defaultDaemonLog;
|
|
7668
7728
|
const sleep = opts.sleep ?? realSleep;
|
|
7669
7729
|
const lease = store.acquireDaemonLease({
|
|
@@ -7675,12 +7735,13 @@ async function runDaemon(opts = {}) {
|
|
|
7675
7735
|
if (!lease)
|
|
7676
7736
|
throw new Error("another loops daemon holds the database lease");
|
|
7677
7737
|
writePid(process.pid, pidPath);
|
|
7678
|
-
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}`);
|
|
7679
7739
|
let stopFlag = false;
|
|
7680
7740
|
let leaseLost = false;
|
|
7681
7741
|
let leaseEpoch = 0;
|
|
7682
7742
|
let runAbort = new AbortController;
|
|
7683
7743
|
const activeRuns = new Map;
|
|
7744
|
+
const activeByLane = { command: 0, agent: 0 };
|
|
7684
7745
|
const requestStop = (message) => {
|
|
7685
7746
|
stopFlag = true;
|
|
7686
7747
|
if (!runAbort.signal.aborted)
|
|
@@ -7773,7 +7834,12 @@ async function runDaemon(opts = {}) {
|
|
|
7773
7834
|
log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
|
|
7774
7835
|
};
|
|
7775
7836
|
const startClaim = (claim) => {
|
|
7776
|
-
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
|
+
});
|
|
7777
7843
|
activeRuns.set(claim.run.id, task);
|
|
7778
7844
|
};
|
|
7779
7845
|
try {
|
|
@@ -7809,19 +7875,23 @@ async function runDaemon(opts = {}) {
|
|
|
7809
7875
|
onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
|
|
7810
7876
|
tickFn: async () => {
|
|
7811
7877
|
ensureLease();
|
|
7812
|
-
const
|
|
7878
|
+
const laneLimits = {
|
|
7879
|
+
command: Math.max(0, laneConcurrency.command - activeByLane.command),
|
|
7880
|
+
agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
|
|
7881
|
+
};
|
|
7813
7882
|
const result = claimDueRuns({
|
|
7814
7883
|
store,
|
|
7815
7884
|
runnerId,
|
|
7816
7885
|
daemonLeaseId: leaseId,
|
|
7817
7886
|
beforeRun: () => ensureLease(),
|
|
7818
|
-
maxClaims:
|
|
7887
|
+
maxClaims: laneLimits.command + laneLimits.agent,
|
|
7888
|
+
laneLimits
|
|
7819
7889
|
});
|
|
7820
7890
|
for (const claim of result.claims)
|
|
7821
7891
|
startClaim(claim);
|
|
7822
7892
|
const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
7823
7893
|
if (changed > 0) {
|
|
7824
|
-
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}`);
|
|
7825
7895
|
}
|
|
7826
7896
|
await reapAbandoned(result.recovered);
|
|
7827
7897
|
}
|
|
@@ -11376,11 +11446,18 @@ var PR_REVIEW_REQUIRED_FIELDS = [
|
|
|
11376
11446
|
"branch_protection_review_required",
|
|
11377
11447
|
"branchProtectionReviewRequired"
|
|
11378
11448
|
];
|
|
11449
|
+
var GITHUB_USER_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
11379
11450
|
function githubLogin(value) {
|
|
11380
11451
|
if (!value?.trim())
|
|
11381
11452
|
return;
|
|
11382
|
-
|
|
11383
|
-
|
|
11453
|
+
let login = value.trim().replace(/^@/, "");
|
|
11454
|
+
const appActor = /^app\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/.exec(login);
|
|
11455
|
+
if (appActor)
|
|
11456
|
+
login = `${appActor[1]}[bot]`;
|
|
11457
|
+
const bot = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\[bot\]$/.exec(login);
|
|
11458
|
+
if (bot)
|
|
11459
|
+
return `${bot[1]}[bot]`;
|
|
11460
|
+
return GITHUB_USER_LOGIN.test(login) ? login : undefined;
|
|
11384
11461
|
}
|
|
11385
11462
|
function githubLogins(values) {
|
|
11386
11463
|
const seen = new Set;
|
|
@@ -11439,6 +11516,27 @@ function routeTextFieldValues(record, field) {
|
|
|
11439
11516
|
}
|
|
11440
11517
|
return values;
|
|
11441
11518
|
}
|
|
11519
|
+
var PR_STATE_FIELDS = [
|
|
11520
|
+
"pr_state",
|
|
11521
|
+
"prState",
|
|
11522
|
+
"pull_request_state",
|
|
11523
|
+
"pullRequestState",
|
|
11524
|
+
"state",
|
|
11525
|
+
"pr_status",
|
|
11526
|
+
"prStatus"
|
|
11527
|
+
];
|
|
11528
|
+
var CLOSED_PR_STATES = new Set(["MERGED", "CLOSED"]);
|
|
11529
|
+
function normalizePrState(value) {
|
|
11530
|
+
const trimmed = value?.trim().toUpperCase();
|
|
11531
|
+
return trimmed ? trimmed : undefined;
|
|
11532
|
+
}
|
|
11533
|
+
function prStateFromEvidence(records, text) {
|
|
11534
|
+
const field = normalizePrState(firstRouteField(records, PR_STATE_FIELDS));
|
|
11535
|
+
if (field)
|
|
11536
|
+
return field;
|
|
11537
|
+
const match = /\b(?:pr[_\s-]?state|pull[_\s-]?request[_\s-]?state|state)\s*[:=]\s*(MERGED|CLOSED|OPEN)\b/i.exec(text);
|
|
11538
|
+
return match ? match[1].toUpperCase() : undefined;
|
|
11539
|
+
}
|
|
11442
11540
|
function prReferenceFrom(text) {
|
|
11443
11541
|
const url = /github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)/i.exec(text);
|
|
11444
11542
|
if (url)
|
|
@@ -11448,12 +11546,54 @@ function prReferenceFrom(text) {
|
|
|
11448
11546
|
return { owner: short[1], repo: short[2], number: Number(short[3]) };
|
|
11449
11547
|
return;
|
|
11450
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
|
+
}
|
|
11451
11580
|
function ghAuthorResolver(ref) {
|
|
11452
11581
|
const result = spawnSync7("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "author", "-q", ".author.login"], { encoding: "utf8", timeout: 20000 });
|
|
11453
11582
|
if (result.error || result.status !== 0)
|
|
11454
11583
|
return;
|
|
11455
11584
|
return githubLogin((result.stdout ?? "").trim());
|
|
11456
11585
|
}
|
|
11586
|
+
function ghStateResolver(ref) {
|
|
11587
|
+
const result = spawnSync7("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "state,mergeStateStatus"], { encoding: "utf8", timeout: 20000 });
|
|
11588
|
+
if (result.error || result.status !== 0)
|
|
11589
|
+
return;
|
|
11590
|
+
try {
|
|
11591
|
+
const parsed = JSON.parse(result.stdout ?? "{}");
|
|
11592
|
+
return { state: parsed.state, mergeStateStatus: parsed.mergeStateStatus };
|
|
11593
|
+
} catch {
|
|
11594
|
+
return;
|
|
11595
|
+
}
|
|
11596
|
+
}
|
|
11457
11597
|
function authorFromPrText(text) {
|
|
11458
11598
|
const patterns = [
|
|
11459
11599
|
/\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
|
|
@@ -11480,7 +11620,7 @@ function reviewersFromPrText(text) {
|
|
|
11480
11620
|
}
|
|
11481
11621
|
return githubLogins(reviewers);
|
|
11482
11622
|
}
|
|
11483
|
-
function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorResolver) {
|
|
11623
|
+
function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorResolver, resolveState = ghStateResolver) {
|
|
11484
11624
|
const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
|
|
11485
11625
|
const text = routeEvidenceText(records);
|
|
11486
11626
|
const signals = [];
|
|
@@ -11500,6 +11640,23 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
|
|
|
11500
11640
|
const required = hasPrReference && (reviewRequiredByField || reviewRequiredByText || mergeBlockedByText || approvalIntent);
|
|
11501
11641
|
if (!required)
|
|
11502
11642
|
return { required: false, allowed: true, reviewers: [], signals };
|
|
11643
|
+
const prRef = prReferenceFrom(text);
|
|
11644
|
+
let prState = prStateFromEvidence(records, text);
|
|
11645
|
+
if (!prState && prRef) {
|
|
11646
|
+
const live = resolveState(prRef);
|
|
11647
|
+
prState = normalizePrState(live?.state);
|
|
11648
|
+
}
|
|
11649
|
+
if (prState && CLOSED_PR_STATES.has(prState)) {
|
|
11650
|
+
return {
|
|
11651
|
+
required: true,
|
|
11652
|
+
allowed: false,
|
|
11653
|
+
reason: `PR is already ${prState.toLowerCase()}; skipping merge/review route (freshness gate)`,
|
|
11654
|
+
reviewers: [],
|
|
11655
|
+
signals: [...signals, "pr-not-open"],
|
|
11656
|
+
freshnessSkip: true,
|
|
11657
|
+
prState
|
|
11658
|
+
};
|
|
11659
|
+
}
|
|
11503
11660
|
const reviewers = githubLogins([
|
|
11504
11661
|
opts.githubReviewer,
|
|
11505
11662
|
...splitList(opts.githubReviewerPool) ?? [],
|
|
@@ -11508,14 +11665,11 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
|
|
|
11508
11665
|
...reviewersFromPrText(text)
|
|
11509
11666
|
]);
|
|
11510
11667
|
let author = githubLogin(firstRouteField(records, PR_AUTHOR_FIELDS)) ?? authorFromPrText(text);
|
|
11511
|
-
if (!author) {
|
|
11512
|
-
const
|
|
11513
|
-
if (
|
|
11514
|
-
|
|
11515
|
-
|
|
11516
|
-
author = derived;
|
|
11517
|
-
signals.push("author-derived-gh");
|
|
11518
|
-
}
|
|
11668
|
+
if (!author && prRef) {
|
|
11669
|
+
const derived = githubLogin(resolveAuthor(prRef));
|
|
11670
|
+
if (derived) {
|
|
11671
|
+
author = derived;
|
|
11672
|
+
signals.push("author-derived-gh");
|
|
11519
11673
|
}
|
|
11520
11674
|
}
|
|
11521
11675
|
const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
|
|
@@ -11711,6 +11865,34 @@ var UNCLEARED_ROUTE_WORK_ITEM_STATUSES = new Set([
|
|
|
11711
11865
|
function isUnclearedRouteWorkItem(item) {
|
|
11712
11866
|
return UNCLEARED_ROUTE_WORK_ITEM_STATUSES.has(item.status);
|
|
11713
11867
|
}
|
|
11868
|
+
var REACTIVATABLE_TERMINAL_STATUSES = new Set([
|
|
11869
|
+
"succeeded",
|
|
11870
|
+
"failed",
|
|
11871
|
+
"dead_letter",
|
|
11872
|
+
"cancelled"
|
|
11873
|
+
]);
|
|
11874
|
+
var MAX_TODOS_TASK_ROUTE_REDISPATCHES = 8;
|
|
11875
|
+
function todosTaskRouteRedispatchBackoffMs(attempts) {
|
|
11876
|
+
const base = 2 * 60000;
|
|
11877
|
+
const cap = 30 * 60000;
|
|
11878
|
+
const exp = Math.max(0, Math.min(attempts - 1, 10));
|
|
11879
|
+
return Math.min(cap, base * 2 ** exp);
|
|
11880
|
+
}
|
|
11881
|
+
function reactivateStaleTodosTaskWorkItem(store, routeKey, item, now = Date.now()) {
|
|
11882
|
+
if (routeKey !== "todos-task")
|
|
11883
|
+
return;
|
|
11884
|
+
if (!REACTIVATABLE_TERMINAL_STATUSES.has(item.status))
|
|
11885
|
+
return;
|
|
11886
|
+
if (item.attempts >= MAX_TODOS_TASK_ROUTE_REDISPATCHES)
|
|
11887
|
+
return;
|
|
11888
|
+
const finishedAt = Date.parse(item.updatedAt);
|
|
11889
|
+
if (Number.isFinite(finishedAt) && now - finishedAt < todosTaskRouteRedispatchBackoffMs(item.attempts)) {
|
|
11890
|
+
return;
|
|
11891
|
+
}
|
|
11892
|
+
return store.requeueWorkflowWorkItem(item.id, {
|
|
11893
|
+
reason: `re-admitted from ${item.status}: todos task still actionable after prior run (attempt ${item.attempts + 1}/${MAX_TODOS_TASK_ROUTE_REDISPATCHES})`
|
|
11894
|
+
});
|
|
11895
|
+
}
|
|
11714
11896
|
function todosTaskRouteTemplateId(opts) {
|
|
11715
11897
|
const id = (opts.template ?? TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).trim();
|
|
11716
11898
|
if (!TODOS_TASK_ROUTE_TEMPLATE_IDS.has(id)) {
|
|
@@ -11803,9 +11985,11 @@ function routeEvent(plan) {
|
|
|
11803
11985
|
const invocation = store.createWorkflowInvocation(plan.invocationInput);
|
|
11804
11986
|
const existingItem = store.findWorkflowWorkItem(plan.routeKey, idempotencyKey);
|
|
11805
11987
|
if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
|
|
11806
|
-
|
|
11807
|
-
|
|
11808
|
-
|
|
11988
|
+
if (!reactivateStaleTodosTaskWorkItem(store, plan.routeKey, existingItem)) {
|
|
11989
|
+
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
11990
|
+
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
11991
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
|
|
11992
|
+
}
|
|
11809
11993
|
}
|
|
11810
11994
|
const throttle = hasThrottleLimits(plan.throttleLimits) ? routeThrottleDecision(store, { projectPath: plan.routeProjectPath, projectGroup: plan.projectGroup, limits: plan.throttleLimits }) : undefined;
|
|
11811
11995
|
const workItem = store.upsertWorkflowWorkItem({
|
|
@@ -11927,7 +12111,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11927
12111
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
11928
12112
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
11929
12113
|
const sourceProjectIdempotencyPrefix = sourceTodosProjectPath ? normalizeRoutePath(sourceTodosProjectPath) ?? resolve7(sourceTodosProjectPath) : undefined;
|
|
11930
|
-
const
|
|
12114
|
+
const prFingerprint2 = prFingerprintFromTask(data, metadata);
|
|
12115
|
+
const idempotencyKey = prFingerprint2 ? `todos-task:pr:${prFingerprint2}` : sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
|
|
11931
12116
|
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
11932
12117
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
11933
12118
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
@@ -11937,10 +12122,12 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11937
12122
|
try {
|
|
11938
12123
|
const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
11939
12124
|
if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
12125
|
+
if (!reactivateStaleTodosTaskWorkItem(store, "todos-task", existingItem)) {
|
|
12126
|
+
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
12127
|
+
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
12128
|
+
const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
|
|
12129
|
+
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
|
|
12130
|
+
}
|
|
11944
12131
|
}
|
|
11945
12132
|
} finally {
|
|
11946
12133
|
store.close();
|
|
@@ -11957,7 +12144,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11957
12144
|
event,
|
|
11958
12145
|
taskId,
|
|
11959
12146
|
routeError: true,
|
|
11960
|
-
prReviewRouting
|
|
12147
|
+
prReviewRouting,
|
|
12148
|
+
...prReviewRouting.freshnessSkip ? { freshnessSkip: true, prState: prReviewRouting.prState } : {}
|
|
11961
12149
|
},
|
|
11962
12150
|
human: `skipped task ${taskId}: ${prReviewRouting.reason}`
|
|
11963
12151
|
};
|
|
@@ -12518,6 +12706,31 @@ function markInvalidDrainTaskNonRouteable(sourceTodosProject, task, reason) {
|
|
|
12518
12706
|
untagAutoRoute: todosMutationSummary(untagResult)
|
|
12519
12707
|
};
|
|
12520
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
|
+
}
|
|
12521
12734
|
function drainTodosTaskRoutes(opts) {
|
|
12522
12735
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
12523
12736
|
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
@@ -12559,6 +12772,12 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12559
12772
|
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { fatal: true });
|
|
12560
12773
|
}
|
|
12561
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
|
+
}
|
|
12562
12781
|
results.push(result);
|
|
12563
12782
|
if (result.kind === "created")
|
|
12564
12783
|
created += 1;
|
|
@@ -12584,6 +12803,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12584
12803
|
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
12585
12804
|
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
12586
12805
|
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
12806
|
+
freshnessClosed: results.filter((result) => isFreshnessSkip(result) && result.value.sourceTaskUpdate?.attempted === true).length,
|
|
12587
12807
|
fatal: results.filter((result) => result.value.fatal === true).length,
|
|
12588
12808
|
maxDispatch,
|
|
12589
12809
|
source: "todos ready",
|
|
@@ -12612,6 +12832,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12612
12832
|
deduped: report.deduped,
|
|
12613
12833
|
throttled: report.throttled,
|
|
12614
12834
|
skipped: report.skipped,
|
|
12835
|
+
freshnessClosed: report.freshnessClosed,
|
|
12615
12836
|
fatal: report.fatal,
|
|
12616
12837
|
maxDispatch: report.maxDispatch,
|
|
12617
12838
|
source: report.source,
|
|
@@ -12621,7 +12842,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12621
12842
|
} : { ...report, evidencePath };
|
|
12622
12843
|
return {
|
|
12623
12844
|
value,
|
|
12624
|
-
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}`
|
|
12625
12846
|
};
|
|
12626
12847
|
}
|
|
12627
12848
|
// src/lib/route/route-tasks.ts
|