Skip to content

Harden specialist background lifecycle#116

Merged
anandh8x merged 2 commits into
mainfrom
feat/specialist-lifecycle-cleanup
Jun 7, 2026
Merged

Harden specialist background lifecycle#116
anandh8x merged 2 commits into
mainfrom
feat/specialist-lifecycle-cleanup

Conversation

@anandh8x

@anandh8x anandh8x commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Summary:

  • adds a specialist Runtime owner for the lazy background manager
  • closes running specialist background tasks when the CLI/TUI runtime exits
  • tracks background prompt temp files and cleans them on child exit or runtime close
  • adds background.Manager KillRunning for shutdown cleanup

Self-review:

  • Runtime.Close does not create background storage if no background task was used
  • cleanup is wired into both interactive TUI and headless exec registration paths
  • this intentionally does not implement hook command execution; the hooks package currently only supports selection/audit primitives

Tests:

  • GOCACHE=/tmp/zero-go-cache go test ./internal/background ./internal/specialist ./internal/cli
  • GOCACHE=/tmp/zero-go-cache go test ./...
  • git diff --check

Summary by CodeRabbit

  • New Features

    • Runtime abstraction added to manage specialist-tool lifecycles and track temporary prompt files.
  • Improvements

    • Background task handling and shutdown improved to ensure running tasks are stopped and resources cleaned.
    • Specialist tool registration now provides a runtime handle so background resources are reliably closed.
  • Tests

    • Added tests covering task exit transitions, bulk-kill behavior, runtime close semantics, and prompt-file cleanup.

@anandh8x
anandh8x requested review from Vasanthdev2004 and gnanam1990 and removed request for Vasanthdev2004 June 7, 2026 07:53
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 56dbdc6de8ae
Changed files (8): internal/background/manager.go, internal/background/manager_test.go, internal/cli/app.go, internal/cli/exec.go, internal/specialist/exec.go, internal/specialist/registry.go, internal/specialist/runtime.go, internal/specialist/runtime_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 155c869d-937d-4ea0-b5f7-15c79aad6826

📥 Commits

Reviewing files that changed from the base of the PR and between 9355fdf and 56dbdc6.

📒 Files selected for processing (5)
  • internal/background/manager.go
  • internal/background/manager_test.go
  • internal/specialist/exec.go
  • internal/specialist/registry.go
  • internal/specialist/runtime_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/specialist/registry.go
  • internal/specialist/exec.go

Walkthrough

Adds a specialist.Runtime for lazy background-manager init and per-task prompt-file tracking; implements Manager.MarkExited and Manager.KillRunning; wires the runtime into tool registration and Executor; ensures CLI entry points defer runtime.Close() to kill running tasks and remove prompt files.

Changes

Specialist Runtime Lifecycle

Layer / File(s) Summary
CLI entry-point resource management
internal/cli/app.go, internal/cli/exec.go
runInteractiveTUI and runExec capture *specialist.Runtime from registration and defer closeSpecialistRuntime(); registerSpecialistTools now returns (*specialist.Runtime, error) and closeSpecialistRuntime() logs Close() failures.
Runtime abstraction, prompt-file tracking, and Close()
internal/specialist/runtime.go, internal/specialist/runtime_test.go
New Runtime with Manager() (lazy, mutex-protected), TrackPromptFile()/UntrackPromptFile(), and Close() which calls KillRunning() and cleans tracked prompt files. Tests cover non-creation of manager, killing running tasks, file cleanup, and preserving killed state after late child exit.
Tool registration and Runtime initialization
internal/specialist/registry.go
RegisterTools now returns (*Runtime, error); it ensures executor.BackgroundRuntime is initialized via NewRuntime, assigns executor.BackgroundManagerFunc = runtime.Manager, and registers output/stop tools using runtime.Manager.
Executor background execution & prompt-file lifecycle
internal/specialist/exec.go
Executor gains BackgroundRuntime *Runtime. Background launches track/untrack prompt files via runtime helpers, and backgroundManager() prefers executor.BackgroundRuntime.Manager() when present.
Background task exit semantics and bulk kill
internal/background/manager.go, internal/background/manager_test.go
Added Manager.MarkExited(taskID, status, exitCode) to transition running tasks to terminal states safely, and Manager.KillRunning() to iterate running tasks and kill them (aggregating errors). Tests verify kill behavior and that MarkExited doesn't clobber already-killed tasks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Gitlawb/zero#108: Related changes to background Manager APIs (List/Kill) that form groundwork for KillRunning/MarkExited.
  • Gitlawb/zero#111: Overlaps with specialist Executor and prompt/building plumbing that this PR extends.
  • Gitlawb/zero#113: Prior work on specialist tools registration and CLI wiring that this PR modifies.

Suggested reviewers

  • gnanam1990
  • Vasanthdev2004
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 "Harden specialist background lifecycle" directly aligns with the PR's primary objective of improving specialist runtime shutdown cleanup, task termination, and resource management.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/specialist-lifecycle-cleanup

Comment @coderabbitai help to get the list of available commands and usage tips.

@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.

🧹 Nitpick comments (1)
internal/specialist/registry.go (1)

5-19: 💤 Low value

Pass-by-value executor: local mutations have no effect.

RegisterTools receives Executor by value (line 5), so assignments on lines 13–14 modify only the local copy and are discarded when the function returns. If the intent is to update the caller's executor, change the signature to executor *Executor. If the intent is to treat Executor as immutable config (which matches current usage), remove lines 13–14 to avoid confusion.

In practice this is harmless because the CLI always passes a fresh Executor, but the dead assignments are misleading.

♻️ Option 1: Remove dead assignments (preferred if Executor is immutable)
 func RegisterTools(registry *tools.Registry, executor Executor) (*Runtime, error) {
 	runtime := executor.BackgroundRuntime
 	if runtime == nil {
 		runtime = NewRuntime(RuntimeOptions{
 			Manager:     executor.BackgroundManager,
 			ManagerFunc: executor.BackgroundManagerFunc,
 		})
 	}
-	executor.BackgroundRuntime = runtime
-	executor.BackgroundManagerFunc = runtime.Manager

 	registry.Register(NewTaskTool(executor))
♻️ Option 2: Change to pointer receiver (if mutation intended)
-func RegisterTools(registry *tools.Registry, executor Executor) (*Runtime, error) {
+func RegisterTools(registry *tools.Registry, executor *Executor) (*Runtime, error) {
 	runtime := executor.BackgroundRuntime

(Would also require updating call sites.)

🤖 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 `@internal/specialist/registry.go` around lines 5 - 19, RegisterTools currently
takes Executor by value so the assignments to executor.BackgroundRuntime and
executor.BackgroundManagerFunc inside RegisterTools (in function RegisterTools)
modify only the local copy and have no effect; either remove those dead
assignments (delete the lines that set executor.BackgroundRuntime and
executor.BackgroundManagerFunc) if Executor is intended to be immutable, or
change the function signature to accept a pointer (*Executor) and update all
call sites so the mutations to BackgroundRuntime and BackgroundManagerFunc
persist — adjust NewRuntime usage to set runtime on the caller's Executor when
choosing the pointer option.
🤖 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.

Nitpick comments:
In `@internal/specialist/registry.go`:
- Around line 5-19: RegisterTools currently takes Executor by value so the
assignments to executor.BackgroundRuntime and executor.BackgroundManagerFunc
inside RegisterTools (in function RegisterTools) modify only the local copy and
have no effect; either remove those dead assignments (delete the lines that set
executor.BackgroundRuntime and executor.BackgroundManagerFunc) if Executor is
intended to be immutable, or change the function signature to accept a pointer
(*Executor) and update all call sites so the mutations to BackgroundRuntime and
BackgroundManagerFunc persist — adjust NewRuntime usage to set runtime on the
caller's Executor when choosing the pointer option.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c18c297a-0191-41c5-834f-178374a308b6

📥 Commits

Reviewing files that changed from the base of the PR and between 936bbea and 9355fdf.

📒 Files selected for processing (8)
  • internal/background/manager.go
  • internal/background/manager_test.go
  • internal/cli/app.go
  • internal/cli/exec.go
  • internal/specialist/exec.go
  • internal/specialist/registry.go
  • internal/specialist/runtime.go
  • internal/specialist/runtime_test.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blockers

  • internal/specialist/exec.go:314 / internal/specialist/runtime.go:91: shutdown cleanup kills running background tasks, but the child wait callback can still overwrite the terminal killed state with error after the stopped process exits non-zero. That leaves TaskOutput reporting a runtime-close/TaskStop cleanup as an error instead of a deliberate kill. I reproduced this on the PR head with a focused runtime-close probe: Runtime.Close() marked the task killed, then the simulated child exit callback changed it to error.

Non-Blocking

  • internal/specialist/output_tool.go: still reads the full task output file on every poll. A tail/limit can come later, but it would make long-running background tasks safer to inspect.

Looks Good

  • Runtime ownership keeps the background manager lazy; Runtime.Close() does not create storage when no background task was used.
  • Interactive and headless exec paths now defer specialist runtime cleanup, and prompt temp files are tracked for background children.
  • Local validation passed: go test -count=1 ./internal/background ./internal/specialist ./internal/cli, full go test -count=1 -p 1 ./..., go build ./cmd/zero, zero exec --list-tools --auto high, and git diff --check.
  • CI is green at the reviewed head.

Verdict: Changes Requested — preserve the explicit killed terminal state after runtime shutdown/TaskStop before shipping this lifecycle cleanup.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — this resolves the #115 lifecycle gap, approving

Exactly the fix I asked for on #115, and cleanly done:

  • Orphans on shutdownRuntime.Close()manager.KillRunning() terminates still-running background tasks (reusing #108's TOCTOU-safe Kill, and KillRunning only touches StatusRunning tasks).
  • Prompt-file leak — prompt files are tracked on launch, untracked+cleaned on child exit (normal/error paths), and cleaned by Close() if the runtime exits while a child is still running. Close snapshots+clears the map before KillRunning, so a killed child's onExit can't double-clean. No leak either way.
  • Shared-manager correctness preservedRegisterTools creates one Runtime and wires Task (via the executor) and Output/Stop (via runtime.Manager) to the same instance.
  • LazyManager()/Close() never create background storage if no task used it.
  • Wired via defer closeSpecialistRuntime(...) on both the interactive TUI and headless exec paths — so it fires on normal return and on Bubble Tea's Ctrl-C (which returns from p.Run).
  • Tests pin it: TestRuntimeCloseKillsRunningTasksAndCleansPromptFiles (asserts StatusKilled + prompt file removed) and TestManagerKillRunningStopsOnlyRunningTasks. go test ./..., -race, and GOOS=windows build green.

Approving. 👍

Non-blocking observation

A headless run that receives an uncaught SIGINT (e.g. zero exec with a background task, terminal Ctrl-C) won't run the defer (Go doesn't run defers on uncaught signals), so Close is skipped there. In practice the background child shares the process group and dies with the parent anyway, so the only residual is a prompt-temp-file leak on hard SIGINT. A signal handler that calls Close would close that last gap, but it's a minor edge — fine to leave or fold into a later pass.

🤖 Verified re-review via Claude Code (vet/-race/windows/full-suite run locally).

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correcting my earlier approve — @Vasanthdev2004's race is real

Good catch by @Vasanthdev2004, and I have to walk back my approve: I traced the Close/KillRunning/prompt-file paths carefully but missed the kill-vs-onExit status race. Confirmed on the head:

background.UpdateStatus sets task.Status unconditionally (no terminal-state guard). The background child's wait goroutine (launchBackgroundProcessonExit) calls manager.UpdateStatus(taskID, StatusError, exitCode) when the process exits non-zero. So on shutdown:

  1. Runtime.Close()KillRunning()Kill sets the task to killed.
  2. The killed process exits non-zero → the still-running onExit fires → UpdateStatus(StatusError) overwrites killederror.

So TaskOutput/status reports a deliberate runtime-close/TaskStop as a failure. Same class as the #108 Kill TOCTOU, but on the onExit side.

Suggested fix

Make onExit not clobber a terminal state — e.g. a UpdateStatusIfRunning that only transitions when the current status is still StatusRunning (mirrors markKilledIfStillRunning). Then a kill sets killed, and the subsequent non-zero onExit is a no-op. A focused test: register → Kill/KillRunning → invoke the exit callback with a non-zero code → assert status stays killed.

The rest of the PR is solid (the lifecycle cleanup itself, prompt-file tracking, lazy runtime, defer wiring) — this is the one correctness blocker to close.

Non-blocking (also noted by @Vasanthdev2004)

output_tool.go still os.ReadFiles the whole output on every poll — a tail/limit can come later.

Apologies for the premature approve — requesting changes to reflect the real blocker.

🤖 Verified review via Claude Code.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — race fixed, approving

Preserve killed background task state (56dbdc6) closes the kill-vs-onExit race cleanly:

  • New MarkExited(taskID, status, exitCode) only transitions from StatusRunning (if task.Status != StatusRunning { return nil }) and only accepts Completed/Error. onExit now calls it instead of the unconditional UpdateStatus.
  • So a Kill/KillRunning sets killed, and the killed child's subsequent non-zero exit is a no-opkilled (ExitCode -1) is preserved, and TaskOutput no longer misreports a deliberate stop as an error.
  • Normal completion still works (Running → Completed/Error via MarkExited).
  • Tests pin it: TestManagerMarkExitedDoesNotClobberKilledTask and TestRuntimeClosePreservesKilledTaskAfterChildExit (Close kills → child-exit callback → status stays killed). go test ./..., -race, and GOOS=windows build green.

That resolves the blocker @Vasanthdev2004 caught (and that I missed on my first pass). Combined with the lifecycle cleanup itself, this is a complete, well-tested fix for the #115 background-lifecycle gap. Approving. 👍

The earlier non-blocking notes stand for a later pass (headless uncaught-SIGINT bypasses the defer; TaskOutput reads the full file per poll).

🤖 Verified re-review via Claude Code (vet/-race/windows/full-suite run locally).

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Blockers

None found.

Non-Blocking

  • internal/specialist/output_tool.go: background output polling still reads the full output file each time. Not introduced by the fix, but a future tail/limit would make chatty long-running specialist tasks safer to inspect.

Looks Good

  • The prior lifecycle blocker is fixed: background child exits now call Manager.MarkExited, which only transitions running tasks and preserves an explicit killed terminal state after Runtime.Close, KillRunning, or TaskStop.
  • Regression coverage now exercises both the manager-level killed-state preservation and the real specialist runtime close + child exit callback path.
  • PR-head validation passed: focused regression tests, targeted background/specialist/CLI tests, full go test -count=1 -p 1 ./..., go build ./cmd/zero, specialist CLI smokes, Task/TaskOutput/TaskStop tool listing, and git diff --check.
  • Latest-main test merge from current origin/main was clean and passed the same targeted/full Go validation, build, tool-listing smoke, and diff check.

Verdict: Approve — clean lifecycle hardening; the prior killed-status overwrite is resolved.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved after rereview: prior killed-status overwrite blocker is fixed and validation passed on PR head plus latest-main test merge.

@anandh8x
anandh8x merged commit 27f0fe8 into main Jun 7, 2026
6 checks passed
@Vasanthdev2004
Vasanthdev2004 deleted the feat/specialist-lifecycle-cleanup branch June 28, 2026 08:27
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.

3 participants