@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 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.9",
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(homedir(), ".hasna", "loops");
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(homedir(), ".config", "systemd", "user", "loops-daemon.service");
81
+ return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
78
82
  }
79
83
  function launchdPlistPath() {
80
- return join(homedir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
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 : scrubbedOrNull(progress.stdout),
3224
- $stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
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: scrubbedOrNull(patch.stdout),
3286
- $stderr: scrubbedOrNull(patch.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: scrubbedOrNull(patch.stdout),
3665
- $stderr: scrubbedOrNull(patch.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: scrubbedOrNull(run.stdout),
4063
- $stderr: scrubbedOrNull(run.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.9",
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
- lines.push(`__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 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
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((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
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 concurrencyFromEnv() {
7615
- const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
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 concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
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 task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => activeRuns.delete(claim.run.id));
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 available = Math.max(0, concurrency - activeRuns.size);
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: available
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
- const login = value.trim().replace(/^@/, "");
11383
- return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) ? login : undefined;
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 ref = prReferenceFrom(text);
11513
- if (ref) {
11514
- const derived = githubLogin(resolveAuthor(ref));
11515
- if (derived) {
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
- const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11807
- const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11808
- return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
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 idempotencyKey = sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
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
- const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11941
- const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11942
- const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
11943
- return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
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