os-sdlc owns the lsp-probe daemon lifecycle per implementation; worktree plugin stops managing it #606

Closed
opened 2026-09-17 14:39:57 +00:00 by jared · 7 comments
Owner

Context

Session 01153d94 (2026-09-16 to 2026-09-17) shipped ADR-0198: a per-root ruby-lsp daemon over a UNIX socket, warmed by os worktree start and stopped by os worktree finish (#600, #601). A review on 2026-09-17 found five defects.

Observed

The daemon key is sha1(expand_path(--root)) and --root defaults to ".", so a client run from a subdirectory spawns a second daemon and the warm is wasted.
fresh? trusts a pid file, so a reused pid stalls every call for 15 s.
The socket lives under the shared .cc-os symlink target, so deleting a worktree does not stop its daemon.
A ruby-lsp spawned by OpenSession.open outside the daemon has no process group and cannot be reaped; one orphan on a deleted worktree was killed by hand.
Paths#cleanup! leaves one empty hash directory per worktree.
The os plugin warms on every worktree whether or not the task reads Ruby symbols, and it is the only place os references an os-sdlc binary.

Reproduce

n/a: design change, not a bug reproduction

Expected

  • Runner open for an implementation finds or serves the daemon for Project.root, detached; intake does not wait on the cold start.
  • The terminal transition that stamps completed_at, failed_at, or escalated_at stops that implementation's daemon.
  • lsp-probe defs|refs|symbols|outline run from a subdirectory of a checkout connects to the daemon keyed on Model::Project.load(Dir.pwd).root; no second daemon spawns. --root remains only on serve.
  • Daemon#fresh? returns true only when a connect to daemon.sock succeeds; the pid file and the daemon.since client-side idle check are removed.
  • Daemon::Server exits within one idle pulse when its root directory no longer exists; Paths#cleanup! removes the hash directory.
  • OpenSession.open spawns ruby-lsp with pgroup: true; Stopper#stop sends KILL to the group after the wait without checking the leader.
  • os worktree start and finish no longer warm or stop the daemon; Worktree::LspWarmer and its tests are deleted.
  • ADR-0198 is amended: lifecycle owner is the implementation, registry key is the project root, worktree hooks removed.

Origin

  • Trigger: post-session review of session 01153d94, in session 03fac08d
  • Improvised this session: none
  • Chain: cwd-keyed daemon + pid liveness + worktree-owned lifecycle → duplicate daemons, stalls, orphans DESIGN
  • Root candidate: implementation lifecycle owns the daemon
  • Where: plugins/os-sdlc/lib/os_sdlc/lsp_probe/{cli.rb,daemon.rb,daemon/server.rb,daemon/paths.rb,daemon/stopper.rb,open_session.rb}; os-sdlc runner open and terminal transition; plugins/os/lib/worktree/{start_command.rb,finish_command.rb,lsp_warmer.rb}; docs/adr/0198
  • Session: 03fac08d-b3dc-4a6b-bb30-7c2742bb482c
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/03fac08d-b3dc-4a6b-bb30-7c2742bb482c.jsonl

Skeptic verdict

VERDICT: CREATE. User-filed ticket with a real review of five defects and eight concrete acceptance items (ticket-skeptic, 2026-09-17).

Scope

In scope for this ticket (os-sdlc target): items 1 to 6 of Expected, plus the ADR amendment as structural follow-through.
Out of scope, moved to the child ticket "os worktree start/finish stop managing the lsp-probe daemon; delete Worktree::LspWarmer" (os target): item 7 of Expected. Blocked by this ticket.

Owner sketch

```ruby
module OsSdlc::Pipeline
class LspDaemonLifecycle
def open(root)
def close(root)
end
class OpenImplementation
def call
end
end

module OsSdlc::Model::Implementation::InstanceMethods
def complete!(position: nil)
def fail!(position:, reason:)
def escalate!(position:, reason:)
end

module OsSdlc::LspProbe
class CLI < Thor
def defs / refs / symbols / outline
def serve
def stop
end
class Daemon
def fresh?
def connect
def stop
end
class Daemon::Paths
def cleanup!
end
class Daemon::Server
def waiting?
end
class Daemon::Stopper
def stop(pid)
end
class OpenSession
def self.spawn(root_path)
end
end
```

Cases

  1. Given a project root with no daemon socket, when an implementation opens, then the lifecycle starts `lsp-probe serve --root ` detached and open returns before the socket exists.
  2. Given a daemon socket for the project root that accepts a connection, when an implementation opens, then no second serve process is spawned.
  3. Given an implementation whose daemon is running, when complete!, fail!, or escalate! stamps its timestamp, then the daemon for that implementation's project root is stopped.
  4. Given Dir.pwd is a subdirectory of a checkout, when defs, refs, symbols, or outline runs, then the client connects to the daemon keyed on the checkout root found by RootSearch and no second daemon spawns.
  5. Given a process accepts a connection on daemon.sock, when fresh? is asked, then it returns true without reading a pid file or an activity file.
  6. Given a running server whose root directory has been removed, when one idle pulse elapses, then the server exits and cleanup! removes the hash directory.
  7. Given OpenSession spawns ruby-lsp, when the child starts, then it is spawned with pgroup: true.
  8. Given Stopper#stop has sent TERM and the wait has elapsed, when the process is still alive, then KILL is sent to the process group without a liveness check on the leader.

Structural follow-through

  • `--root` remains an option only on `serve` and `stop`; defs, refs, symbols, outline drop it.
  • Remove `Daemon#since`, `Daemon#idle?`, the pid file, and `Paths#pid_path`/`activity_path` once no caller remains.
  • Amend ADR-0198: lifecycle owner is the implementation, registry key is the project root, worktree hooks removed.
  • Confirm Daemon#fresh? no longer reads the pid file once the socket check lands (former case 5 is already satisfied by current code).
## Context Session 01153d94 (2026-09-16 to 2026-09-17) shipped ADR-0198: a per-root ruby-lsp daemon over a UNIX socket, warmed by `os worktree start` and stopped by `os worktree finish` (#600, #601). A review on 2026-09-17 found five defects. ## Observed The daemon key is `sha1(expand_path(--root))` and `--root` defaults to `"."`, so a client run from a subdirectory spawns a second daemon and the warm is wasted. `fresh?` trusts a pid file, so a reused pid stalls every call for 15 s. The socket lives under the shared `.cc-os` symlink target, so deleting a worktree does not stop its daemon. A ruby-lsp spawned by `OpenSession.open` outside the daemon has no process group and cannot be reaped; one orphan on a deleted worktree was killed by hand. `Paths#cleanup!` leaves one empty hash directory per worktree. The `os` plugin warms on every worktree whether or not the task reads Ruby symbols, and it is the only place `os` references an os-sdlc binary. ## Reproduce n/a: design change, not a bug reproduction ## Expected - [ ] Runner open for an implementation finds or serves the daemon for `Project.root`, detached; intake does not wait on the cold start. - [ ] The terminal transition that stamps `completed_at`, `failed_at`, or `escalated_at` stops that implementation's daemon. - [ ] `lsp-probe defs|refs|symbols|outline` run from a subdirectory of a checkout connects to the daemon keyed on `Model::Project.load(Dir.pwd).root`; no second daemon spawns. `--root` remains only on `serve`. - [ ] `Daemon#fresh?` returns true only when a connect to `daemon.sock` succeeds; the pid file and the `daemon.since` client-side idle check are removed. - [ ] `Daemon::Server` exits within one idle pulse when its root directory no longer exists; `Paths#cleanup!` removes the hash directory. - [ ] `OpenSession.open` spawns ruby-lsp with `pgroup: true`; `Stopper#stop` sends KILL to the group after the wait without checking the leader. - [ ] `os worktree start` and `finish` no longer warm or stop the daemon; `Worktree::LspWarmer` and its tests are deleted. - [ ] ADR-0198 is amended: lifecycle owner is the implementation, registry key is the project root, worktree hooks removed. ## Origin - Trigger: post-session review of session 01153d94, in session 03fac08d - Improvised this session: none - Chain: cwd-keyed daemon + pid liveness + worktree-owned lifecycle → duplicate daemons, stalls, orphans DESIGN - Root candidate: implementation lifecycle owns the daemon - Where: plugins/os-sdlc/lib/os_sdlc/lsp_probe/{cli.rb,daemon.rb,daemon/server.rb,daemon/paths.rb,daemon/stopper.rb,open_session.rb}; os-sdlc runner open and terminal transition; plugins/os/lib/worktree/{start_command.rb,finish_command.rb,lsp_warmer.rb}; docs/adr/0198 - Session: 03fac08d-b3dc-4a6b-bb30-7c2742bb482c - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/03fac08d-b3dc-4a6b-bb30-7c2742bb482c.jsonl ## Skeptic verdict VERDICT: CREATE. User-filed ticket with a real review of five defects and eight concrete acceptance items (ticket-skeptic, 2026-09-17). ## Scope In scope for this ticket (os-sdlc target): items 1 to 6 of Expected, plus the ADR amendment as structural follow-through. Out of scope, moved to the child ticket "os worktree start/finish stop managing the lsp-probe daemon; delete Worktree::LspWarmer" (os target): item 7 of Expected. Blocked by this ticket. ## Owner sketch \`\`\`ruby module OsSdlc::Pipeline class LspDaemonLifecycle def open(root) def close(root) end class OpenImplementation def call end end module OsSdlc::Model::Implementation::InstanceMethods def complete!(position: nil) def fail!(position:, reason:) def escalate!(position:, reason:) end module OsSdlc::LspProbe class CLI < Thor def defs / refs / symbols / outline def serve def stop end class Daemon def fresh? def connect def stop end class Daemon::Paths def cleanup! end class Daemon::Server def waiting? end class Daemon::Stopper def stop(pid) end class OpenSession def self.spawn(root_path) end end \`\`\` ## Cases 1. Given a project root with no daemon socket, when an implementation opens, then the lifecycle starts \`lsp-probe serve --root <root>\` detached and open returns before the socket exists. 2. Given a daemon socket for the project root that accepts a connection, when an implementation opens, then no second serve process is spawned. 3. Given an implementation whose daemon is running, when complete!, fail!, or escalate! stamps its timestamp, then the daemon for that implementation's project root is stopped. 4. Given Dir.pwd is a subdirectory of a checkout, when defs, refs, symbols, or outline runs, then the client connects to the daemon keyed on the checkout root found by RootSearch and no second daemon spawns. 5. Given a process accepts a connection on daemon.sock, when fresh? is asked, then it returns true without reading a pid file or an activity file. 6. Given a running server whose root directory has been removed, when one idle pulse elapses, then the server exits and cleanup! removes the hash directory. 7. Given OpenSession spawns ruby-lsp, when the child starts, then it is spawned with pgroup: true. 8. Given Stopper#stop has sent TERM and the wait has elapsed, when the process is still alive, then KILL is sent to the process group without a liveness check on the leader. ## Structural follow-through - \`--root\` remains an option only on \`serve\` and \`stop\`; defs, refs, symbols, outline drop it. - Remove \`Daemon#since\`, \`Daemon#idle?\`, the pid file, and \`Paths#pid_path\`/\`activity_path\` once no caller remains. - Amend ADR-0198: lifecycle owner is the implementation, registry key is the project root, worktree hooks removed. - Confirm Daemon#fresh? no longer reads the pid file once the socket check lands (former case 5 is already satisfied by current code).
Author
Owner

Work started via /os-sdlc:implement on branch ticket-606 (worktree .claude/worktrees/ticket-606).

Work started via /os-sdlc:implement on branch ticket-606 (worktree .claude/worktrees/ticket-606).
Author
Owner

Decomposed: os-sdlc scope enriched in place (Owner sketch + Cases); os plugin removal split to #607.

Decomposed: os-sdlc scope enriched in place (Owner sketch + Cases); os plugin removal split to #607.
Author
Owner

Implementation 27 (branch ticket-606) ended implementation_failed: bound_exhausted at test-reviewer/fail. Failed step: test-reviewer rejected the test-writer's red round three times (dispatches 334, 336, 338); the test-writer's own gates (diff-test red, test-lint green) passed each round. Gate details are in the handoff records for implementation 27 in .sdlc/pipeline.db. Worktree ticket-606 kept with the uncommitted test files for inspection. Recovery: a new implementation-open.

Implementation 27 (branch ticket-606) ended implementation_failed: bound_exhausted at test-reviewer/fail. Failed step: test-reviewer rejected the test-writer's red round three times (dispatches 334, 336, 338); the test-writer's own gates (diff-test red, test-lint green) passed each round. Gate details are in the handoff records for implementation 27 in .sdlc/pipeline.db. Worktree ticket-606 kept with the uncommitted test files for inspection. Recovery: a new implementation-open.
Author
Owner

Work started via /os-sdlc:implement on branch ticket-606 (fresh worktree from main b1a10cdc, session 67967f79).

Work started via /os-sdlc:implement on branch ticket-606 (fresh worktree from main b1a10cdc, session 67967f79).
Author
Owner

Implementation 1 (session 67967f79) FAILED at the programmer node (dispatch 9), reason verdict:error.

Gate details: the programmer's handoff disclosed the gate red as a pre-existing test-mapping gap for lib/os_sdlc/model/implementation/instance_methods.rb. The full os-sdlc suite in worktree ticket-606 shows 14 failures + 3 errors; unmodified main is 0/0. So the increment itself is red, not only the map.

Failure classes:

  1. Existing tests contradict behavior the ticket deliberately changes (daemon_test: stale pid file handling, stop sends only TERM, idle-timeout file removal). No pipeline node may edit existing tests, so this ticket cannot go green through the map as written.
  2. Real defects in the increment: Paths.new receives nil root via Server.run -> Daemon.connect (lsp-probe bin tests: TypeError), Daemon.new no longer accepts spawner:/clock: keywords used by an existing test, evidence probes get ECONNRESET / socket never appeared.
  3. Programmer mis-disclosed the red as mapping-only.

Worktree ticket-606 keeps the 7 new test files and 7 modified lib files, uncommitted. Test files: tests/lsp_probe/{cli_resolves_root_from_subdirectory,open_session_spawns_with_pgroup}_test.rb, tests/lsp_probe/daemon/{fresh_by_connect_only,paths_cleanup_removes_hash_dir,stopper_kills_process_group_without_leader_check}_test.rb, tests/model/implementation_stops_lsp_daemon_on_terminal_stamp_test.rb, tests/pipeline/open_implementation_warms_lsp_daemon_test.rb.

Recovery needs a decision: either the ticket lists the existing tests to retire/rewrite (so a node can own that edit), or the change is hand-finished from the worktree. Labelled waiting.

Implementation 1 (session 67967f79) FAILED at the programmer node (dispatch 9), reason verdict:error. Gate details: the programmer's handoff disclosed the gate red as a pre-existing test-mapping gap for lib/os_sdlc/model/implementation/instance_methods.rb. The full os-sdlc suite in worktree ticket-606 shows 14 failures + 3 errors; unmodified main is 0/0. So the increment itself is red, not only the map. Failure classes: 1. Existing tests contradict behavior the ticket deliberately changes (daemon_test: stale pid file handling, stop sends only TERM, idle-timeout file removal). No pipeline node may edit existing tests, so this ticket cannot go green through the map as written. 2. Real defects in the increment: Paths.new receives nil root via Server.run -> Daemon.connect (lsp-probe bin tests: TypeError), Daemon.new no longer accepts spawner:/clock: keywords used by an existing test, evidence probes get ECONNRESET / socket never appeared. 3. Programmer mis-disclosed the red as mapping-only. Worktree ticket-606 keeps the 7 new test files and 7 modified lib files, uncommitted. Test files: tests/lsp_probe/{cli_resolves_root_from_subdirectory,open_session_spawns_with_pgroup}_test.rb, tests/lsp_probe/daemon/{fresh_by_connect_only,paths_cleanup_removes_hash_dir,stopper_kills_process_group_without_leader_check}_test.rb, tests/model/implementation_stops_lsp_daemon_on_terminal_stamp_test.rb, tests/pipeline/open_implementation_warms_lsp_daemon_test.rb. Recovery needs a decision: either the ticket lists the existing tests to retire/rewrite (so a node can own that edit), or the change is hand-finished from the worktree. Labelled waiting.
Author
Owner

Workflow defect captured as #610 (map has no node that may edit existing tests the ticket invalidates; verdict:error pre-empted the suite-check route).

Workflow defect captured as #610 (map has no node that may edit existing tests the ticket invalidates; verdict:error pre-empted the suite-check route).
Author
Owner

Resolution

Done: Implementation owns the lsp-probe daemon lifecycle: Pipeline::LspDaemonLifecycle opens at implementation-open and closes on complete!/fail!/escalate! keyed on the project root; Daemon#fresh? is a socket connect (pid/activity files removed); query subcommands resolve the root via RootSearch with --root only on serve/stop/warm; ruby-lsp spawns with pgroup: true; Stopper sends group KILL after the wait without a leader check; server exits within one idle pulse when its socket or root is gone and cleanup! removes the hash dir. ADR-0201 amends ADR-0198. Cases 1-8 covered; expected item 7 (os worktree hooks) stays with the child ticket #607.

Evidence: Commit 418804d9 on branch ticket-606 (merged to main by worktree finish). Suite: 1341 runs, 3043 assertions, 0 failures, 0 errors (run twice by the worker, once by the orchestrator); main baseline 1331 runs 0/0. rubocop on touched files: 83 files, no offenses. Pipeline impl 1 failed at programmer (verdict:error); hand-finished by user decision.

Follow-ups: #610 map gap (no node may edit existing tests a ticket invalidates; verdict:error skipped suite-check); #607 os worktree start/finish stop managing the daemon. Unticketed observations: 34 pre-existing rubocop offenses in untouched os-sdlc files; daemon dir .cc-os/lsp// still inside the project root (per-user runtime dir would end fixture teardown races); autocorrect_prepass_test bare Dir.chdir races sibling tests, now stay_serial!.

## Resolution **Done:** Implementation owns the lsp-probe daemon lifecycle: Pipeline::LspDaemonLifecycle opens at implementation-open and closes on complete!/fail!/escalate! keyed on the project root; Daemon#fresh? is a socket connect (pid/activity files removed); query subcommands resolve the root via RootSearch with --root only on serve/stop/warm; ruby-lsp spawns with pgroup: true; Stopper sends group KILL after the wait without a leader check; server exits within one idle pulse when its socket or root is gone and cleanup! removes the hash dir. ADR-0201 amends ADR-0198. Cases 1-8 covered; expected item 7 (os worktree hooks) stays with the child ticket #607. **Evidence:** Commit 418804d9 on branch ticket-606 (merged to main by worktree finish). Suite: 1341 runs, 3043 assertions, 0 failures, 0 errors (run twice by the worker, once by the orchestrator); main baseline 1331 runs 0/0. rubocop on touched files: 83 files, no offenses. Pipeline impl 1 failed at programmer (verdict:error); hand-finished by user decision. **Follow-ups:** #610 map gap (no node may edit existing tests a ticket invalidates; verdict:error skipped suite-check); #607 os worktree start/finish stop managing the daemon. Unticketed observations: 34 pre-existing rubocop offenses in untouched os-sdlc files; daemon dir .cc-os/lsp/<hash>/ still inside the project root (per-user runtime dir would end fixture teardown races); autocorrect_prepass_test bare Dir.chdir races sibling tests, now stay_serial!.
jared closed this issue 2026-09-17 20:12:31 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#606
No description provided.