Skip to content

Commit 80a8dd2

Browse files
committed
fix(runtime): isolate detached worktrees and recover orphan groups
1 parent 8f7f86f commit 80a8dd2

18 files changed

Lines changed: 272 additions & 20 deletions

.codex/skills/hack-cli/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Use `hack` as the primary interface for local-first development.
7070
## Linked git worktrees
7171

7272
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
73-
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
73+
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
7474
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
7575

7676
## Advanced networking (extra_hosts + local proxies/tunnels)

.cursor/rules/hack.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
3434
## Linked git worktrees
3535

3636
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
37-
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
37+
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
3838
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
3939

4040
## Standard workflow

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ Project files (managed vs generated):
261261

262262
Linked git worktrees:
263263
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
264-
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
264+
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
265265
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
266266

267267
Advanced networking (extra_hosts + local proxies/tunnels):

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Project files (managed vs generated):
131131

132132
Linked git worktrees:
133133
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
134-
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
134+
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
135135
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
136136

137137
Advanced networking (extra_hosts + local proxies/tunnels):

docs/architecture.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ Projects can run host-side hooks around `hack up/down` and start managed host pr
122122
local proxies/tunnels). Processes are started inside a mux session (tmux or zellij) so they have a
123123
stable home and can be torn down on `hack down`.
124124

125+
Lifecycle state is checkout-local at `.hack/.internal/lifecycle/state.json`. Each compose instance
126+
has its own entry, so starting a branch instance does not replace the base instance's entry in the
127+
same checkout. Cleanup reconciles the saved process group with the live process table; if the group
128+
leader exited while members remain, `hack doctor` reports the orphan and `hack down` terminates the
129+
persisted leaderless group. A live group leader without its saved pane PID is not trusted as lifecycle
130+
ownership because the PID may have been reused.
131+
125132
For fixed-port helpers such as SSM/database/search tunnels, lifecycle config can also declare a
126133
`singleton` listener set. This lets Hack reuse an already-running equivalent helper or fail fast on
127134
partial conflicts instead of launching a competing duplicate supervisor. The intent is to reduce local
@@ -267,6 +274,10 @@ the primary checkout through the shared git common dir (see "Project env + secre
267274
`hack doctor` checks for divergent secret keys and `dev_host` collisions across checkouts sharing the
268275
same repo.
269276

277+
If the linked worktree is detached, Hack refuses to silently target the base compose project because
278+
there is no branch name from which to derive an isolated instance. Pass `--branch <name>` to select
279+
one, or set `worktree.auto_branch=false` to opt into the base instance explicitly.
280+
270281
## Files and directories
271282

272283
- `~/.hack/` (default; override the whole root with `HACK_HOME`)

docs/core.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ alive. You do not need gateway, remote nodes, or extension setup to use this pat
3131
`HACK_NO_INTERACTIVE=1`) so scripted/agent runs never block on prompts.
3232
- In a linked git worktree, `hack up` defaults to a branch instance named after the worktree's git
3333
branch (`worktree.auto_branch`), and the secret key is inherited automatically from the primary
34-
checkout via the shared git common dir.
34+
checkout via the shared git common dir. A detached linked worktree has no branch name to derive;
35+
pass `--branch <name>`, or set `worktree.auto_branch=false` when intentionally targeting the base
36+
instance.
3537

3638
## When to leave core
3739

src/agents/instruction-source.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [
122122
surfaces: ALL_SURFACES,
123123
bullets: [
124124
"Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.",
125-
"`hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.",
125+
"`hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.",
126126
"`hack doctor` flags divergent secret keys and dev_host collisions across checkouts.",
127127
],
128128
},

src/commands/doctor.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,7 +1845,10 @@ async function checkProjectLifecycleHygiene({
18451845
const inspection = await inspectProjectLifecycleHygiene({
18461846
projectDir: ctx.projectDir,
18471847
});
1848-
if (inspection.staleEntries.length === 0) {
1848+
if (
1849+
inspection.staleEntries.length === 0 &&
1850+
inspection.orphanedProcessGroups.length === 0
1851+
) {
18491852
return {
18501853
name: "lifecycle hygiene",
18511854
status: "ok",
@@ -1854,15 +1857,19 @@ async function checkProjectLifecycleHygiene({
18541857
};
18551858
}
18561859

1857-
const orphanedGroups = new Set<number>();
1860+
const orphanedGroups = new Set<number>(inspection.orphanedProcessGroups);
18581861
for (const entry of inspection.staleEntries) {
18591862
for (const group of entry.liveProcessGroups) {
18601863
orphanedGroups.add(group);
18611864
}
18621865
}
18631866

18641867
const details = [
1865-
`${inspection.staleEntries.length} stale lifecycle state entr${inspection.staleEntries.length === 1 ? "y" : "ies"}`,
1868+
...(inspection.staleEntries.length > 0
1869+
? [
1870+
`${inspection.staleEntries.length} stale lifecycle state entr${inspection.staleEntries.length === 1 ? "y" : "ies"}`,
1871+
]
1872+
: []),
18661873
...(orphanedGroups.size > 0
18671874
? [
18681875
`${orphanedGroups.size} orphaned lifecycle process group${orphanedGroups.size === 1 ? "" : "s"}`,

src/commands/project.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,11 @@ async function resolveEffectiveBranchForCommand(opts: {
594594
projectRoot: opts.project.projectRoot,
595595
autoBranchEnabled: resolveWorktreeAutoBranch(cfg),
596596
});
597+
if (resolved.source === "detached-worktree") {
598+
throw new CliUsageError(
599+
"Detached linked worktree cannot auto-select an isolated instance. Pass --branch <name>, or set worktree.auto_branch=false to target the base instance explicitly."
600+
);
601+
}
597602
if (resolved.source === "worktree" && resolved.branch) {
598603
const message = `Linked worktree detected → using branch instance "${resolved.branch}" (override with --branch <name>, disable with worktree.auto_branch=false)`;
599604
if (opts.noticeToStderr === true) {

src/lib/branches.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,11 @@ export async function touchBranchUsage(opts: {
160160
return { updated: true, created, path: read.path };
161161
}
162162

163-
export type EffectiveBranchSource = "explicit" | "worktree" | "none";
163+
export type EffectiveBranchSource =
164+
| "explicit"
165+
| "worktree"
166+
| "detached-worktree"
167+
| "none";
164168

165169
export type EffectiveBranchResolution = {
166170
/** Branch instance slug to target, or null for the base instance. */
@@ -178,8 +182,10 @@ export type EffectiveBranchResolution = {
178182
* - otherwise, in a LINKED git worktree with `autoBranchEnabled` (config
179183
* `worktree.auto_branch`, default true), default to the sanitized current
180184
* git branch name (`source: "worktree"`)
181-
* - primary checkouts, non-git paths, detached HEAD, and opted-out projects
182-
* resolve to the base instance (`branch: null`, `source: "none"`)
185+
* - a detached linked worktree is reported separately so callers can refuse
186+
* an implicit base instance instead of colliding with the primary checkout
187+
* - primary checkouts, non-git paths, and opted-out projects resolve to the
188+
* base instance (`branch: null`, `source: "none"`)
183189
*/
184190
export async function resolveEffectiveBranch(opts: {
185191
readonly explicitBranch: string | null;
@@ -206,7 +212,11 @@ export async function resolveEffectiveBranch(opts: {
206212
repoRoot: opts.projectRoot,
207213
});
208214
if (!gitBranch) {
209-
return { branch: null, source: "none", gitBranch: null };
215+
return {
216+
branch: null,
217+
source: "detached-worktree",
218+
gitBranch: null,
219+
};
210220
}
211221

212222
const slug = sanitizeBranchSlug(gitBranch);

0 commit comments

Comments
 (0)