Skip to content

refactor(agent): route all SSE streams through a shared seam with a required heartbeat - #368

Merged
jaredglaser merged 4 commits into
mainfrom
claude/agent-sse-heartbeats
Jul 25, 2026
Merged

refactor(agent): route all SSE streams through a shared seam with a required heartbeat#368
jaredglaser merged 4 commits into
mainfrom
claude/agent-sse-heartbeats

Conversation

@jaredglaser

@jaredglaser jaredglaser commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Two of the agent's four SSE routes had no heartbeat. Rather than adding one to each, this ports the web app's createSseStream seam into the agent package and converts all four routes onto it, so the heartbeat is armed by stream construction and there is no call left to forget.

Replaces #277, which was closed as overtaken: src/lib/sse/create-sse-stream.ts already owns the heartbeat on the web side, and both web factories delegate to it. Only the agent half was genuinely outstanding.

The seam

agent/src/lib/sse-stream.ts is a port of src/lib/sse/create-sse-stream.ts. createSseStream(request, { onStart, heartbeatMs = 5000 }) returns the finished Response. The heartbeat is armed unconditionally inside start() right after the ': ok\n\n' flush. onStart(emit, signal) receives only an SseEmitter (data / event / raw / close) and may return a cleanup that runs exactly once at teardown. One teardown clears the interval, runs cleanup and closes the controller; every write goes through the deferred-build write() which tears down on failure; abort is registered before onStart with an aborted recheck after it resolves.

It is a deliberate copy, not an import. The agent is not a workspace member and web/worker take only types from it via @homelab-manager/agent/* with no runtime dependency, so it cannot import web code. The duplication is what the package split forces.

The web seam's test file was ported verbatim (import path only) to agent/src/lib/__tests__/sse-stream.test.ts; all 18 pass unmodified, which is the evidence that the port is faithful.

Routes converted

  • containers-events.ts and logs.ts lose their hand-rolled ReadableStream, closed flags, abort handlers and inline heartbeat blocks.
  • stats.ts and zfs.ts gain a heartbeat as a consequence of the conversion. stats.ts previously had none at all, so a host with zero running containers produced a silent stream.

Removed in total: 2 inline heartbeat implementations, 4 hand-rolled ReadableStream teardown paths and 2 local sendSSE helpers. agent/src/lib/sse-utils.ts was deleted; no consumer remained.

Why zfs.ts needed one after all

The earlier reasoning was that zpool iostat -v 1 emits every second so the stream cannot idle. That is true of the healthy case and insufficient. The route has no timeout, no tick of its own and no output of its own: every frame originates from a line read off zpool's stdout, and reader.read() blocks identically whether zpool is quiet or wedged. A degraded pool with a hung disk stalls output, Bun's 10s default idleTimeout then drops the socket, and that is exactly when the dashboard should stay connected.

Lifecycle subtlety worth reviewing

stats.ts and zfs.ts own long-running loops. If onStart awaited the loop, the returned cleanup would never be registered and a mid-loop teardown would never kill the subprocess or stop the poll. Both start the loop with void and return cleanup synchronously; the loop observes a stopped flag the cleanup flips.

Constraints

5000ms, not 25000. Bun's default HTTP idleTimeout is 10s, which containers-events.ts already documented and is why the existing routes chose 5s. Frames use the bare ':\n\n' comment, matching both existing implementations.

Consumers verified, not assumed

All three streaming worker collectors go through connectAgentSseStream, whose readFrames does const dataLine = lines.find((line) => line.startsWith('data: ')); if (!dataLine) continue; so comment frames are skipped before any JSON parse. src/routes/api/docker-logs.$containerId.ts forwards agent chunks verbatim through emit.raw, so pings pass through and it needs no second heartbeat. Browser EventSource ignores : comments per spec.

Testing

  • bun run typecheck:all clean.
  • Agent tests 324 to 337, zero failures. Each converted route has an idle-heartbeat test asserting both the 5000ms cadence and a real ':\n\n' frame on the wire, driven by a globalThis.setInterval spy per CLAUDE.md rule 7.
  • bun run test:coverage:agent passes; sse-stream.ts, containers-events.ts, logs.ts and zfs.ts all at 100% lines.
  • Web src/lib/sse/__tests__/ and src/worker/collectors/__tests__/: 134 pass. No web source files changed.

containers-events is one test lower because its two inline heartbeat tests collapsed into one idle-heartbeat test; the clears-on-abort case is now covered at the seam.

Note for the ESLint PR

This PR removes 22 of the 23 findings the proposed SSE lint rules produce. If this merges first, those five no-restricted-syntax selectors can land as error with one remaining fix in src/routes/api/settings.ts:17, rather than as warn.


Generated by Claude Code

Summary by CodeRabbit

  • Improvements
    • Standardized Server-Sent Events (SSE) streaming across container events, logs, statistics, and ZFS with consistent : ok startup and regular 5s heartbeat comment frames.
    • Improved client disconnect and mid-stream abort handling to reliably stop ongoing background streaming work.
    • Strengthened SSE parsing and event framing to emit the same JSON payload shapes while improving “not found” and error signaling.
  • Documentation
    • Updated streaming documentation to describe the SSE response/header format and the required route teardown/cleanup contract.

The agent had three different SSE implementations: containers-events and
logs each hand-rolled a ReadableStream with their own closed flag, teardown
path and copy of the 5s heartbeat interval, while stats and zfs had no
heartbeat at all. A Docker host with zero running containers therefore
produced a completely silent stats stream, and Bun's default HTTP
idleTimeout of 10s dropped the socket, putting the worker into a reconnect
loop. zfs has the same exposure: `zpool iostat -v 1` normally ticks every
second, but a degraded pool with a hung disk can stall output for long
stretches, which is exactly when the dashboard should not be reconnecting.

Port the web app's createSseStream seam into agent/src/lib/sse-stream.ts and
build all four routes on it. The heartbeat is armed unconditionally inside
stream construction, so there is no call for a future route to forget. Frames
go out through an SseEmitter (data/event/raw/close) rather than hand-written
`data: ` strings, one teardown owns the closed flag, and abort is registered
before onStart with a recheck after it resolves so a subscription cannot leak
past a disconnect.

This duplicates src/lib/sse/create-sse-stream.ts. The agent is deliberately
not a workspace member and web/worker import only types from it, so it cannot
import web code; the seam has to exist once on each side of that split.

Routes that own a subprocess or subscription (stats, zfs) now return their
cleanup from onStart before starting their read/poll loop, so a teardown
mid-loop kills the subprocess and stops the loop instead of leaving it
running against a dead consumer.

Heartbeat stays at 5s and keeps the bare `:\n\n` comment frame. Consumers are
unaffected: the worker's shared SSE reader skips messages with no `data: `
line, the docker-logs route pipes agent bytes through verbatim, and browser
EventSource ignores comment frames natively.

sse-utils.ts had no remaining consumer after the conversion and is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6a1cc0ad-20e4-40a4-85b3-3c69fe70a5f7

📥 Commits

Reviewing files that changed from the base of the PR and between 3a4115f and e337cb3.

📒 Files selected for processing (2)
  • agent/src/lib/test/sse-test-utils.ts
  • agent/src/routes/zfs.ts
💤 Files with no reviewable changes (1)
  • agent/src/routes/zfs.ts

📝 Walkthrough

Walkthrough

Agent SSE routes now use shared createSseStream handling for headers, framing, heartbeats, aborts, and cleanup. Stats, logs, container events, and ZFS routes plus their tests were updated.

Changes

Agent SSE streaming

Layer / File(s) Summary
Shared SSE lifecycle and protocol
agent/src/lib/sse-stream.ts, agent/src/lib/__tests__/sse-stream.test.ts, CLAUDE.md
Defines shared SSE framing, headers, initial flush, 5-second heartbeats, abort teardown, cleanup handling, and close-error classification.
Stats and logs route migration
agent/src/routes/stats.ts, agent/src/routes/logs.ts
Routes emit through createSseStream and register cleanup for polling, container streams, and Docker logs.
Container events and ZFS migration
agent/src/routes/containers-events.ts, agent/src/routes/zfs.ts
Container subscriptions and ZFS subprocess output use shared SSE emission and teardown-aware processing.
Route validation and test utilities
agent/src/lib/test/sse-test-utils.ts, agent/src/__tests__/*
Adds shared SSE readers and parsers and tests frame parsing, heartbeats, abort teardown, stderr handling, and stream errors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • jaredglaser/homelab-manager issue 233 — Covers periodic SSE heartbeat comments and stream teardown in the same routes.

Possibly related PRs

Poem

A rabbit hops through streams of light,
: ok begins the flow just right.
Heartbeats bloom every five,
Cleanup keeps the stream alive.
Logs and events dance through the night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: all agent SSE streams now use a shared seam with a required heartbeat.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/agent-sse-heartbeats

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agent/src/__tests__/containers-events.test.ts`:
- Around line 54-64: Move parseDataFrames from containers-events.test.ts into
the shared test-utility module under src/lib/test/, then import and reuse it in
both containers-events.test.ts and zfs.test.ts. Also consolidate the duplicated
readUntil helper there and update all agent SSE tests to use the shared
implementation, removing their local copies while preserving behavior.
- Around line 651-669: Update the test “a quiet host still gets a comment
heartbeat on the 5s cadence” to capture the original globalThis.setInterval
before mocking it, then wrap the test assertions and response handling in
try/finally and restore the captured function in finally, matching the cleanup
pattern used by the equivalent stats and zfs tests.

In `@agent/src/lib/sse-stream.ts`:
- Around line 67-79: Update the write function so build() serialization errors
are caught separately, logged as unexpected payload failures, and return without
calling teardown; keep controller.enqueue errors on the existing error-handling
and teardown path.

In `@agent/src/routes/zfs.ts`:
- Around line 57-60: Update the subprocess options in the zpool iostat spawn
flow to prevent an undrained stderr pipe: set stderr to ignore, or explicitly
drain proc.stderr if ZFS diagnostics must be exposed. Keep stdout piped for SSE
frame processing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 93d5c45e-bfe6-41ef-b2f3-bf67b6d061ab

📥 Commits

Reviewing files that changed from the base of the PR and between 1e9034a and 54e6d51.

📒 Files selected for processing (13)
  • CLAUDE.md
  • agent/src/__tests__/containers-events.test.ts
  • agent/src/__tests__/logs.test.ts
  • agent/src/__tests__/stats.test.ts
  • agent/src/__tests__/zfs.test.ts
  • agent/src/lib/__tests__/sse-stream.test.ts
  • agent/src/lib/__tests__/sse-utils.test.ts
  • agent/src/lib/sse-stream.ts
  • agent/src/lib/sse-utils.ts
  • agent/src/routes/containers-events.ts
  • agent/src/routes/logs.ts
  • agent/src/routes/stats.ts
  • agent/src/routes/zfs.ts
💤 Files with no reviewable changes (2)
  • agent/src/lib/tests/sse-utils.test.ts
  • agent/src/lib/sse-utils.ts

Comment thread agent/src/__tests__/containers-events.test.ts Outdated
Comment thread agent/src/__tests__/containers-events.test.ts
Comment thread agent/src/lib/sse-stream.ts
Comment thread agent/src/routes/zfs.ts
claude added 2 commits July 25, 2026 01:01
`zpool iostat -v 1` runs for the whole SSE session with `stderr: 'pipe'`
and nothing ever reads it. Sustained stderr fills the OS pipe buffer and
blocks the child, stalling stdout frames while the new heartbeat keeps
the socket alive, which hides exactly the wedged-pool case the heartbeat
was added to surface. Switch that spawn to `stderr: 'ignore'`; the frame
protocol carries only `{ line, timestamp }`, so there is no channel for
ZFS diagnostics and nothing to drain. The short-lived `zpool list -Hp`
spawn keeps its pipe, since it reads stderr on non-zero exit.

Move `readUntil` and `parseDataFrames` into agent/src/lib/test/, mirroring
the web package's src/lib/test/ convention, and drop the three local
copies. The consolidated `readUntil` is the containers-events variant,
which clears its race timer instead of leaking one per read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The streaming `zpool iostat -v 1` subprocess piped stderr and never read
it, so a chatty pool fills the OS pipe buffer and blocks zpool's stdout.
The previous commit fixed the blocking with `stderr: 'ignore'`, which
also threw the diagnostics away. Read the pipe instead and log each line,
matching the short-lived `zpool list -Hp` spawn in the same file, which
already surfaces stderr on failure.

The stdout pump and the stderr drain share a `readLines` helper rather
than repeating the chunk-buffering loop; both observe the same `stopped`
flag the SSE cleanup flips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
agent/src/__tests__/containers-events.test.ts (1)

424-445: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Synchronize the subscription race without sleeping.

Line 443 can race on slow CI. Resolve a promise inside the listContainers mock, then await that promise before aborting.

Proposed fix
+    let markSubscribeStarted!: () => void;
+    const subscribeStarted = new Promise<void>((resolve) => {
+      markSubscribeStarted = resolve;
+    });
     const listHolder: { resolve: ((v: unknown[]) => void) | null } = { resolve: null };
     const { stream, destroyed } = makeDestroyableEventsStream();
     const docker = {
       listContainers: mock(() => new Promise<unknown[]>((resolve) => {
         listHolder.resolve = resolve;
+        markSubscribeStarted();
       })),
@@
-    await new Promise((r) => setTimeout(r, 10));
+    await subscribeStarted;
     ac.abort();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/__tests__/containers-events.test.ts` around lines 424 - 445,
Replace the timing-based setTimeout wait in the
abort-during-broadcasterSubscribe test with an explicit synchronization promise
resolved by the listContainers mock. Await that promise to confirm subscription
is blocked in listContainers before calling ac.abort(), then resolve listHolder
so the late subscriber teardown path remains tested deterministically.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agent/src/__tests__/containers-events.test.ts`:
- Line 6: Replace the relative sse-test-utils imports with the
`@/lib/test/sse-test-utils` alias in agent/src/__tests__/containers-events.test.ts
lines 6-6, agent/src/__tests__/stats.test.ts lines 4-4, and
agent/src/__tests__/zfs.test.ts lines 4-4; no other changes are needed.

In `@agent/src/lib/test/sse-test-utils.ts`:
- Around line 28-30: Update the finally path in the SSE test helper to await
reader.cancel() and then release the reader lock, ensuring asynchronous teardown
completes and any errors are propagated through the helper’s existing async
flow.
- Around line 11-20: Update readUntil to treat timeoutMs as a total deadline by
creating the timeout promise once before the while loop or calculating remaining
time from a fixed deadline. Remove per-iteration timer recreation and ensure
timer cleanup remains correct while preserving the existing predicate and
reader.read handling.

In `@agent/src/routes/zfs.ts`:
- Line 28: Remove the redundant JSDoc immediately above pumpZpoolOutput, while
retaining the separate comment documenting the lifecycle constraint below.

---

Outside diff comments:
In `@agent/src/__tests__/containers-events.test.ts`:
- Around line 424-445: Replace the timing-based setTimeout wait in the
abort-during-broadcasterSubscribe test with an explicit synchronization promise
resolved by the listContainers mock. Await that promise to confirm subscription
is blocked in listContainers before calling ac.abort(), then resolve listHolder
so the late subscriber teardown path remains tested deterministically.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: de41bf06-20fa-4a10-8164-1a87e61f5b5d

📥 Commits

Reviewing files that changed from the base of the PR and between 54e6d51 and 3a4115f.

📒 Files selected for processing (5)
  • agent/src/__tests__/containers-events.test.ts
  • agent/src/__tests__/stats.test.ts
  • agent/src/__tests__/zfs.test.ts
  • agent/src/lib/test/sse-test-utils.ts
  • agent/src/routes/zfs.ts

Comment thread agent/src/__tests__/containers-events.test.ts
Comment thread agent/src/lib/test/sse-test-utils.ts Outdated
Comment on lines +28 to +30
} finally {
reader.cancel();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)sse-test-utils\.ts$' || true

echo "== file excerpt =="
file="$(git ls-files | rg '(^|/)sse-test-utils\.ts$' | head -n1 || true)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  cat -n "$file"
fi

echo "== stream reader cancel/releaseLock usage in codebase =="
rg -n '\b(reader|readable|ReadableStream)\.(cancel|releaseLock)\(' -g '*.ts' -g '*.tsx' . || true

echo "== runtime semantics probe if Node is available =="
node - <<'JS'
console.log("node", process.version);
const { TransformStream } = require("stream/web");

let reader;
let cancelled = false;
let released = false;
let cancelCallCount = 0;
let releaseCallCount = 0;

const ts = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk);
    controller.terminate();
  },
});
reader = ts.readable.getReader();
reader.closed
  .catch(() => {})
  .then(() => {})
  .then(async () => {
    console.log("readUntil resolved before cancel await?", { cancelled, released, cancelCallCount, releaseCallCount });
  });
const promise = reader.cancel();
console.log("cancel returns promise?", promise instanceof Promise);
promise.then(
  (value) => {
    cancelled = true;
    reader.releaseLock();
    released = true;
    cancelCallCount = 1;
  },
  (err) => {
    console.log("cancel rejected:", String(err));
    cancelled = true;
    reader.releaseLock();
    released = true;
    cancelCallCount = 1;
  }
);
releaseCallCount = 1;
reader.releaseLock();
console.log("final cancelled released cancel release calls?", { cancelled, released, cancelCallCount, releaseCallCount });
JS

Repository: jaredglaser/homelab-manager

Length of output: 4638


🌐 Web query:

MDN ReadableStreamDefaultReader cancel promise releaseLock

💡 Result:

In the context of the Streams API, both ReadableStreamDefaultReader.cancel() and ReadableStreamDefaultReader.releaseLock() are methods used to manage the lifecycle and ownership of a stream, but they serve distinct purposes [1][2][3]. ReadableStreamDefaultReader.cancel The cancel method is used to signal that the consumer is no longer interested in the stream [1][2]. Calling it performs a terminal action: it cancels the stream entirely, meaning no further data can be read, even if chunks remain in the internal queue [2][4]. It returns a promise that resolves when the stream is successfully canceled [2]. Because cancellation is terminal, the stream cannot be reused afterward [4]. ReadableStreamDefaultReader.releaseLock The releaseLock method releases the lock the reader holds on the stream, allowing the stream to be read by a different reader in the future [3][5]. Importantly, releasing the lock does not cancel the stream [3]. If there are any pending read requests when releaseLock is called, those requests are immediately rejected with a TypeError [3][6]. The stream remains open, and any unread chunks stay in the internal queue [3]. Key Differences and Interaction 1. Terminal vs. Non-Terminal: cancel is a terminal operation that ends the stream [4]. releaseLock is a non-terminal operation that simply relinquishes control [3]. 2. Order of Operations: You cannot call any reader methods (including cancel) after calling releaseLock, as the reader no longer has access to the stream [4]. Therefore, if you intend to both cancel and release the lock, you must call cancel first [4]. 3. Pending Reads: While both methods can affect pending reads, they do so differently: cancel resolves them (signaling the stream is done), whereas releaseLock rejects them with a TypeError [3][4][6]. In practice, if you have a reader and wish to stop consuming a stream, you should use reader.cancel [2][4]. If you simply need to transfer control of the stream to another part of your application, you should use reader.releaseLock [3][5].

Citations:


Await stream cancellation, then release the reader lock.

reader.cancel() returns a promise and reader.releaseLock() only relinquishes ownership. In the timeout/canonical exit path, this helper can resolve before asynchronous teardown completes, and any cancellation errors/release errors can become unhandled. Await cancellation and release the lock from the finally path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/lib/test/sse-test-utils.ts` around lines 28 - 30, Update the
finally path in the SSE test helper to await reader.cancel() and then release
the reader lock, ensuring asynchronous teardown completes and any errors are
propagated through the helper’s existing async flow.

Comment thread agent/src/routes/zfs.ts Outdated
The timer was rebuilt each loop iteration, so every chunk reset it. On a
stream that keeps emitting (a 5s heartbeat is enough) a predicate that
never matches would never trip the timeout and the helper would hang
until bun's own per-test limit, reporting a generic timeout instead of
the helper's message. Build the deadline once outside the loop.

Also await reader.cancel() so a rejection is not unhandled. It rejects
when the stream has already errored, which the reader-throws tests
induce deliberately, so the await is guarded.

Drops the pumpZpoolOutput JSDoc, which restated a body that is now four
lines of readLines call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jaredglaser
jaredglaser merged commit 60ab7d6 into main Jul 25, 2026
12 checks passed
@jaredglaser
jaredglaser deleted the claude/agent-sse-heartbeats branch July 25, 2026 03:35
jaredglaser added a commit that referenced this pull request Jul 25, 2026
…374)

## Why this touches .gitignore

`.gitignore` excluded everything under `.claude/` except `scripts/` and
`settings.json`, so skills could not be committed at all, and none were.
Cloud sessions start from a fresh clone and see only what is tracked, so
a skill that lives on one machine is a skill the cloud agents do not
have. This adds `!/.claude/skills/`.

## The skill

`.claude/skills/pr-queue/SKILL.md` covers orchestrating the open PR
queue, written from what this repo actually does rather than generic
advice.

**Where state lives.** Not in the repo. A per-session GitHub issue holds
queue-level state (PR table, decisions waiting, parked questions) and
each PR carries one status comment edited in place. That way an
orchestrator's context can be compacted without losing track of what is
in flight, what was already declined and why, and what is waiting on a
human.

**Session tagging.** Several orchestrator sessions can run at once, so
each owns a tag derived from its session id and stamps it on every
artifact it creates. One search resolves a whole session's work.

**Triage rules.** Verify every CodeRabbit finding against current code
before acting, and reply with evidence on every decline. Recorded
because the false-positive rate is real: of 8 findings on #368, 3 were
wrong or out of scope, and one that was correct came with a suggested
diff that would have disabled the timeout it was fixing.

**Traps that have cost time here.** Agent worktrees branch from `main`
rather than the feature branch. `bun run setup` drifts the lockfile
under bun 1.3.x. `get_status` shows only CodeRabbit and has reported
green over two failing jobs that `get_check_runs` showed. Migrations
collide silently because the filenames differ.

## Scope

Documentation and one `.gitignore` line. No source, build, or CI
changes.

`pr-queue session pq-db6c36c3`

---
_Generated by [Claude
Code](https://claude.ai/code/session_01E72xKQhMceYgcw4WrtjZau)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants