Skip to content

fix(devkitd): close duplicate-name holes in agent.start - #418

Merged
codeaholicguy merged 3 commits into
mainfrom
fix/agent-start-duplicate-name
Nov 3, 2026
Merged

codeaholicguy merged 3 commits into
mainfrom
fix/agent-start-duplicate-name

Conversation

@codeaholicguy

Copy link
Copy Markdown
Owner

Summary

Fixes a duplicate-name hole in agent.start: a name registered via the fallback path (AI_DEVKIT_NO_DAEMON or pre-daemon agents) could be claimed again through the daemon.

Root cause — three holes:

  • import_legacy_agents ran once at daemon boot — post-boot legacy registrations were invisible to the name check
  • prune_dead deleted every tombstoned row unconditionally (WHERE gone_at IS NOT NULL), pruning live-pid registrations before the check could see them
  • the name_in_use check required gone_at IS NULL, but imported legacy rows are tombstoned-by-default — a live pid is now sufficient evidence

Fix:

  • Re-import inside start_mutex before the name check
  • prune_dead only deletes tombstones whose pids are actually dead
  • pid_alive alone decides name_in_use (matches TS prune semantics)
  • pid_alive moved to devkit-core (needed by prune_dead; agent.stop will use it too)

Closes #417

Test plan

  • cargo test --workspace — 135 pass, 0 fail
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --check clean
  • New regression tests: tombstoned_but_live_pid_still_blocks_name, legacy_row_written_after_daemon_boot_blocks_name (reproduces the reported scenario)
  • Manual repro: AI_DEVKIT_NO_DAEMON=1 start → daemon start with same name → expect name_in_use

A name registered via the fallback path (AI_DEVKIT_NO_DAEMON or
pre-daemon agents) could be claimed again through the daemon:

- import_legacy_agents only ran at daemon boot — post-boot legacy
  registrations were invisible to the name check. Now re-imports inside
  the start mutex before lookup_name.
- prune_dead deleted every tombstoned row without checking pid liveness,
  pruning live-pid registrations before the check could see them. It now
  deletes only tombstones whose pids are actually dead.
- the name-in-use check required gone_at IS NULL, but imported legacy
  rows are tombstoned-by-default — a live pid is now sufficient evidence
  regardless of gone_at (matches TS prune semantics).

pid_alive moves to devkit-core (prune_dead needs it; agent.stop will).
Regression tests cover both holes; rusqlite added as devkitd dev-dep
for the legacy-db fixture.
pid 10 is a live kernel thread on Linux, so prune_dead's new liveness
check kept the tombstone and the name reuse hit UNIQUE constraint.
i32::MAX is above every pid_max — dead on any platform.
Pure rustfmt line-wrapping of tombstone_frees_name_via_prune_and_reuse;
no semantic change. Fixes the nx rust:fmt:check failure on PR #418.
@codeaholicguy

Copy link
Copy Markdown
Owner Author

Second-pair-of-eyes review of the duplicate-name fix (post CI-greening: 99151c2 is a pure cargo fmt of the touched registry test, no semantic change).

Verdict: the three holes are closed correctly. ✅

  • prune_dead liveness gate — sound. The daemon store has pid INTEGER PRIMARY KEY, so DELETE FROM agents WHERE pid=?1 can only remove the exact selected tombstone row (no collateral); all store ops serialize on the conn mutex and prune runs under start_mutex, so no interleaving with a concurrent register_start from the daemon side.
  • import_legacy_agents under start_mutex — closes the post-boot fallback hole. The delta-merge (name IS NULL attach / OR IGNORE) is idempotent and daemon rows always win; re-import on every start is cheap and correct.
  • Relaxed check (pid_alive alone, dropping gone_at.is_none()) — right call given tombstone semantics: imported legacy rows are tombstoned-by-default while their pids run. Dead pids still fall through to unregister + reclaim, and the UNIQUE(name) backstop in register_start maps to name_in_use.
  • Error paths are fine: legacy-db unreadable/missing → import no-ops with a warn, so agent.start doesn't hard-fail on a corrupt legacy file; prune/import failures map to internal.
  • New tests cover both main holes end-to-end (tombstoned_but_live_pid_still_blocks_name, legacy_row_written_after_daemon-boot_blocks_name), and the i32::MAX pid swap makes the prune test CI-deterministic (pid 10 is a live kernel thread on Linux).

Non-blocking notes (no action requested):

  1. Residual cross-process TOCTOU (accepted-risk grade): a legacy-path start can claim a name in agents.db after the daemon's re-import but before register_start — the daemon's check won't see it, and dual_write_legacy's INSERT OR REPLACE (keyed (type,pid)) would silently replace the name-conflicting legacy row rather than fail, since the legacy UNIQUE(name) resolves via REPLACE. Millisecond window, requires concurrent legacy-path start with the same name; fine to accept, worth a line in the docs.
  2. Pid-recuse blocking: a tombstone whose pid was recycled by an unrelated live process now blocks that name until the pid dies. Conservative direction (safe for duplicates), but could surprise — it's the documented attach-by-pid narrow race made slightly more visible.
  3. Minor: no direct core-level unit test asserting prune_dead() itself retains a live-pid tombstone (the devkitd e2e test covers it indirectly since run_with prunes before the lookup); and filter_map(|r| r.ok()) in prune_dead silently drops row-read errors — consistent with read_legacy_rows, cosmetic.

Evidence: cargo fmt --all -- --check ✅, cargo clippy --workspace --all-targets -- -D warnings ✅, cargo test --workspace 135/135 ✅ locally; all 8 CI checks green on 99151c2.

@codeaholicguy
codeaholicguy merged commit de74bf0 into main Nov 3, 2026
8 checks passed
@codeaholicguy
codeaholicguy deleted the fix/agent-start-duplicate-name branch November 3, 2026 16:16
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.

fix(devkitd): agent.start allows duplicate names for fallback-registered agents

1 participant