feat: AI-agent worktree lifecycle (git-native metadata, reap/lock, ports, CC plugin) - #62
Merged
Merged
Conversation
withGitRetry retries transient git ref-lock errors with full-jitter exponential backoff (5/100ms/2s); isTransientLockError classifies exit-128 lock errors while excluding permission/auth/disk-full failures. Part of AI-agent worktree lifecycle (design gap #4). SDD T1 (pi: codex impl + gemini/codex review, 1 codex fix cycle).
worktreeMetadata stores per-worktree lifecycle state (kind, createdAt, sourceBranch, owner, ttl, ports, dbName) in each worktree's own git config under pando.* keys; enumerateAll reads linked worktrees via config.worktree files; ensureWorktreeConfigEnabled safely enables extensions.worktreeConfig (migrating core.* out of shared config); assertGitVersion guards git >= 2.38. Design gap #1. SDD T2 (pi: codex impl + gemini/codex review, 1 fix cycle: --local core migration/unset + NUL-framed config/worktree parsing).
Adds worktree.{defaultKind,ephemeralTtl,autoLockActive}, reap.requireMerged,
concurrency.retry.{maxAttempts,baseMs,capMs}, and ports.* config sections with
Zod schemas, defaults, PANDO_* env mappings (incl. numeric/array/bool env
typing), .pando.toml.example docs, and full config-init generate+merge support.
Design gap #1-4/#6 foundation. SDD T3 (pi: codex impl + gemini/codex review,
1 fix cycle: init.ts merge/generate + required interface sections).
GitHelper gains lockWorktree/unlockWorktree (idempotent), getWorktreeAgeMs (metadata createdAt then dir mtime), isReapClean (fail-closed: direct status check + exact local-branch merge verification), inferOwner, and withGitRetry wrapping of mutating ops (add/remove worktree, create/delete/force branch, reset, fetch --prune). Also fixes gitRetry to actually fire: simple-git GitError exposes no exit code, so classification now recognizes real ref-lock contention by message signature while excluding permanent permission and ref D/F-conflict errors; isBranchMerged now strips the '+' (checked-out-elsewhere) marker so worktree branches reap. Design gaps #2/#3/#4. SDD T4 (pi: codex impl + gemini/codex review, 2 codex cycles + 1 guarded captain patch; cross-task fix to T1 gitRetry).
add gains --ephemeral/--long-lived/--ttl/--owner/--ports; after setup it resolves kind (flag>config>inference), writes git-native metadata, auto-locks only when an agent session is active, and exposes PANDO_KIND/PANDO_TTL to postCommands. Metadata is non-fatal (add still succeeds on failure) and --json stays a single document with lifecycle fields on success and error paths. list/health surface kind/age/ttl/owner/locked (locked parsed from porcelain). Design gap #1. SDD T5 (pi: codex impl + gemini/codex review, 1 fix cycle: JSON single-doc hygiene, error-path field parity, session-gated auto-lock, boolean PANDO_EPHEMERAL).
reap reclaims expired ephemeral worktrees (kind=ephemeral && !locked && age > effective TTL) that are safe to remove — fail-closed cleanliness (direct status check + merge verification), per-candidate lock revalidation, no-force removal, loud skip reporting, --dry-run/--force/--json/--owner, interactive confirm. lock/unlock wrap git worktree lock/unlock with realpath-canonical path/branch resolution. --json exits non-zero on partial failure. Note: reap intentionally disposes an ephemeral worktree's gitignored and skip-worktree symlink state; the safety guarantee covers committed work (design S2). Design gaps #2/#3. SDD T6 (pi: codex impl + gemini/codex review, 2 cycles).
allocate assigns the lowest free port per name within a range, treating both OS-probed (IPv4+IPv6) and peer-worktree metadata ports as taken, persisting to git-native metadata; optimistic re-enumerate+reconcile narrows the concurrent- allocation race and omits (never persists) a known duplicate on exhaustion. Service names are validated against git's config-key rule; deriveDbName collapses separators with a stable-hash fallback. Never throws on exhaustion/probe error. Design gap #6. SDD T7 (pi: codex impl + gemini/codex review, 2 cycles).
When ports are enabled (config or --ports), add allocates ports + derives a db name during lifecycle setup and injects PANDO_PORT_<NAME>/PANDO_DB_NAME into the postCommands env alongside existing PANDO_* vars; allocation is non-fatal (add succeeds with a warning on failure/exhaustion) and disabled by default (zero behavior change). ports/dbName appear in add --json on success and error paths. remove --json now includes each removed worktree's metadata for parity. Design gap #6. SDD T8 (pi: codex impl + gemini/codex review; PASS, no CRITICAL/HIGH).
Ships a repo-local Claude Code plugin (marketplace.json + plugin.json without a version so git SHA drives it): a pando-worktrees lifecycle skill, /pando-status and /pando-reap commands, and WorktreeCreate/SessionEnd hooks. worktree-create.sh delegates creation to 'pando add --ephemeral --owner --path --json' under .claude/worktrees/<name>, always emitting one absolute path and exiting 0 with a git-worktree-add fallback; session-end.sh unlocks the session's worktrees then reaps by owner. Hooks honor the verified CC contract (name-only stdin, replace git, explicit SessionEnd timeout) and degrade gracefully without pando/jq. Design gap: plugin phase. SDD T9 (pi: codex impl + gemini/codex review, 1 fix cycle).
- Per-worktree DB name now derives from the worktree's OWN branch (path basename fallback for detached), not the source branch — so worktrees branched from the same base no longer collide on PANDO_DB_NAME. - Thread concurrency.retry config through GitHelper.setRetryConfig into every withGitRetry call (add/reap/remove/clean) so the documented knob is honored. - worktreeMetadata.unsetPort clears a pando.port.<name> key when port reconciliation drops it, keeping git metadata and the returned map in sync. Found by the final whole-branch reviewer (Claude). SDD final gate (pi codex fix + codex re-review, all confirmed PASS).
There was a problem hiding this comment.
Pull request overview
This PR implements an AI-agent-oriented worktree lifecycle layer for the pando CLI, storing all lifecycle state in per-worktree Git config (pando.*) with additional commands for reaping expired ephemeral worktrees and locking/unlocking active worktrees, plus an optional bundled Claude Code plugin to delegate worktree creation and session-end cleanup.
Changes:
- Added Git-native per-worktree metadata (
worktreeMetadata) and lifecycle wiring in core commands (add,list,health,remove). - Introduced concurrency hardening via retry-with-jitter for transient Git ref-lock contention (
gitRetry) and applied it to mutating Git operations. - Added optional port isolation (
portAllocator) and a Claude Code plugin (hooks + docs + tests) for delegating worktree creation and session-end reaping/unlocking.
Reviewed changes
Copilot reviewed 44 out of 44 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/utils/worktreeMetadata.test.ts | Adds unit coverage for reading/writing/enumerating per-worktree pando.* metadata and enabling extensions.worktreeConfig. |
| test/utils/postCommands.test.ts | Verifies new lifecycle/port/db env vars injected into post-command script execution. |
| test/utils/portAllocator.test.ts | Adds unit coverage for port allocation, reconciliation, and DB name derivation. |
| test/utils/gitRetry.test.ts | Adds tests for transient lock detection and retry/backoff behavior. |
| test/utils/git.test.ts | Extends GitHelper tests for lock/unlock, retry wiring, lifecycle age computation, and reap-clean checks. |
| test/plugin/plugin.test.ts | Adds integration-style tests validating plugin manifests and hook script behaviors. |
| test/config/schema.test.ts | Verifies defaults for new lifecycle/reap/concurrency/ports config schema sections. |
| test/config/env.test.ts | Adds coverage for env parsing of new lifecycle/concurrency/ports variables and number parsing behavior. |
| test/commands/unlock.test.ts | Adds tests for the new unlock command and target resolution. |
| test/commands/remove.test.ts | Ensures remove captures lifecycle metadata before Git removes per-worktree config, and wires retry config. |
| test/commands/reap.test.ts | Adds tests for TTL parsing, candidate selection/partitioning, and reap command JSON behavior. |
| test/commands/lock.test.ts | Adds tests for the new lock command and target resolution. |
| test/commands/list.test.ts | Updates list tests for lifecycle fields in JSON and verbose output. |
| test/commands/health.test.ts | Updates health tests for lifecycle fields and locked state output. |
| test/commands/config/init.test.ts | Ensures config init/merge includes lifecycle/reap/concurrency/ports defaults and comments. |
| test/commands/clean.test.ts | Wires retry config into clean command tests. |
| test/commands/add.test.ts | Adds extensive coverage for lifecycle kind resolution, metadata setup, port allocation, and JSON shape consistency. |
| src/utils/worktreeMetadata.ts | Implements reading/writing/enumerating pando.* per-worktree config and enabling worktree config support. |
| src/utils/postCommands.ts | Injects lifecycle + port/db environment variables into post-command scripts. |
| src/utils/portAllocator.ts | Implements opt-in port allocation with reconciliation and DB name derivation. |
| src/utils/gitRetry.ts | Adds transient lock detection and full-jitter exponential backoff retry helper. |
| src/utils/git.ts | Adds lock detection, lock/unlock operations, lifecycle age calculation, reap-clean check, and applies retry to mutating ops. |
| src/config/schema.ts | Extends config schema with lifecycle defaults, reap safeguards, concurrency retry config, and ports config. |
| src/config/env.ts | Adds env var mapping + parsing for new lifecycle/reap/concurrency/ports settings (including numbers/arrays). |
| src/commands/unlock.ts | Introduces pando unlock command with path/branch resolution and JSON output. |
| src/commands/remove.ts | Captures lifecycle metadata before removal and wires retry config from config. |
| src/commands/reap.ts | Introduces pando reap command with TTL parsing, safety checks, and JSON/human output modes. |
| src/commands/lock.ts | Introduces pando lock command with optional reason and JSON output. |
| src/commands/list.ts | Adds lifecycle enrichment (kind/owner/ttl/age/locked) to JSON and verbose human output. |
| src/commands/health.ts | Adds lifecycle fields to health results (human + JSON). |
| src/commands/config/init.ts | Adds lifecycle/reap/concurrency/ports defaults to generated config and merge behavior. |
| src/commands/clean.ts | Wires concurrency retry config into GitHelper usage. |
| src/commands/add.ts | Adds lifecycle flags, resolves kind/TTL/owner, best-effort metadata setup, optional ports/db allocation, and JSON warning consistency. |
| README.md | Documents new lifecycle flags/behavior, new commands, and new env vars for post-commands. |
| plugin/skills/pando-worktrees/SKILL.md | Adds a Claude Code skill describing lifecycle conventions and JSON-first usage. |
| plugin/scripts/worktree-create.sh | Implements WorktreeCreate hook: delegates to pando add with safe fallback to git worktree. |
| plugin/scripts/session-end.sh | Implements SessionEnd hook: unlocks owned locked worktrees and runs owner-scoped pando reap. |
| plugin/README.md | Documents plugin requirements, install, configuration, and usage. |
| plugin/hooks/hooks.json | Registers WorktreeCreate and SessionEnd hooks for the plugin. |
| plugin/commands/pando-status.md | Adds a Claude Code command to summarize pando list/health --json. |
| plugin/commands/pando-reap.md | Adds a Claude Code command to preview/confirm pando reap using --json. |
| plugin/.claude-plugin/plugin.json | Adds the plugin manifest with userConfig options. |
| .pando.toml.example | Documents new lifecycle/reap/concurrency/ports config sections and env overrides. |
| .claude-plugin/marketplace.json | Adds marketplace manifest to distribute the plugin from this repo. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+62
to
+65
| if (name && value.trim() && Number.isFinite(port)) { | ||
| metadata.ports ??= {} | ||
| metadata.ports[name] = port | ||
| } |
Comment on lines
+139
to
+142
| if (worktreeConfig.autoLockActive && hasActiveSession) { | ||
| await gitHelper.lockWorktree(resolvedPath, `pando: active session ${owner}`) | ||
| result.locked = true | ||
| } |
Comment on lines
+374
to
376
| inferOwner(): string { | ||
| return process.env.CLAUDE_SESSION_ID ?? process.env.PANDO_SESSION ?? '' | ||
| } |
Comment on lines
+32
to
+35
| const [metadata, ageMs] = await Promise.all([ | ||
| readMetadata(worktree.path), | ||
| gitHelper.getWorktreeAgeMs(worktree.path), | ||
| ]) |
Comment on lines
+97
to
+100
| const [metadata, ageMs] = await Promise.all([ | ||
| readMetadata(worktree.path), | ||
| gitHelper.getWorktreeAgeMs(worktree.path), | ||
| ]) |
- parseMetadata: use a null-prototype ports map and validate port names against git's config-key rule, so keys like __proto__/constructor cannot pollute or land. - add: wrap auto-lock in its own try/catch so a lock failure no longer skips port allocation/DB naming; drop trailing space in the lock reason when owner is empty. - inferOwner: use trimmed values with || so an empty CLAUDE_SESSION_ID falls back to PANDO_SESSION. - list/health: pass the already-read metadata into getWorktreeAgeMs to avoid a second git-config read per worktree.
ensureWorktreeConfigEnabled migrated worktree-specific core.* keys into config.worktree, assuming git.raw throws for absent keys. simple-git instead resolves an absent 'config --get' to '', so an unset core.worktree was written back as 'git config --worktree core.worktree ""'. With extensions.worktreeConfig enabled, git then reads an empty core.worktree for the main worktree and fails every command with 'fatal: cannot chdir to ""', surfacing in CI as 'Not a git repository'. This bricked the shared repo after the first 'pando add', cascading into 53 Docker E2E failures across add/list/remove/symlink/health/clean. Only migrate keys that hold a real value. Add a regression test covering simple-git's empty-string-for-absent-key behavior.
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.
Summary
Implements the AI-agent worktree lifecycle design — a stateful lifecycle layer for pando worktrees with no external state file. All state lives in each worktree's own
git config(pando.*namespace), which git cleans up ongit worktree remove. Core stays tool-agnostic (usable from a plain terminal); a bundled Claude Code plugin is a convenience layer over the CLI.Built as 9 tasks across the design's phases (foundation → reaping/locking → ports → plugin), plus a whole-branch-review fix pass.
What's new
worktreeMetadata): per-worktreepando.*keys (kind, createdAt, sourceBranch, owner, ttl, ports, dbName); safe auto-enable ofextensions.worktreeConfig(migratescore.*); git ≥ 2.38 guard.ephemeralorlong-lived(flag >worktree.defaultKind> inference);pando addgains--ephemeral/--long-lived/--ttl/--owner/--ports;list/healthsurface kind/age/ttl/owner/locked.pando reap: age/kind-based reclamation of expired ephemeral worktrees — fail-closed (never removes uncommitted/unmerged/locked/long-lived/main);--dry-run/--force/--json/--owner.pando lock/pando unlock+ auto-lock while an agent session is active.gitRetry): mutating git ops retry transient ref-lock contention with full-jitter backoff (tunable viaconcurrency.retry.*).portAllocator): per-worktree ports + derived DB name, exposed topostCommandsasPANDO_PORT_<NAME>/PANDO_DB_NAME(off by default).plugin/+.claude-plugin/): lifecycle skill,/pando-status+/pando-reapcommands, andWorktreeCreate/SessionEndhooks that delegate to the CLI with a safe git fallback.Notable design boundaries (intentional)
skip-worktreesymlink state by design.Test plan
pnpm validate(format + lint + typecheck + 816 unit tests) greenpnpm build(dist) green; built binary exposes new commands/flagspnpm test:e2e --hookTimeout=60000🤖 Implemented via subagent-driven-development (pi agents: codex implementers, gemini + codex adversarial review per task, whole-branch review by Claude).