Skip to content

refactor(supervisor): fix monitor exit path race + rewrite architecture docs - #620

Draft
gaojunran wants to merge 20 commits into
jdx:mainfrom
gaojunran:eager-gopher
Draft

refactor(supervisor): fix monitor exit path race + rewrite architecture docs#620
gaojunran wants to merge 20 commits into
jdx:mainfrom
gaojunran:eager-gopher

Conversation

@gaojunran

@gaojunran gaojunran commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes a concurrency bug in the monitor task exit path, rewrites the architecture documentation to reflect the current codebase, and enables mermaid diagram rendering in the VitePress docs.

Changes

1. Monitor exit path race condition fix (src/supervisor/lifecycle.rs)

Three issues identified and fixed in the monitor task exit path:

  • MonitorGuard placed after drain (real bug): close() could observe active_monitors == 0 during the 5s output drain window and call exit(0) before hooks were registered. Moved MonitorGuard acquisition before the drain.

  • Drain blocking critical path: The 5s synchronous drain was on the critical path, blocking close() and start(). Moved it to a fire-and-forget background task (spawn_output_drain()) so the 5s window no longer blocks supervisor shutdown or daemon restart.

  • Unclear naming: Renamed pre_drain_is_stopping to stop_intent_observed, split into was_stopped_before_cleanup / was_stopping_before_cleanup for clarity about what the snapshot represents.

2. Architecture documentation rewrite (docs/concepts/architecture.md)

The existing architecture doc was outdated. Rewrote it with the full technical overview:

  • Module layout (src/supervisor/ submodules)
  • Supervisor startup sequence and background task graph
  • Daemon states and state persistence (qualified keys, file locking)
  • Process spawning (run_once(), privilege switching, PTY allocation)
  • Readiness detection (biased tokio::select! loop, first-ready-wins, timeout exhaustion)
  • Port management (auto-bump, active port detection)
  • Process termination (stop(), close())
  • Monitor task exit path (reflects the refactored code)
  • Hooks, logging, proxy integration, resource limits, IPC responses

Added ARCHITECTURE.md symlink at the repo root pointing to the VitePress source, and referenced it prominently in AGENTS.md.

3. Mermaid diagram rendering (docs/)

Enabled mermaid diagrams in VitePress via vitepress-plugin-mermaid + mermaid 11.16.0.

Fixed text clipping caused by mermaid 11.15+ bug #7759 (themeCSS lowercases foreignObject selector, breaking XML case-sensitive CSS matching) with three layers:

  • flowchart.wrappingWidth: 1000 in mermaid config (default 200 clips long node text)
  • CSS targeting both foreignObject and foreignobject spellings
  • JS post-processing via MutationObserver to set overflow="visible" on asynchronously rendered nodes

Verified in headless Chromium: all 17 visible foreignObject nodes have overflow="visible" with no clipping, on both desktop (1440px) and mobile (390px) viewports.

4. Test tooling note (mise.toml)

Added a comment above the test task documenting that local developers should install GNU parallel to enable bats --jobs 16 parallel execution.

Verification

  • cargo nextest run: 476/476 passed
  • mise run test:bats: 279/279 passed (8 slow tests skipped)
  • cargo clippy --all-targets --all-features -- -D warnings: clean
  • mise run build:docs: builds successfully
  • Mermaid rendering verified via Playwright DOM inspection + screenshots

This PR body was generated by Claude Code.

Summary by CodeRabbit

  • Documentation

    • Expanded the architecture documentation with added diagrams and clearer lifecycle/state/readiness/shutdown details.
    • Enabled Mermaid rendering on the docs site and adjusted styling to improve diagram layout and prevent clipping.
    • Updated architecture guidance to point to the centralized architecture document.
  • Bug Fixes

    • Improved daemon log capture by draining/persisting trailing output more reliably during stop/restart and monitor shutdown edge cases.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds Mermaid rendering support to the documentation site, expands architecture documentation, redirects the architecture entry point, and revises supervisor monitor shutdown and daemon exit cleanup.

Changes

Documentation rendering and architecture

Layer / File(s) Summary
Mermaid documentation rendering
docs/.npmrc, docs/package.json, docs/pnpm-workspace.yaml, docs/.vitepress/*, docs/aube-lock.yaml
Adds Mermaid tooling, VitePress integration, clipping fixes, build configuration, and regenerated lockfile data.
Architecture documentation and references
AGENTS.md, ARCHITECTURE.md, docs/concepts/architecture.md, mise.toml
Routes the architecture reference through the documentation file, expands runtime architecture details, and documents GNU parallel test execution behavior.

Daemon lifecycle

Layer / File(s) Summary
Lifecycle exit handling and output draining
src/supervisor/lifecycle.rs
Adds monitor guards and bounded output draining while updating PID checks, stop-intent handling, state transitions, hooks, and test scaffolding.
Supervisor monitor shutdown coordination
src/supervisor/mod.rs
Documents monitor accounting and updates shutdown waiting and timeout handling.

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

Possibly related PRs

  • jdx/pitchfork#602: Related supervisor readiness, exit-path, and shutdown coordination changes.
  • jdx/pitchfork#657: Related daemon termination and status classification behavior.
  • jdx/pitchfork#661: Related daemon output capture and lifecycle logging changes.

Suggested reviewers: jdx

Poem

A rabbit watched the daemons run,
Mermaid diagrams caught the sun.
Logs hopped safely into place,
Monitors tidied every trace,
Architecture grew with grace.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% 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 two main changes: a supervisor race fix and an architecture-doc rewrite.
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.

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.

@socket-security

socket-security Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​vitepress-plugin-mermaid@​2.0.171001008280100
Addednpm/​mermaid@​11.16.09810010092100

View full report

@socket-security

socket-security Bot commented Jul 18, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm robust-predicates is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/mermaid@11.16.0npm/vitepress-plugin-mermaid@2.0.17npm/robust-predicates@3.0.3

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/robust-predicates@3.0.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@gaojunran
gaojunran marked this pull request as draft July 18, 2026 15:56
@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real concurrency bug in the monitor task exit path (MonitorGuard acquisition moved before the output drain so close() cannot observe active_monitors == 0 prematurely), rewrites the architecture documentation with mermaid diagrams, and wires up vitepress-plugin-mermaid including workarounds for the mermaid 11.15+ foreignObject CSS case-sensitivity bug.

  • Monitor race fix: MonitorGuard is now incremented and constructed at the very top of each monitor tokio::spawn task, before any lifecycle work, ensuring close() reliably waits for every exit-path branch (including drain and hook registration) before calling exit(0).
  • Docs rewrite: docs/concepts/architecture.md is a full rewrite covering module layout, startup sequence, daemon states, readiness detection, port management, termination flow, and IPC; an ARCHITECTURE.md symlink at the repo root points to it.
  • Mermaid setup: vitepress-plugin-mermaid + mermaid 11.16.0 added; text-clipping fixed via CSS targeting both foreignObject/foreignobject spellings and a MutationObserver post-processing pass.

Confidence Score: 5/5

Safe to merge. The MonitorGuard placement fix is correct and the drain-ordering logic works as intended; no functional regressions were found.

The core concurrency fix — moving MonitorGuard acquisition before the drain — is implemented correctly and matches the stated intent. The only finding is a stale block comment in the exit-path header that describes a background-drain architecture (step 5) and a flush-only step 2, neither of which matches the actual inline drain. The code behaviour itself is correct; the comment is misleading to future readers but does not affect runtime.

Files Needing Attention: The exit-path header comment in src/supervisor/lifecycle.rs (lines 1495–1509) should be updated to describe the actual inline-drain algorithm instead of the background-task approach it currently documents.

Important Files Changed

Filename Overview
src/supervisor/lifecycle.rs MonitorGuard correctly moved to task top (race fix). Exit-path header comment (lines 1495-1509) describes a background-drain algorithm that was not implemented — drain still runs inline; comment is misleading.
docs/.vitepress/config.mts Adds withMermaid wrapper and mermaid flowchart config; securityLevel and shamefully-hoist concerns already discussed in previous threads.
docs/.vitepress/theme/index.ts Adds MutationObserver-based fixMermaidClipping to set overflow=visible on foreignObject nodes; observer is properly disconnected in onUnmounted.
docs/.vitepress/theme/custom.css Adds foreignObject/foreignobject overflow:visible rules to fix mermaid 11.15+ CSS case-sensitivity bug; stylelint disable comment included.
docs/concepts/architecture.md Full rewrite of the architecture doc with mermaid diagrams, module layout, lifecycle flows, and state management; content reflects the refactored code.
AGENTS.md Adds a reference to ARCHITECTURE.md at the top of the file.
docs/package.json Adds mermaid ^11.16.0 and vitepress-plugin-mermaid ^2.0.17 as devDependencies.
src/supervisor/mod.rs No functional changes; module declarations unchanged.

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'upstream/m..." | Re-trigger Greptile

Comment thread src/supervisor/lifecycle.rs Outdated
Comment thread docs/.vitepress/config.mts
Comment thread docs/.npmrc
@@ -0,0 +1 @@
shamefully-hoist=true

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.

P2 shamefully-hoist=true masks undeclared peer dependencies

shamefully-hoist=true makes pnpm hoist all packages to the root node_modules, mimicking npm's legacy flat layout. This is sometimes required by packages that don't declare their own peer dependencies. A comment noting which package requires this flag would help future maintainers know whether it can ever be removed.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 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 `@docs/.vitepress/theme/custom.css`:
- Around line 183-190: Add a narrowly scoped Stylelint disable around the
Mermaid foreignObject/foreignobject selectors so the intentional SVG selector is
accepted, and re-enable the rule immediately afterward. Keep the existing
overflow and line-height declarations unchanged.

In `@docs/concepts/architecture.md`:
- Around line 318-360: Track the fire-and-forget tasks created by
spawn_output_drain in the supervisor’s shutdown bookkeeping, alongside
active_monitors and hook_tasks. Update close() to await the registered drain
handles with a bounded timeout before shutdown completes, while preserving the
existing 5-second drain limit and ensuring trailing logs can be flushed.

In `@src/supervisor/lifecycle.rs`:
- Around line 170-199: Update the drain loop’s flush logic to retain and await
the `spawn_blocking` handle for each batch, ensuring only one log write is in
flight at a time. Await each completed batch before starting the next flush,
including the final `flush(&mut buffer)`, while preserving the existing batch
size and error logging behavior.
- Around line 1378-1384: Update the active_port cleanup in the lifecycle stop
flow to verify ownership under the state-file lock before clearing it. In the
!stop_intent_observed branch, clear the entry only when the stored PID still
matches pid; otherwise preserve the replacement daemon’s port.
- Around line 1340-1352: Move the active-monitor registration and MonitorGuard
creation from the late exit path to the beginning of the outer monitor task,
before it can observe or act on process shutdown. Keep MonitorGuard’s Drop
implementation decrementing SUPERVISOR.active_monitors and notifying
monitor_done so the guard covers the monitor task’s entire lifetime.
🪄 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: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: a8bf1e00-b43c-41ea-b12d-287a04f9487f

📥 Commits

Reviewing files that changed from the base of the PR and between 64c2c20 and a2a5637.

📒 Files selected for processing (12)
  • AGENTS.md
  • ARCHITECTURE.md
  • docs/.npmrc
  • docs/.vitepress/config.mts
  • docs/.vitepress/theme/custom.css
  • docs/.vitepress/theme/index.ts
  • docs/aube-lock.yaml
  • docs/concepts/architecture.md
  • docs/package.json
  • docs/pnpm-workspace.yaml
  • mise.toml
  • src/supervisor/lifecycle.rs

Comment thread docs/.vitepress/theme/custom.css
Comment thread docs/concepts/architecture.md
Comment thread src/supervisor/lifecycle.rs Outdated
Comment thread src/supervisor/lifecycle.rs Outdated
Comment thread src/supervisor/lifecycle.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
src/supervisor/lifecycle.rs (1)

1513-1538: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make PID validation and the state update atomic.

The takeover check at Lines 1462–1466 releases the state lock before this upsert. A concurrent start can install a replacement PID after that check, then this old monitor overwrites it with Stopped and pid = None. Perform a PID-conditional update while holding the state lock, as done for active_port.

🤖 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 `@src/supervisor/lifecycle.rs` around lines 1513 - 1538, The daemon state check
and update in the lifecycle monitor are racy because the lock is released before
upsert_daemon. Change the flow around the takeover validation and the
!already_stopped && !stop_intent_observed branch to retain the state lock
through a PID-conditional update, matching the active_port handling, so the old
monitor clears the PID and sets Stopped/Errored only when its monitored PID is
still current.
🤖 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 `@src/supervisor/lifecycle.rs`:
- Around line 727-740: The monitor registration currently occurs inside the
spawned future, allowing close() to observe active_monitors as zero before the
task is first polled. Move the active_monitors increment and MonitorGuard
construction to the code before tokio::spawn, then move the guard into the
spawned future so cancellation and all exit paths still decrement the count.
- Around line 158-164: Update MonitorGuard::drop to call notify_one() instead of
notify_waiters() after decrementing SUPERVISOR.active_monitors, preserving the
shutdown wakeup permit for the single waiter and avoiding the
missed-notification gap.

In `@src/supervisor/mod.rs`:
- Around line 950-970: Update the drain-task setup around drain_handles and the
JoinSet timeout so each original drain task’s abort handle is retained. On
timeout, abort those original handles directly before reaping the JoinSet
wrapper futures; keep the existing timeout warning and wrapper cleanup behavior.

---

Outside diff comments:
In `@src/supervisor/lifecycle.rs`:
- Around line 1513-1538: The daemon state check and update in the lifecycle
monitor are racy because the lock is released before upsert_daemon. Change the
flow around the takeover validation and the !already_stopped &&
!stop_intent_observed branch to retain the state lock through a PID-conditional
update, matching the active_port handling, so the old monitor clears the PID and
sets Stopped/Errored only when its monitored PID is still current.
🪄 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: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 998ebde7-54ec-4d2d-ba13-856ab31dcd58

📥 Commits

Reviewing files that changed from the base of the PR and between a2a5637 and 7ba02c4.

📒 Files selected for processing (4)
  • docs/.vitepress/theme/custom.css
  • docs/concepts/architecture.md
  • src/supervisor/lifecycle.rs
  • src/supervisor/mod.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/.vitepress/theme/custom.css
  • docs/concepts/architecture.md

Comment on lines +158 to +164
impl Drop for MonitorGuard {
fn drop(&mut self) {
SUPERVISOR
.active_monitors
.fetch_sub(1, atomic::Ordering::Release);
SUPERVISOR.monitor_done.notify_waiters();
}

Copy link
Copy Markdown

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

# Locate relevant symbols and read the surrounding code.
rg -n "MonitorGuard|monitor_done|notify_waiters|notify_one|close\\(" src/supervisor/lifecycle.rs

echo '--- lifecycle.rs excerpt ---'
sed -n '130,210p' src/supervisor/lifecycle.rs

echo '--- more lifecycle.rs around shutdown close path ---'
sed -n '680,780p' src/supervisor/lifecycle.rs

echo '--- any other monitor_done usages ---'
rg -n "monitor_done" src

Repository: jdx/pitchfork

Length of output: 10411


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the file size first and then inspect only the relevant nearby sections.
wc -l src/supervisor/lifecycle.rs
sed -n '1,260p' src/supervisor/lifecycle.rs

Repository: jdx/pitchfork

Length of output: 10286


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '880,960p' src/supervisor/mod.rs

Repository: jdx/pitchfork

Length of output: 3647


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the monitor task exit path around the comments mentioning monitor_done.
sed -n '1440,1490p' src/supervisor/lifecycle.rs
sed -n '1560,1590p' src/supervisor/lifecycle.rs

Repository: jdx/pitchfork

Length of output: 4132


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '880,960p' src/supervisor/mod.rs
sed -n '1440,1490p' src/supervisor/lifecycle.rs
sed -n '1560,1590p' src/supervisor/lifecycle.rs

Repository: jdx/pitchfork

Length of output: 7628


Preserve the shutdown wakeup. close() can hit the gap between checking active_monitors and awaiting notified(), so notify_waiters() can drop the wakeup and leave shutdown sitting on the 5s timeout even after all monitors have exited. With a single shutdown waiter, notify_one() keeps that permit for the next poll.

Proposed fix
-        SUPERVISOR.monitor_done.notify_waiters();
+        SUPERVISOR.monitor_done.notify_one();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
impl Drop for MonitorGuard {
fn drop(&mut self) {
SUPERVISOR
.active_monitors
.fetch_sub(1, atomic::Ordering::Release);
SUPERVISOR.monitor_done.notify_waiters();
}
impl Drop for MonitorGuard {
fn drop(&mut self) {
SUPERVISOR
.active_monitors
.fetch_sub(1, atomic::Ordering::Release);
SUPERVISOR.monitor_done.notify_one();
}
🤖 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 `@src/supervisor/lifecycle.rs` around lines 158 - 164, Update
MonitorGuard::drop to call notify_one() instead of notify_waiters() after
decrementing SUPERVISOR.active_monitors, preserving the shutdown wakeup permit
for the single waiter and avoiding the missed-notification gap.

Comment on lines +727 to +740
// Register with `close()` *before* any lifecycle work so that
// shutdown reliably waits for this monitor to finish (and to
// register any drain/hook tasks it spawns) regardless of which
// exit path is taken. The guard is dropped on every return path,
// decrementing `active_monitors` and notifying `monitor_done`.
//
// Increment first, then construct the guard: if guard
// construction were to fail (e.g. panic) the count would still
// be balanced; and Drop cannot run before the matching
// increment because there is no await point between them.
SUPERVISOR
.active_monitors
.fetch_add(1, atomic::Ordering::Release);
let _monitor_guard = MonitorGuard;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Register the monitor before publishing the spawned task.

The increment still runs inside the spawned future. run_once() can return and close() can observe active_monitors == 0 before Tokio first polls this task, allowing shutdown to skip its eventual drain registration. Increment synchronously before tokio::spawn, create the guard outside, and move it into the future so cancellation still balances the count.

This is the same monitor-registration race identified previously, but the registration was moved only to the beginning of the future rather than before spawning it.

🤖 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 `@src/supervisor/lifecycle.rs` around lines 727 - 740, The monitor registration
currently occurs inside the spawned future, allowing close() to observe
active_monitors as zero before the task is first polled. Move the
active_monitors increment and MonitorGuard construction to the code before
tokio::spawn, then move the guard into the spawned future so cancellation and
all exit paths still decrement the count.

Comment thread src/supervisor/mod.rs Outdated
Comment on lines +950 to +970
let mut join_set = tokio::task::JoinSet::new();
for handle in drain_handles {
join_set.spawn(async move {
let _ = handle.await;
});
}
let overall_timeout = Duration::from_secs(7);
let timed_out = tokio::time::timeout(overall_timeout, async {
while join_set.join_next().await.is_some() {}
})
.await
.is_err();
if timed_out {
let remaining = join_set.len();
warn!(
"output drain tasks did not complete within {overall_timeout:?} during \
shutdown, aborting {remaining} remaining task(s)"
);
join_set.abort_all();
// Reap the aborted tasks so their resources are released.
while join_set.join_next().await.is_some() {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Abort the drain tasks themselves on timeout.
JoinSet::abort_all() only cancels the wrapper futures here; dropping the inner JoinHandles detaches the actual drain tasks, so they can keep running past shutdown. Keep an abort handle for each original drain task and cancel those directly before reaping the wrappers.

🤖 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 `@src/supervisor/mod.rs` around lines 950 - 970, Update the drain-task setup
around drain_handles and the JoinSet timeout so each original drain task’s abort
handle is retained. On timeout, abort those original handles directly before
reaping the JoinSet wrapper futures; keep the existing timeout warning and
wrapper cleanup behavior.

@github-actions

Copy link
Copy Markdown
Contributor

This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically.

This is warning day 1 of 7.

Please update the PR when you have a chance. Feel free to reopen or create a new PR if it is closed and you'd like to continue working on it.

This comment was generated by an automated workflow.

@github-actions

Copy link
Copy Markdown
Contributor

This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically.

This is warning day 2 of 7.

Please update the PR when you have a chance. Feel free to reopen or create a new PR if it is closed and you'd like to continue working on it.

This comment was generated by an automated workflow.

@github-actions

Copy link
Copy Markdown
Contributor

This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically.

This is warning day 3 of 7.

Please update the PR when you have a chance. Feel free to reopen or create a new PR if it is closed and you'd like to continue working on it.

This comment was generated by an automated workflow.

jdx and others added 2 commits July 25, 2026 00:17
Opening the log store from two processes at once fails: switching a
database into WAL mode needs an exclusive lock and, unlike ordinary
statements, does not consult the busy handler, so the connection that
loses the race gets SQLITE_BUSY no matter how long its timeout is. The
error surfaced from open() itself, taking the whole store down rather
than one statement.

WAL is recorded in the database header and persists across connections,
so only the first open needs to set it. enable_wal now retries briefly in
case a peer is mid-switch and then carries on regardless: a connection
that never personally set WAL still works correctly, and refusing to open
the log store over a transient pragma race is far worse than running one
connection unoptimised.

Also states busy_timeout explicitly. rusqlite already defaults to the
same 5s, so this changes no behavior today; it records the requirement at
the call site instead of resting on a dependency's default, and the new
contention test guards it.

Prerequisite for moving log writes out of the supervisor process, where
several writers open the same store concurrently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without one the scheduler was free to run the workers in sequence, so the
first would enable WAL and the rest would find it already set — never
contending for the journal-mode switch the test exists to guard. It
caught the bug on this machine but was not guaranteed to anywhere else.

Verified by reverting enable_wal: the test now fails on every run rather
than by luck.

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

Copy link
Copy Markdown
Contributor

This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically.

This is warning day 4 of 7.

Please update the PR when you have a chance. Feel free to reopen or create a new PR if it is closed and you'd like to continue working on it.

This comment was generated by an automated workflow.

jdx and others added 6 commits July 25, 2026 01:58
The supervisor held the read end of every daemon's output pipe, which put
it in the data path. Killing it left the pipe with no reader, so the
daemon's next write took SIGPIPE and the daemon usually died with it —
undermining the re-adoption added in jdx#633, since a daemon that logs to
stdout rarely survived long enough to be adopted. Anything written but
not yet read was lost as well.

Capture now happens in a sibling process: `pitchfork log-sink`, this
binary re-executed, holding the read end and writing to the log store.
A supervisor crash is invisible to logging — the daemon keeps writing,
the sink keeps recording, and no line is dropped. This is the arrangement
runit uses, where runsv starts a service alongside its own log service
and stays out of the stream.

The supervisor retains a spare read end, which does two things: a sink
that dies cannot leave the pipe readerless and kill the daemon, and its
replacement can be handed the same pipe. It deliberately keeps no write
end, which would stop the sink from ever seeing end of file. A sink that
exits while its daemon is still monitored is replaced, mirroring runsv
restarting a log service; one that exits cleanly has reached end of file
and is not.

Failed starts report what the daemon printed by querying the log store,
which the in-process path guaranteed by flushing synchronously before
signalling. That ordering is restored by waiting for the sink's final
write, bounded so a daemon whose descendants hold the pipe open cannot
stall the caller.

Daemons using ready_output, an on_output hook, or pty = true keep the
in-process path, because the supervisor itself has to read the stream to
serve them, and so remain vulnerable to a supervisor crash. Teaching the
sink to evaluate those and report matches back is left for a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three problems, two of which could leave a daemon with no reader at all —
blocking it once the pipe filled and killing it with SIGPIPE once the
sink's own end went.

The retry test also failed, on CI and locally: waiting for the sink's
final write on every attempt both delayed the backoff and widened the
window in which a daemon looks errored and idle. `run` retries inline, so
the background retry checker had time to start an attempt of its own
alongside it, producing five attempts where four were expected and
doubling the elapsed time. The wait now happens only on the attempt that
gives up, which is the only one whose output is reported.

- a sink that cannot be spawned no longer ends supervision. It dropped
  the retained read end while the daemon was already running and writing;
  spawning is now retried for as long as the daemon is monitored.
- the sink reads bytes and converts lossily instead of requiring valid
  UTF-8. A single stray byte used to end the read loop, and because that
  looked like a clean exit the supervisor read it as end of file and
  stopped replacing the sink. Genuine read failures now exit non-zero so
  a replacement starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were raised in review: stdout and stderr deliberately share one pipe
(as the in-process path effectively did, and as runit does), and an
adopted daemon's sink is not covered by a replacement loop, so one that
dies after adoption is not replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught `retry zero fails immediately with one attempt`, which requires
a failed start to finish in under three seconds. Waiting for the sink
process to exit was costing ~766ms of that budget, and the reason is that
exiting is a poor proxy for having written: it also pays for spawning a
process and opening the log store, hundreds of milliseconds, while the
write itself lands in a few dozen. Measured on the same daemon, the
output was queryable after 36ms.

The failure path now polls for this run's output directly, capped at
400ms, which brings a failed start from ~766ms down to ~33ms. A daemon
that fails without printing anything has nothing to wait for and pays the
cap, which is why it is short.

Sinks are also started before the daemon rather than after it, so a
daemon that exits immediately no longer has to wait for its reader to
come up before anything can be captured, and the supervision loop now
waits on the sink it already holds before consulting the monitor
registry. Checking the registry first abandoned the sink of a daemon that
had already exited, since its monitor entry was gone by then.

This removes the Drained handshake entirely: nothing needs to observe the
sink's exit any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's lint step failed on formatting: later edits went in without
re-running rustfmt. `mise run lint` now passes locally, which is the
same command CI runs.

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

Four robustness problems, all in the process that is now the only reader
of a daemon's pipe — so each one ends with the daemon blocked or its
output lost.

- reading and writing are separate tasks. The batch write was on the read
  path, so a contended SQLite write, which can now wait out the store's
  five-second busy timeout, stopped the pipe being drained. A bounded
  queue between them keeps backpressure without unbounded growth.
- output containing no newline is split at 64 KiB instead of accumulated
  indefinitely. A daemon emitting an endless stream, or binary data, would
  have grown the buffer until the sink was killed for memory — and the
  supervisor would have started another to repeat it. Verified: 300 KB of
  newline-free output is recorded as bounded lines.
- a sink started for a daemon that then fails to spawn is killed and
  reaped explicitly. Dropping the handle only reaps best-effort, and
  run_once runs per retry attempt, so repeated spawn failures could leave
  several behind.
- the gap before retrying a failed sink spawn drops from 1s to 200ms.
  Nothing drains the pipe meanwhile, so a chatty daemon blocks; blocking
  is the right outcome rather than discarding output, but the gap should
  be brief.

A failed start also waits for its output to settle rather than returning
at the first row, since the sink batches: a daemon printing eight lines
before failing reported only the first. Now all eight, at a cost of
~120ms rather than ~30ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jdx and others added 7 commits July 25, 2026 03:39
Five issues, four of them introduced by the previous commit's split of
reading from writing.

A queue closed by a dead writer was reported as success, which tells the
supervisor the sink reached end of file — so it would stop replacing a
sink while the daemon was still writing. It is now a read error, which
exits non-zero and gets the sink replaced.

The wait a failed start does for its output had three problems:

- its timeout sat between polls, so a single query waiting out the
  store's five-second busy timeout blew straight through the 400ms cap.
  The cap now wraps the whole loop.
- it counted every entry since the run began on each poll, materializing
  a chatty daemon's whole history every 20ms. It now reads one row: the
  newest entry's id, which is all that growth detection needs.
- a query error counted as zero rows, which reads as a settled stream. An
  unreadable store says nothing about whether the sink has finished
  writing, so it no longer ends the wait early.

Sink spawn failures now back off to five seconds rather than retrying
five times a second for the life of the daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch for a daemon that exits before its PID can be read killed the
sink immediately, discarding whatever was still in the pipe or batched in
it — and since that daemon ran, its output is the only explanation of why
it gave up. Reaping like that is right for a daemon that never started at
all, which is the path it was added for.

Its write end is already closed, so the sink is on its way to end of file:
wait for the output to land, as the other readiness failures do, and reap
afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splitting a line purely by byte count cut multi-byte characters in half at
the 64 KiB cap, and converting each half on its own turned one valid
character into two replacement characters. The split now stops at the last
complete character and carries the partial sequence into the next line.

Emitting at the cap also left the buffer empty, so a newline arriving
immediately afterwards recorded an extra blank row — that newline
terminates the line already written. Blank lines a daemon actually prints
are still recorded.

Verified: a two-byte character straddling the boundary survives intact,
exactly 64 KiB followed by a newline yields two rows rather than three,
and consecutive blank lines are still preserved.

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

Two review follow-ups.

A sink that has been started but not yet handed to `supervise` is now held
by a guard that terminates it when dropped. `run_once` can bail out at
several points in between — stdio failing to wire up, the daemon's spawn
failing, its PID being unreadable — and each one had to remember to reap.
Three findings have now been raised about paths through that gap, so tying
cleanup to the value's lifetime retires the class rather than patching the
next one.

The character-boundary split no longer asks `from_utf8` where the string
stops being valid. That reports the first problem, so an invalid byte
earlier in the line hid an unfinished character at the end and the
character was split anyway. It now inspects the final bytes directly, which
cannot be masked. Verified with an invalid byte early and a two-byte
character straddling the cap: the injected byte alone becomes a
replacement character and the straddling one survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tokio::spawn` in a destructor panics when there is no runtime to spawn
onto, and silently abandons the task if shutdown has already begun — both
reachable while the runtime is going away. `start_kill` needs neither, so
the signal is always delivered; collecting the exit status is left to
tokio's orphan reaping, since a destructor cannot await.

Verified against the case the guard exists for, using a shell binary that
does not exist so the daemon's spawn fails after its sink has started:
five failed starts leave no sinks behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move MonitorGuard acquisition before the output drain so close() cannot
observe active_monitors == 0 during the 5s drain window and exit(0)
before hooks are registered.

Make the output drain fire-and-forget via spawn_output_drain() so the
5s drain window no longer blocks the close()/start() critical path.

Rename pre_drain_is_stopping to stop_intent_observed and split it into
was_stopped_before_cleanup / was_stopping_before_cleanup for clarity.
Capture log_format.clone() in parse_line so the monitor task retains
ownership of log_format.
Rewrite docs/concepts/architecture.md with the full technical overview:
module layout, startup sequence, background tasks, daemon states, state
persistence, process spawning, readiness detection, port management,
termination, monitor exit path, hooks, logging, proxy, resource limits,
and IPC responses.

Add ARCHITECTURE.md symlink at repo root pointing to the VitePress
source, and reference it prominently in AGENTS.md.
Add vitepress-plugin-mermaid and mermaid 11.16.0 to render flowchart
diagrams in the architecture docs.

Fix text clipping caused by mermaid 11.15+ bug #7759 (themeCSS
lowercases foreignObject selector, breaking XML case-sensitive CSS
matching) with three layers:
- flowchart.wrappingWidth: 1000 in mermaid config (default 200 clips)
- CSS targeting both foreignObject and foreignobject spellings
- JS post-processing via MutationObserver to set overflow=visible on
  asynchronously rendered nodes

Add docs/.npmrc (shamefully-hoist=true) and docs/pnpm-workspace.yaml
(allowBuilds: esbuild) required by the mermaid plugin install.
Add a comment above the test task explaining that local developers
should install GNU parallel (apt install parallel / brew install
parallel) to enable bats --jobs 16 parallel execution. Without it,
bats falls back to sequential execution.
Track monitor tasks for their full lifetime and register background output
drains before their monitor guard drops. Shutdown waits for registered
drains concurrently under a single bounded timeout.

Await each SQLite drain batch before starting the next one, and clear an
active port only while the state record still belongs to the exiting PID.

Also add a scoped stylelint exemption for Mermaid SVG selectors and keep
the architecture documentation aligned with the shutdown sequence.
Reflect the log-sink design from PR jdx#661: most daemons now have their
output captured by a sibling `pitchfork log-sink` process that holds
the pipe's read end, surviving supervisor crashes. Daemons using
ready_output, on_output hooks, or pty keep the in-process path.

Updated sections:
- Module layout: add log_sink.rs
- Startup sequence: add sink setup step before spawn
- Readiness detection: clarify two capture paths
- Monitor exit path: note drain applies only to in-process path
- Logging: rewrite with sink-path and in-process-path subsections

Also includes rustfmt formatting from ci-dev (let-chains, remove
unnecessary reference).
@gaojunran
gaojunran changed the base branch from main to claude/log-sink July 25, 2026 08:54
@gaojunran
gaojunran changed the base branch from claude/log-sink to main July 25, 2026 09:27
# Conflicts:
#	docs/cli/commands.json
#	pitchfork.usage.kdl
#	src/cli/log_sink.rs
#	src/supervisor/lifecycle.rs
#	src/supervisor/log_sink.rs
#	test/logs.bats

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
src/supervisor/lifecycle.rs (1)

1535-1568: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not block restart state cleanup on the output drain.

Lines 1548-1568 hold the daemon record in Running while draining for up to five seconds. A natural exit followed by an immediate start is therefore rejected as already running. Move the drain into a registered background task before MonitorGuard drops; complete port/state cleanup and hooks without waiting for the drain deadline.

AI-generated review comment. Based on learnings, GitHub PR comments must note that they are AI-generated.

🤖 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 `@src/supervisor/lifecycle.rs` around lines 1535 - 1568, Move the post-exit
output drain and final log flush out of the synchronous lifecycle path around
MonitorGuard, registering them as a background task before the guard drops.
Ensure port/state cleanup and exit hooks proceed without awaiting the
five-second drain deadline, while preserving sender removal, local-line
buffering, timeout behavior, and final flush within the background task.

Source: Learnings

🧹 Nitpick comments (2)
docs/.vitepress/config.mts (1)

177-185: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Remove securityLevel: "loose" from the Mermaid config.

wrappingWidth and useMaxWidth work with Mermaid’s default strict mode. This docs deploy only contains one Mermaid diagram, and it does not use click callbacks or raw HTML, so keeping loose unnecessarily exposes diagram content to raw HTML/interactivity in docs that are generated from content behind the docs site.

🔒️ Proposed change
   mermaid: {
-    securityLevel: "loose",
     // wrappingWidth: mermaid 11.16 `#7354` clips long node text at default 200px.
     // useMaxWidth: scale SVG to container width.
     flowchart: {
       wrappingWidth: 1000,
       useMaxWidth: true,
     },
   },
🤖 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 `@docs/.vitepress/config.mts` around lines 177 - 185, Remove the securityLevel
setting from the Mermaid configuration object, leaving the existing flowchart
wrappingWidth and useMaxWidth options unchanged so Mermaid uses its default
strict mode.
src/supervisor/lifecycle.rs (1)

2609-2613: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make test database cleanup async and fallible.

These synchronous removals block the runtime and discard all cleanup failures. Use tokio::fs, ignore only NotFound, and return miette::Result<()>.

AI-generated review comment. As per coding guidelines, “all I/O should be async” and “Return miette::Result for rich error messages.” Based on learnings, GitHub PR comments must note that they are AI-generated.

🤖 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 `@src/supervisor/lifecycle.rs` around lines 2609 - 2613, Update cleanup_db to
be async and return miette::Result<()>, replacing synchronous remove_file calls
with tokio::fs operations. Ignore only NotFound errors for the database, WAL,
and SHM paths; propagate all other failures through the miette result, and
update callers to await the cleanup.

Sources: Coding guidelines, Learnings

🤖 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 `@docs/concepts/architecture.md`:
- Around line 313-340: Update the `close()` shutdown sequence and Monitor Task
Exit Path to document that output draining runs in a background fire-and-forget
task, with drain handles registered before monitor completion. Include those
handles in shutdown waiting under the bounded timeout, and remove wording that
implies the monitor synchronously drains output or that all trailing output is
flushed before it exits.
- Around line 213-223: Renumber the duplicated spawn-pipeline entries in the
architecture documentation: keep Command construction as step 8, change the
pre_exec hook to step 9, and increment each subsequent Spawn, Spawn monitor
task, and wait_ready entry accordingly.

---

Outside diff comments:
In `@src/supervisor/lifecycle.rs`:
- Around line 1535-1568: Move the post-exit output drain and final log flush out
of the synchronous lifecycle path around MonitorGuard, registering them as a
background task before the guard drops. Ensure port/state cleanup and exit hooks
proceed without awaiting the five-second drain deadline, while preserving sender
removal, local-line buffering, timeout behavior, and final flush within the
background task.

---

Nitpick comments:
In `@docs/.vitepress/config.mts`:
- Around line 177-185: Remove the securityLevel setting from the Mermaid
configuration object, leaving the existing flowchart wrappingWidth and
useMaxWidth options unchanged so Mermaid uses its default strict mode.

In `@src/supervisor/lifecycle.rs`:
- Around line 2609-2613: Update cleanup_db to be async and return
miette::Result<()>, replacing synchronous remove_file calls with tokio::fs
operations. Ignore only NotFound errors for the database, WAL, and SHM paths;
propagate all other failures through the miette result, and update callers to
await the cleanup.
🪄 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: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 135328ad-3e56-40f8-82ae-c1e27d559ccf

📥 Commits

Reviewing files that changed from the base of the PR and between 7ba02c4 and 37dad98.

📒 Files selected for processing (13)
  • AGENTS.md
  • ARCHITECTURE.md
  • docs/.npmrc
  • docs/.vitepress/config.mts
  • docs/.vitepress/theme/custom.css
  • docs/.vitepress/theme/index.ts
  • docs/aube-lock.yaml
  • docs/concepts/architecture.md
  • docs/package.json
  • docs/pnpm-workspace.yaml
  • mise.toml
  • src/supervisor/lifecycle.rs
  • src/supervisor/mod.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • docs/.npmrc
  • mise.toml
  • ARCHITECTURE.md
  • docs/pnpm-workspace.yaml
  • docs/.vitepress/theme/custom.css
  • docs/.vitepress/theme/index.ts

Comment on lines +213 to +223
8. **Command construction**:
- `current_dir = opts.dir`
- `PATH` reset to the original user PATH (so daemons find user tools)
- Custom `env` from config applied first
- Pitchfork metadata injected after user env: `PITCHFORK_DAEMON_ID`, `PITCHFORK_DAEMON_NAMESPACE`, `PITCHFORK_RETRY_COUNT`
- `PORT` / `PORT0` / `PORT1` / ... set from resolved ports
- Proxy env via `inject_proxy_env` (see Proxy Integration)
8. **`pre_exec` hook** (Unix): `setsid()` (so daemon PID == PGID, enabling process-group kills), optional `TIOCSCTTY` for PTY controlling terminal, and `apply_run_identity` for privilege switching.
9. **Spawn**, capture PID, `PROCS.refresh_pids`, upsert daemon as `Running`. If a sink was started, hand its read end to `SinkPipe::supervise()` which keeps a sink running for the daemon's lifetime (replacing one that dies unexpectedly).
10. **Spawn monitor task** (see Readiness Detection).
11. If `wait_ready` is true, block on the readiness oneshot channel and return `DaemonReady` / `DaemonFailedWithCode`; otherwise return `DaemonStart` immediately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the duplicated spawn-pipeline step number.

pre_exec is also labeled step 8, so the subsequent steps are ambiguous. Renumber pre_exec and the following entries sequentially.

Proposed documentation fix
-8. **`pre_exec` hook** (Unix):
-9. **Spawn**,
-10. **Spawn monitor task**,
-11. If `wait_ready` is true,
+9. **`pre_exec` hook** (Unix):
+10. **Spawn**,
+11. **Spawn monitor task**,
+12. If `wait_ready` is true,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
8. **Command construction**:
- `current_dir = opts.dir`
- `PATH` reset to the original user PATH (so daemons find user tools)
- Custom `env` from config applied first
- Pitchfork metadata injected after user env: `PITCHFORK_DAEMON_ID`, `PITCHFORK_DAEMON_NAMESPACE`, `PITCHFORK_RETRY_COUNT`
- `PORT` / `PORT0` / `PORT1` / ... set from resolved ports
- Proxy env via `inject_proxy_env` (see Proxy Integration)
8. **`pre_exec` hook** (Unix): `setsid()` (so daemon PID == PGID, enabling process-group kills), optional `TIOCSCTTY` for PTY controlling terminal, and `apply_run_identity` for privilege switching.
9. **Spawn**, capture PID, `PROCS.refresh_pids`, upsert daemon as `Running`. If a sink was started, hand its read end to `SinkPipe::supervise()` which keeps a sink running for the daemon's lifetime (replacing one that dies unexpectedly).
10. **Spawn monitor task** (see Readiness Detection).
11. If `wait_ready` is true, block on the readiness oneshot channel and return `DaemonReady` / `DaemonFailedWithCode`; otherwise return `DaemonStart` immediately.
8. **Command construction**:
- `current_dir = opts.dir`
- `PATH` reset to the original user PATH (so daemons find user tools)
- Custom `env` from config applied first
- Pitchfork metadata injected after user env: `PITCHFORK_DAEMON_ID`, `PITCHFORK_DAEMON_NAMESPACE`, `PITCHFORK_RETRY_COUNT`
- `PORT` / `PORT0` / `PORT1` / ... set from resolved ports
- Proxy env via `inject_proxy_env` (see Proxy Integration)
9. **`pre_exec` hook** (Unix): `setsid()` (so daemon PID == PGID, enabling process-group kills), optional `TIOCSCTTY` for PTY controlling terminal, and `apply_run_identity` for privilege switching.
10. **Spawn**, capture PID, `PROCS.refresh_pids`, upsert daemon as `Running`. If a sink was started, hand its read end to `SinkPipe::supervise()` which keeps a sink running for the daemon's lifetime (replacing one that dies unexpectedly).
11. **Spawn monitor task** (see Readiness Detection).
12. If `wait_ready` is true, block on the readiness oneshot channel and return `DaemonReady` / `DaemonFailedWithCode`; otherwise return `DaemonStart` immediately.
🧰 Tools
🪛 LanguageTool

[grammar] ~223-~223: Ensure spelling is correct
Context: ..._readyis true, block on the readiness oneshot channel and returnDaemonReady/Dae...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 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 `@docs/concepts/architecture.md` around lines 213 - 223, Renumber the
duplicated spawn-pipeline entries in the architecture documentation: keep
Command construction as step 8, change the pre_exec hook to step 9, and
increment each subsequent Spawn, Spawn monitor task, and wait_ready entry
accordingly.

Comment on lines +313 to +340
### `close()` (supervisor shutdown)

1. Shut down IPC (stop accepting new requests).
2. Force-flush state.
3. Stop daemons in **dependency reverse order** (`compute_reverse_stop_order`), concurrent within each dependency layer.
4. Wait up to 5s for all monitor tasks to exit (`active_monitors` counter + `monitor_done` Notify), ensuring their hook tasks have been registered.
5. Wait for all hook tasks to complete (30s timeout per task).
6. Cancel proxy / web / mDNS tasks.

## Monitor Task Exit Path

When the spawned process exits, the monitor task performs cleanup in a specific order to handle concurrent `stop()` / `start()` races correctly:

1. **Acquire `MonitorGuard`** (RAII counter via `active_monitors` + `monitor_done` Notify) at the start of the monitor task. It covers every task exit path, so `close()` cannot observe `active_monitors == 0` before any drain or hook task has been registered.
2. **Snapshot `stop_intent_observed`** before any cleanup that could race with a concurrent `start()`. Computed as `was_stopped_before_cleanup || was_stopping_before_cleanup` from the pre-cleanup daemon state. This preserves the intentional-stop signal even if `start()` (e.g. from `pitchfork restart`) upserts a new PID with `Running` status during cleanup.
3. **Drain in-flight output** synchronously: the `output_rx` channel is drained for up to 5s (or until EOF) and lines are flushed to the log store. The `OutputRelay` is dropped first so its sender doesn't keep the channel open. Sink-relayed lines (`OutputSource::Sink`) are already stored and skipped. Only applies to the in-process path; sink-path daemons are drained by the sink process itself.
4. **Clear `active_port`** only if the daemon record still has this monitor's PID, checked while holding the state-file lock. A concurrent `start()` with a replacement PID keeps its new `active_port`.
5. **PID-takeover check + exit reason + hooks**: if `stop_intent_observed` is false and a new process has taken over (different PID, not `Stopping`/`Stopped`), spawn the background drain and return without updating state or firing hooks. Otherwise:
- Combine the pre-cleanup snapshot with the current state to cover both race orders: `stop()` set `Stopping` before cleanup (`stop_intent_observed`), or `stop()` set `Stopped` during cleanup (`already_stopped`).
- **Determine `exit_reason`**: `"stop"` (intentional), `"exit"` (clean self-exit), or `"fail"` (non-zero).
- **Update state** unless `stop()` already did it or `stop_intent_observed` is true (avoid undoing a restart that upserted `Running` during cleanup).
- **Fire hooks** based on `exit_reason`:
- `"stop"` → `OnStop` + `OnExit`
- `"exit"` → `OnExit`
- `"fail"` with retries exhausted → `OnFail` + `OnExit`
- `"fail"` with retries remaining → no hooks (retry loop handles it)
6. **Drain complete**: output was already drained inline in step 3, so no further drain is needed. The `MonitorGuard` at the top of the task drops as the task ends.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Document the background output-drain shutdown contract.

These sections still describe the 5-second drain as synchronous and only list monitor/hook-task waiting. That conflicts with the PR objective of moving the drain to a fire-and-forget task; the architecture should state that drain handles are registered and awaited under a bounded shutdown timeout, otherwise readers may assume trailing output is flushed before the monitor exits.

🤖 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 `@docs/concepts/architecture.md` around lines 313 - 340, Update the `close()`
shutdown sequence and Monitor Task Exit Path to document that output draining
runs in a background fire-and-forget task, with drain handles registered before
monitor completion. Include those handles in shutdown waiting under the bounded
timeout, and remove wording that implies the monitor synchronously drains output
or that all trailing output is flushed before it exits.

@github-actions

Copy link
Copy Markdown
Contributor

This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically.

This is warning day 1 of 7.

Please update the PR when you have a chance. Feel free to reopen or create a new PR if it is closed and you'd like to continue working on it.

This comment was generated by an automated workflow.

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