feat(server): reap idle dev stacks from a level-triggered sweep - #296
Merged
Conversation
patroza
marked this pull request as ready for review
August 3, 2026 07:05
Owner
Author
|
@omegent get it green |
Dev stacks that repositories start outside T3's process tree have been surviving for hours after the work that needed them finished. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged PRs, holding roughly 15 GB of RSS. Teardown was never broken; nothing invoked it. Both existing triggers are edge-triggered. runOnWorktreeRemove needs a worktree removal, and merging a PR does not remove one. runOnPrMerged fires from VcsStatusBroadcaster only when it observes a not-merged -> merged transition for a worktree it happens to be polling, so an agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown. Add a sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours. Both project scripts stay: they are the early reap and are strictly faster when they do fire. This is the floor under them, not a replacement. The registry format (`dev-stack/1`, contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Policy is declared per checkout under `devStacks` in t3.json: idle window, consumer patterns, and the ordered entry roles. That split matters beyond tidiness: an idle window is a property of a repository rather than of one running instance, so changing it now applies to stacks that are already up, on the next sweep. Decisions worth knowing: - Opt-in. A repository without `devStacks` is left alone rather than swept under a guessed policy. - `entryRoles` is ordered and we watch the first role present. A frontend holds keep-alive connections to its own API, so also watching the API port would read a stack as busy for as long as the frontend is up, and nothing would ever look idle. An api-only stack falls through to the API by the same rule. - An absent `entryRoles` watches every port. That over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run. - A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation. - The producer's start/stop lock is honoured, so a stack that is halfway up is skipped rather than killed. - Ownership is confirmed against /proc/<pid>/cwd before signalling, so a recycled PID is never mistaken for a stack's own process. The sweep runs as one layer-scoped fiber in the server that already runs, rather than a separate unit: the server is what starts the agents that create these stacks, so "server down" means "no new stacks", and a per-stack systemd TTL is the better answer for a hung server than a second daemon would be. Failures are logged and swallowed. Each sweep reports its duration and counts, and logs at info when it acted or ran long, so whether it burdens the main loop stays a query rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rences The producer of dev-stack/1 is a private repository; naming it here, and using its worktree paths and tenant names as test fixtures, leaks downstream detail into a contract that is meant to be repo-agnostic and is read by anyone adopting the format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
omegent-app
Bot
force-pushed
the
feat/dev-stack-reaper
branch
from
August 3, 2026 11:24
31a7d6f to
e63d6f6
Compare
|
Pushed a fix to PR #296 on rebased head
Local validation I ran:
GitHub is rerunning the required checks now on the new commit. Current state on PR #296 is
|
patroza
enabled auto-merge (squash)
August 3, 2026 11:25
patroza
added a commit
that referenced
this pull request
Aug 4, 2026
* feat(server): reap idle dev stacks from a level-triggered sweep Dev stacks that repositories start outside T3's process tree have been surviving for hours after the work that needed them finished. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged PRs, holding roughly 15 GB of RSS. Teardown was never broken; nothing invoked it. Both existing triggers are edge-triggered. runOnWorktreeRemove needs a worktree removal, and merging a PR does not remove one. runOnPrMerged fires from VcsStatusBroadcaster only when it observes a not-merged -> merged transition for a worktree it happens to be polling, so an agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown. Add a sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours. Both project scripts stay: they are the early reap and are strictly faster when they do fire. This is the floor under them, not a replacement. The registry format (`dev-stack/1`, contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Policy is declared per checkout under `devStacks` in t3.json: idle window, consumer patterns, and the ordered entry roles. That split matters beyond tidiness: an idle window is a property of a repository rather than of one running instance, so changing it now applies to stacks that are already up, on the next sweep. Decisions worth knowing: - Opt-in. A repository without `devStacks` is left alone rather than swept under a guessed policy. - `entryRoles` is ordered and we watch the first role present. A frontend holds keep-alive connections to its own API, so also watching the API port would read a stack as busy for as long as the frontend is up, and nothing would ever look idle. An api-only stack falls through to the API by the same rule. - An absent `entryRoles` watches every port. That over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run. - A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation. - The producer's start/stop lock is honoured, so a stack that is halfway up is skipped rather than killed. - Ownership is confirmed against /proc/<pid>/cwd before signalling, so a recycled PID is never mistaken for a stack's own process. The sweep runs as one layer-scoped fiber in the server that already runs, rather than a separate unit: the server is what starts the agents that create these stacks, so "server down" means "no new stacks", and a per-stack systemd TTL is the better answer for a hung server than a second daemon would be. Failures are logged and swallowed. Each sweep reports its duration and counts, and logs at info when it acted or ran long, so whether it burdens the main loop stays a query rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(contracts): keep the dev-stack contract free of downstream references The producer of dev-stack/1 is a private repository; naming it here, and using its worktree paths and tenant names as test fixtures, leaks downstream detail into a contract that is meant to be repo-agnostic and is read by anyone adopting the format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): green dev stack reaper PR --------- Co-authored-by: Stack Test <stack-test@example.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com>
patroza
added a commit
that referenced
this pull request
Aug 4, 2026
* feat(server): reap idle dev stacks from a level-triggered sweep Dev stacks that repositories start outside T3's process tree have been surviving for hours after the work that needed them finished. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged PRs, holding roughly 15 GB of RSS. Teardown was never broken; nothing invoked it. Both existing triggers are edge-triggered. runOnWorktreeRemove needs a worktree removal, and merging a PR does not remove one. runOnPrMerged fires from VcsStatusBroadcaster only when it observes a not-merged -> merged transition for a worktree it happens to be polling, so an agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown. Add a sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours. Both project scripts stay: they are the early reap and are strictly faster when they do fire. This is the floor under them, not a replacement. The registry format (`dev-stack/1`, contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Policy is declared per checkout under `devStacks` in t3.json: idle window, consumer patterns, and the ordered entry roles. That split matters beyond tidiness: an idle window is a property of a repository rather than of one running instance, so changing it now applies to stacks that are already up, on the next sweep. Decisions worth knowing: - Opt-in. A repository without `devStacks` is left alone rather than swept under a guessed policy. - `entryRoles` is ordered and we watch the first role present. A frontend holds keep-alive connections to its own API, so also watching the API port would read a stack as busy for as long as the frontend is up, and nothing would ever look idle. An api-only stack falls through to the API by the same rule. - An absent `entryRoles` watches every port. That over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run. - A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation. - The producer's start/stop lock is honoured, so a stack that is halfway up is skipped rather than killed. - Ownership is confirmed against /proc/<pid>/cwd before signalling, so a recycled PID is never mistaken for a stack's own process. The sweep runs as one layer-scoped fiber in the server that already runs, rather than a separate unit: the server is what starts the agents that create these stacks, so "server down" means "no new stacks", and a per-stack systemd TTL is the better answer for a hung server than a second daemon would be. Failures are logged and swallowed. Each sweep reports its duration and counts, and logs at info when it acted or ran long, so whether it burdens the main loop stays a query rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(contracts): keep the dev-stack contract free of downstream references The producer of dev-stack/1 is a private repository; naming it here, and using its worktree paths and tenant names as test fixtures, leaks downstream detail into a contract that is meant to be repo-agnostic and is read by anyone adopting the format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): green dev stack reaper PR --------- Co-authored-by: Stack Test <stack-test@example.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com>
patroza
added a commit
that referenced
this pull request
Aug 4, 2026
* feat(server): reap idle dev stacks from a level-triggered sweep Dev stacks that repositories start outside T3's process tree have been surviving for hours after the work that needed them finished. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged PRs, holding roughly 15 GB of RSS. Teardown was never broken; nothing invoked it. Both existing triggers are edge-triggered. runOnWorktreeRemove needs a worktree removal, and merging a PR does not remove one. runOnPrMerged fires from VcsStatusBroadcaster only when it observes a not-merged -> merged transition for a worktree it happens to be polling, so an agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown. Add a sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours. Both project scripts stay: they are the early reap and are strictly faster when they do fire. This is the floor under them, not a replacement. The registry format (`dev-stack/1`, contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Policy is declared per checkout under `devStacks` in t3.json: idle window, consumer patterns, and the ordered entry roles. That split matters beyond tidiness: an idle window is a property of a repository rather than of one running instance, so changing it now applies to stacks that are already up, on the next sweep. Decisions worth knowing: - Opt-in. A repository without `devStacks` is left alone rather than swept under a guessed policy. - `entryRoles` is ordered and we watch the first role present. A frontend holds keep-alive connections to its own API, so also watching the API port would read a stack as busy for as long as the frontend is up, and nothing would ever look idle. An api-only stack falls through to the API by the same rule. - An absent `entryRoles` watches every port. That over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run. - A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation. - The producer's start/stop lock is honoured, so a stack that is halfway up is skipped rather than killed. - Ownership is confirmed against /proc/<pid>/cwd before signalling, so a recycled PID is never mistaken for a stack's own process. The sweep runs as one layer-scoped fiber in the server that already runs, rather than a separate unit: the server is what starts the agents that create these stacks, so "server down" means "no new stacks", and a per-stack systemd TTL is the better answer for a hung server than a second daemon would be. Failures are logged and swallowed. Each sweep reports its duration and counts, and logs at info when it acted or ran long, so whether it burdens the main loop stays a query rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(contracts): keep the dev-stack contract free of downstream references The producer of dev-stack/1 is a private repository; naming it here, and using its worktree paths and tenant names as test fixtures, leaks downstream detail into a contract that is meant to be repo-agnostic and is read by anyone adopting the format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): green dev stack reaper PR --------- Co-authored-by: Stack Test <stack-test@example.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com>
patroza
added a commit
that referenced
this pull request
Aug 5, 2026
* feat(server): reap idle dev stacks from a level-triggered sweep Dev stacks that repositories start outside T3's process tree have been surviving for hours after the work that needed them finished. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged PRs, holding roughly 15 GB of RSS. Teardown was never broken; nothing invoked it. Both existing triggers are edge-triggered. runOnWorktreeRemove needs a worktree removal, and merging a PR does not remove one. runOnPrMerged fires from VcsStatusBroadcaster only when it observes a not-merged -> merged transition for a worktree it happens to be polling, so an agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown. Add a sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours. Both project scripts stay: they are the early reap and are strictly faster when they do fire. This is the floor under them, not a replacement. The registry format (`dev-stack/1`, contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Policy is declared per checkout under `devStacks` in t3.json: idle window, consumer patterns, and the ordered entry roles. That split matters beyond tidiness: an idle window is a property of a repository rather than of one running instance, so changing it now applies to stacks that are already up, on the next sweep. Decisions worth knowing: - Opt-in. A repository without `devStacks` is left alone rather than swept under a guessed policy. - `entryRoles` is ordered and we watch the first role present. A frontend holds keep-alive connections to its own API, so also watching the API port would read a stack as busy for as long as the frontend is up, and nothing would ever look idle. An api-only stack falls through to the API by the same rule. - An absent `entryRoles` watches every port. That over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run. - A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation. - The producer's start/stop lock is honoured, so a stack that is halfway up is skipped rather than killed. - Ownership is confirmed against /proc/<pid>/cwd before signalling, so a recycled PID is never mistaken for a stack's own process. The sweep runs as one layer-scoped fiber in the server that already runs, rather than a separate unit: the server is what starts the agents that create these stacks, so "server down" means "no new stacks", and a per-stack systemd TTL is the better answer for a hung server than a second daemon would be. Failures are logged and swallowed. Each sweep reports its duration and counts, and logs at info when it acted or ran long, so whether it burdens the main loop stays a query rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(contracts): keep the dev-stack contract free of downstream references The producer of dev-stack/1 is a private repository; naming it here, and using its worktree paths and tenant names as test fixtures, leaks downstream detail into a contract that is meant to be repo-agnostic and is read by anyone adopting the format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): green dev stack reaper PR --------- Co-authored-by: Stack Test <stack-test@example.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com>
patroza
added a commit
that referenced
this pull request
Aug 5, 2026
* feat(server): reap idle dev stacks from a level-triggered sweep Dev stacks that repositories start outside T3's process tree have been surviving for hours after the work that needed them finished. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged PRs, holding roughly 15 GB of RSS. Teardown was never broken; nothing invoked it. Both existing triggers are edge-triggered. runOnWorktreeRemove needs a worktree removal, and merging a PR does not remove one. runOnPrMerged fires from VcsStatusBroadcaster only when it observes a not-merged -> merged transition for a worktree it happens to be polling, so an agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown. Add a sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours. Both project scripts stay: they are the early reap and are strictly faster when they do fire. This is the floor under them, not a replacement. The registry format (`dev-stack/1`, contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Policy is declared per checkout under `devStacks` in t3.json: idle window, consumer patterns, and the ordered entry roles. That split matters beyond tidiness: an idle window is a property of a repository rather than of one running instance, so changing it now applies to stacks that are already up, on the next sweep. Decisions worth knowing: - Opt-in. A repository without `devStacks` is left alone rather than swept under a guessed policy. - `entryRoles` is ordered and we watch the first role present. A frontend holds keep-alive connections to its own API, so also watching the API port would read a stack as busy for as long as the frontend is up, and nothing would ever look idle. An api-only stack falls through to the API by the same rule. - An absent `entryRoles` watches every port. That over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run. - A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation. - The producer's start/stop lock is honoured, so a stack that is halfway up is skipped rather than killed. - Ownership is confirmed against /proc/<pid>/cwd before signalling, so a recycled PID is never mistaken for a stack's own process. The sweep runs as one layer-scoped fiber in the server that already runs, rather than a separate unit: the server is what starts the agents that create these stacks, so "server down" means "no new stacks", and a per-stack systemd TTL is the better answer for a hung server than a second daemon would be. Failures are logged and swallowed. Each sweep reports its duration and counts, and logs at info when it acted or ran long, so whether it burdens the main loop stays a query rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(contracts): keep the dev-stack contract free of downstream references The producer of dev-stack/1 is a private repository; naming it here, and using its worktree paths and tenant names as test fixtures, leaks downstream detail into a contract that is meant to be repo-agnostic and is read by anyone adopting the format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): green dev stack reaper PR --------- Co-authored-by: Stack Test <stack-test@example.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com>
patroza
added a commit
that referenced
this pull request
Aug 5, 2026
* feat(server): reap idle dev stacks from a level-triggered sweep Dev stacks that repositories start outside T3's process tree have been surviving for hours after the work that needed them finished. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged PRs, holding roughly 15 GB of RSS. Teardown was never broken; nothing invoked it. Both existing triggers are edge-triggered. runOnWorktreeRemove needs a worktree removal, and merging a PR does not remove one. runOnPrMerged fires from VcsStatusBroadcaster only when it observes a not-merged -> merged transition for a worktree it happens to be polling, so an agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown. Add a sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours. Both project scripts stay: they are the early reap and are strictly faster when they do fire. This is the floor under them, not a replacement. The registry format (`dev-stack/1`, contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Policy is declared per checkout under `devStacks` in t3.json: idle window, consumer patterns, and the ordered entry roles. That split matters beyond tidiness: an idle window is a property of a repository rather than of one running instance, so changing it now applies to stacks that are already up, on the next sweep. Decisions worth knowing: - Opt-in. A repository without `devStacks` is left alone rather than swept under a guessed policy. - `entryRoles` is ordered and we watch the first role present. A frontend holds keep-alive connections to its own API, so also watching the API port would read a stack as busy for as long as the frontend is up, and nothing would ever look idle. An api-only stack falls through to the API by the same rule. - An absent `entryRoles` watches every port. That over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run. - A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation. - The producer's start/stop lock is honoured, so a stack that is halfway up is skipped rather than killed. - Ownership is confirmed against /proc/<pid>/cwd before signalling, so a recycled PID is never mistaken for a stack's own process. The sweep runs as one layer-scoped fiber in the server that already runs, rather than a separate unit: the server is what starts the agents that create these stacks, so "server down" means "no new stacks", and a per-stack systemd TTL is the better answer for a hung server than a second daemon would be. Failures are logged and swallowed. Each sweep reports its duration and counts, and logs at info when it acted or ran long, so whether it burdens the main loop stays a query rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(contracts): keep the dev-stack contract free of downstream references The producer of dev-stack/1 is a private repository; naming it here, and using its worktree paths and tenant names as test fixtures, leaks downstream detail into a contract that is meant to be repo-agnostic and is read by anyone adopting the format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): green dev stack reaper PR --------- Co-authored-by: Stack Test <stack-test@example.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Dev stacks that repositories start outside T3's process tree survive the work that needed them. In the case that prompted this, ten stacks stayed up for thirteen hours across two already-merged pull requests, holding ~15 GB of RSS on the host.
Teardown was never broken — nothing invoked it. Both existing triggers are edge-triggered.
runOnWorktreeRemoveneeds a worktree removal, and merging a PR does not remove one.runOnPrMergedfires fromVcsStatusBroadcaster.maybeRunPrMergedLifecycleonly when it observes a not-merged → merged transition for a worktree it happens to be polling. An agent that finishes its work and a human who merges an hour later produce no observer, no edge, and no teardown.What
A sweep that looks at state rather than events, so a missed hook costs one idle window instead of an unbounded number of hours.
Both project scripts stay. They are the early reap and are strictly faster when they fire; this is the floor under them, not a replacement.
The registry format (
dev-stack/1,contracts/devStack.ts) carries facts only — pids, ports, and where each process runs — because the sweep reads every project's stacks and cannot hold per-repo knowledge. Repositories write those entries themselves, from whatever starts their servers; T3 only reads them. Policy is declared per checkout underdevStacksint3.json: idle window, consumer patterns, ordered entry roles.That split is not just tidiness. An idle window is a property of a repository, not of one running instance, so declaring it in
t3.jsonmeans a change applies to stacks that are already up on the next sweep, rather than only to ones started afterwards.How
Decisions worth reviewing:
Opt-in. A repository without
devStacksis left alone rather than swept under a guessed policy.entryRolesis ordered, and we watch the first role present. A frontend holds keep-alive connections to its own API, so watching the API port as well would read a stack as busy for as long as the frontend is up and nothing would ever look idle. An api-only stack falls through to the API by the same rule. An absententryRoleswatches every port — that over-detects activity, which is the right way to be wrong: a stack lives too long instead of dying mid-run.A stack is never reaped on the sweep that first sees it. A cold boot plus a build can outlast the window, so the clock starts from an observation rather than from nothing.
The producer's start/stop lock is honoured, so a stack halfway through coming up is skipped rather than killed. Ownership is confirmed against
/proc/<pid>/cwdbefore signalling, so a recycled PID is never mistaken for a stack's own process.No new systemd unit. The sweep is one layer-scoped fiber in the server that already runs. The server is what starts the agents that create these stacks, so "server down" means "no new stacks"; the residual case is a hung server, and a per-stack systemd TTL is a better answer to that than a second daemon. Failures are logged and swallowed — a sweep that cannot read
/procmust not take the server down.Every sweep reports duration and counts, logging at info when it acted or ran over 250ms and at debug otherwise, so "is this burdening the main loop" stays a query rather than a guess. Expected cost is a readdir over a handful of files, two
/proc/net/tcpreads, and onecmdline+cwdread per process — syscall-bound, tens of milliseconds, every five minutes.The decision logic is split into
DevStackPolicy.ts, free of the filesystem,/proc, and signals, so it is tested directly rather than through a live stack (17 cases).Remarks
A producer implementation of
dev-stack/1exists in a downstream repository and has been verified end to end against a real stack: cold API start 5s emitting exactly this contract, warm reuse 7ms, stop clean with no surviving processes, no registry entries and no leases.Contracts tests 5/5, server tests 50/50 including the 17 new ones,
tsc --noEmitclean for everything touched. Ownership checked withpnpm fork:overlay-owner: all touched paths resolve tofork/changes.Follow-up, deliberately not here:
RuntimeMaxSecon a systemd scope per stack, as the floor that needs no observer alive at all — the answer to the hung-server case above.🤖 Generated with Claude Code