Skip to content

feat(daemon): keep servers running across a daemon restart or upgrade - #41

Merged
quantizor merged 64 commits into
fix/wait-healthy-current-runfrom
fix/reported-bugs
Oct 19, 2026
Merged

quantizor merged 64 commits into
fix/wait-healthy-current-runfrom
fix/reported-bugs

Conversation

@quantizor

Copy link
Copy Markdown
Owner

This branch works through the reported bugs and keeps your servers running while the daemon restarts or upgrades. It is stacked on #40, so review that one first; this diff starts where #40 ends.

What changes for you

Servers survive a daemon restart or upgrade. directa daemon restart and directa daemon install used to stop every server. Now the old daemon steps aside, the new one picks each server up where it is, and output written during the swap still reaches the logs. Open browser tabs, long builds, and in-memory state come through intact. daemon stop, uninstall, logout, and shutdown still stop everything first. The menu bar app does the same when it has to re-register the background agent, and it now logs why it did so.

Health reporting you can trust

  • A health check no longer reuses a cached answer. A server whose health URL sent a short cache lifetime (Cache-Control: max-age) could read healthy right after a restart, before it was listening.
  • directa ensure restarts a server that went unhealthy instead of handing it back broken.
  • directa wait <name> --stopped returns only once no process is left.
  • A restart checks that no leftover process from the old run still holds the server's port before the new run starts.
  • Health checks run on a lane of their own, so a slow check never holds up launchd or lsof calls.

Locks

  • directa lock --pause starts your command only once every paused server and its children have exited.
  • The command sees DIRECTA_LOCKS, and a nested lock on the same resource runs at once instead of waiting on its own parent.
  • A resource name containing , or :: is refused, both on request and in devservers.json.

Clearer messages

  • Not-found errors point at the project that does define the name, and list only projects that still exist.
  • Every error hint is a command you can paste, including --project where it matters.
  • A command run from a deleted directory fails at once instead of spinning.

Agent hooks. A new session or a compaction gives a full summary again for each checkout, and long agent ids no longer produce file names the system refuses.

Platform

  • directa now states that it needs an Apple silicon Mac. The Homebrew cask refuses an Intel Mac with a clear message, where before it installed a copy that could not open.
  • swift-subprocess moves from a pinned pre-release commit to the 1.0 release.

Other changes in this branch

  • docs(agents): AGENTS.md rewritten as a short index with a charter, plus docs/architecture.md and docs/standards/testing.md for depth.
  • build(cask): the zap list is ordered the way brew style requires, so the cask check passes again.
  • build(test): make test cleans up its run directory even when it finds stray processes.

Verification

On the merged branch:

  • make build
  • make test
  • make dead-code
  • scripts/test-narrow-pool.sh
  • scripts/smoke.sh
  • scripts/smoke-cask.sh

Each new behavior has a test that failed before its fix.

Root cause of the restart-then-healthy report, from the server's own event log: the new run read healthy about 0.2 seconds after it started, a cold start normally takes 35 to 110 seconds, and the health URL answered cache-control: max-age=5.

The menu bar filter matched on server names and URLs that no row displays,
so a row could appear with nothing marked. The filter now matches only the
labels a row shows, and highlighting marks each match in the original text,
so characters whose lowercase form changes length no longer shift the marks.
…check, full probe timeout, and still-starting timeouts
…ts, per-agent hook records, and lock pause guarantees
A test shell that loops with no end and outlives a killed `make test` run
kept forking for hours. The run now names and kills any process whose command
line names its run root, and fails. Each test shell loop stops after a minute,
and the next run reaps shells left by a dead run.
`directa wait <name> --stopped` returned as soon as a server reported failed, even when a process was still running for it. That happens when another server took the port: the first server keeps its process, so a stop was still needed. Now the wait holds until no process is alive, and it returns once `directa stop` has taken the process down.

When such a wait times out, the message names `directa stop <name>` as the next step.
The after-tool hook announced servers it had already reported whenever Claude Code subagents ran beside their parent, or an agent moved between a project's checkouts, because they all shared one remembered picture. Each agent (a subagent counts as its own) now has its own record, holding one picture per project, so unchanged servers stay quiet and a project not yet seen by that agent gets its full summary once.

Records written by an older version keep loading. Their single picture has no project to file under and is dropped, so each project speaks its summary once more. Tool calls that finish together now take turns on the record with a file lock, so only one of them speaks; a hook that cannot get the lock within a few seconds goes ahead without it.
When the daemon restarts and launchd's job list does not answer, a server whose process survived is left alone until the list can be read. Until now a later status read made that server look stopped with no process, and "directa ensure" then started a second copy beside it or failed on the port it still held.

Now the server keeps showing the phase and pid it had, its port still counts as taken for other servers, and start, restart, up, stop, down, unregister, and a pausing lock are refused with daemon-starting, naming the server and its pid. The daemon re-reads launchd's job list in the background with growing delays for up to five minutes. The first answer decides the survivor the way boot restore does: a listed job is taken back over, anything else is stopped and started fresh. A list that never answers leaves the server as it is for the next launch.

Also stops a re-register test from racing its own row read by giving the new run its own gate.
Run from a directory that no longer exists (a removed worktree), every
project-scoped command walked a relative path upward forever at full CPU.
The current directory now reads as gone up front: the command fails usage
(exit 2) saying so, with hint `run: directa status --all`. Session hooks and
the statusline stay silent, and `lock` refuses because its guarded command
needs a directory to run in. Every ancestor walk now ends on any input: a
relative path with no current directory comes back as written, and the walks
are bounded by the path's own depth.
Asking for a server the project lacks answered with hint `directa status
--json`, which listed nothing. The error now looks for the name in the other
registered projects: none keeps `directa status --all`, one names that project
with `directa status <name> --project '<path>'`, and several list every path
with `directa status --all <name>`. A name is never resolved into another
project on its own. The linked-worktree message also says that `--project`
with the main checkout's path acts on the main checkout's server instead.
Covers status, open, monitor, restart, deep links, and the daemon's own
start, ensure, stop, logs, and restart refusals.
A shared session capped every HTTP health check at five seconds whatever
timeoutMs said, so a slow cold boot that answered at six or seven seconds
was reported as a timeout and the server stayed "starting". timeoutMs now
bounds each check in full: an answer that arrives inside it passes, however
slow, and a check that outlasts it ends at the deadline.

A check now falls back from IPv4 to IPv6 only when the IPv4 connection was
refused. A timeout is no longer retried over IPv6, which doubled the wait.

Each check also reports why it failed ("HTTP 500", "connection refused",
"timed out after 10s"), and the supervisor keeps the latest result for the
current run as lastHealthCheck on the server's status: failures in a row,
whether it passed, and the reason. It is cleared when the run ends and absent
for servers with no healthcheck.
A running server whose latest health check failed was described as "running
and healthy". The phase stays running until enough checks fail in a row, so
a server answering 500 read as fine.

`directa why` now says "running; N health checks failed in a row (HTTP 500)"
for such a server, and "running and healthy" only while the latest check
passed. The session context lists a running server with failing checks ahead
of the quiet ones, names the failing check, and points at `directa why`. A
new health check result alone never makes a tool hook speak.
When ensure, wait, up, restart, or switch timed out while a server was still
starting, the output read "fell short (timeout)", which looks like a crash.
It now says the server is still starting, not failed, and names
`directa wait <name> --timeout <seconds>` to keep waiting. The exit code is
still 1 and the JSON is unchanged.

The one-line description of a server (status, ensure, up) also names a
failing latest health check, so a running server answering errors no longer
looks healthy there either.
`directa daemon restart` and `directa daemon install` over a running daemon
now hand the servers to the next daemon instead of stopping them. Under the
background agent the old daemon exits without stopping anything, keeps every
restore intent, and the next daemon adopts the still-running launchd child
jobs. A SIGTERM that races the exit does not drain either. A daemon run with
--foreground, `daemon stop`, uninstall in every form, logout, and machine
shutdown still drain first.

`daemon.shutdown` gains an optional `handoff` param (append-only; an older
daemon ignores it and drains). A restart or install also waits for the new
daemon to finish restoring before it re-ensures the servers, so that re-ensure
is no longer refused with daemon-starting.
The command that `directa lock` runs now sees a `DIRECTA_LOCKS` environment variable listing the locks held around it. Each entry is the project's full path, `::`, and the resource name, comma-separated and added after any value the caller already had, so a tool with a lock of its own for the same resource can tell it is already protected.

The lock key is now built in one place shared by the daemon and the command line.
`directa lock --pause` used to count a server as paused even when its stop gave up, and to start the command as soon as the server's main process exited, while a worker it started could still be holding the file. The command now starts only after every paused server and every process it started has exited. A server whose stop gives up, or leaves processes running, fails the lock with `resource-locked`, names that server, and points at `directa why <name>`. Servers already paused are resumed and the command never runs. The command line fails at once on that refusal instead of waiting out the acquire timeout.

Every stop (restart, watch, stop) now also waits briefly for the processes it killed to exit before it returns, so a respawn does not race a worker still releasing its port.
A restart could report the new run healthy while nothing of its own was
listening: a process left over from the old run's tree still held the port
for a moment and answered the first health check. Every transition that
stops a run and starts the next (restart, the watch sweep's restart, and
start or ensure over a server that failed for its port) now waits a bounded
time after the stop for the server's claimed ports to have no listener. A
listener that stays is named in the server's log (pid, command, and whether
it belonged to the run just stopped) and the transition fails with
port-held instead of starting a run it cannot vouch for.

The server's log also notes when a health check passed but a listener scan
afterward found nothing on the server's port.
…status read

A freshly restarted server could read as running a stale config, or could
start without the port directa injects into its environment. A status read
landing in the short stopped moment between a restart's stop and its start
replaced the spec the restart had prepared with the raw committed one. The
check that the server is idle and the replacement are now one step, and a
prepared spec stays in force until the next run starts.
…-held at why

When a restart or start was refused after directa had prepared the server's
launch settings, the server stayed down but kept showing those settings, and
ignored later devservers.json edits until it next ran. The prepared settings
are now released on every refusal path (restart, start or ensure over a port
failure, and up).

The port-held refusal after a stop now tells the reader to run
`directa why <server>`, which shows the listener's pid and command, instead
of suggesting a kill.
Starting a server whose port an unmanaged process holds now suggests
`directa why <server>`, which shows the listener's pid and command, instead
of a kill command for a process directa does not own. The refusal message
still names the pid and command. The listener lookup the post-healthy check
uses is now the one every caller shares, and the test health checker follows
the health result type.
A daemon-starting refusal can arrive just before restore ends. When
daemon.info then reads as serving, the CLI repeats the request once at
once and fails only if that attempt is refused again.
A server whose surviving process boot restore could not decide about
stayed refused after that process exited, answered `wait` from an idle
supervisor, and was left running by a daemon shutdown.

- A deferred survivor whose run is gone is settled on each retry tick and
  when a request meets the refusal, with no job list needed.
- Once the retry window gives up, the refusal says the job list never
  answered and points at `directa daemon restart`.
- `wait` is refused like the other requests for a deferred survivor.
- A drain stops deferred survivors through the identity-checked teardown
  and keeps their restore intent.
- A settle that finishes after its project was forgotten acts on nothing.
…le tree

A `--pause` acquire stopped declarers one at a time with the key still
free, so a `start` or `ensure` of a declarer, or a second acquire, could
slip in mid-pause. It also trusted the phase alone when a stop it joined
was still waiting for the run's tree to exit.

- The key is reserved before the first await. A declarer's start or
  ensure (including one waiting out a stop) and a second acquire are
  refused as contended. The reservation is released on every refusal and
  resumes what was already paused.
- A pause that joins a stop in flight, or meets a declarer whose last
  stop is still waiting on its tree, waits for that stop's own completion.
- StopCompletion cases are alphabetized.
The two mid-pause tests ended with the stuck run still writing, which
left a temporary tree behind and failed the make test cleanup check.
A health probe reused a cached 200 from the server's previous run when the
server sent a Cache-Control max-age, so a server that was not listening could
read as healthy. The probe session now keeps no HTTP cache, and every probe
request ignores cached answers.
App Transport Security refuses plain http to `*.localhost` with or without an
embedded Info.plist, so the comment now gives that as the reason.
@quantizor
quantizor added this pull request to stack #42 October 19, 2026 03:04
@quantizor
quantizor merged commit e607c00 into main Oct 19, 2026
6 checks passed
@quantizor
quantizor deleted the fix/reported-bugs branch October 19, 2026 03:05
@github-actions github-actions Bot mentioned this pull request Oct 19, 2026
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.

1 participant