Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .codex/skills/hack-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ Use `hack` as the primary interface for local-first development.
- For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks.
- `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`.
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
- Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof.
- `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified.

## Workspaces (mux-managed, tmux-first by default)

Expand Down
2 changes: 2 additions & 0 deletions .cursor/rules/hack.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
- For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks.
- `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`.
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
- Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof.
- `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified.

## Host-side env helpers

Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ jobs:
- name: Smoke slim runtime mounted-project env flow
run: bash scripts/portable-container-smoke.sh hack-runtime-ci:slim linux/amd64

docker-e2e:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.3.9"
- name: Install tmux
run: sudo apt-get update && sudo apt-get install --yes tmux
- name: Install dependencies
run: bun install
- name: Create isolated Hack network
run: docker network create --subnet 172.30.0.0/16 hack-dev
- name: Run local and Docker E2E
run: HACK_E2E_REQUIRE_DOCKER=1 HACK_E2E_REQUIRE_TMUX=1 bun run test:e2e:local:docker

test:
runs-on: blacksmith-6vcpu-macos-15
steps:
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ Lifecycle + startup:
- For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks.
- `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`.
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
- Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof.
- `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified.

Workspaces (mux-managed, tmux-first by default):
- Picker: `hack session` for persistent project workspaces.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ Lifecycle + startup:
- For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks.
- `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`.
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
- Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof.
- `hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified.

Workspaces (mux-managed, tmux-first by default):
- Picker: `hack session` for persistent project workspaces.
Expand Down
9 changes: 8 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,20 @@ stable home and can be torn down on `hack down`.
Lifecycle state is checkout-local at `.hack/.internal/lifecycle/state.json`. For tmux processes, the
saved process group belongs to the wrapped user command rather than its mux pane shell. Each compose
instance has its own entry, so starting a branch instance does not replace the base instance's entry
in the same checkout. Cleanup reconciles the saved process group with the live process table; if the group
in the same checkout. A random token mirrored in mux metadata and lifecycle state proves current
session ownership; deterministic session names never authorize teardown. A versioned SHA-256 digest
of the effective overlay and sorted lifecycle environment participates in session freshness without
persisting environment names, keys, or values. Cleanup reconciles the saved process group with the live process table; if the group
leader exited while members remain, `hack doctor` reports the orphan. `hack down` terminates the
persisted leaderless group and descendant process groups only while a matching lifecycle mux session
still proves ownership; without that session, cleanup stays non-destructive because the numeric PGID
may have been reused. A live group leader without its saved pane PID is likewise not trusted as
lifecycle ownership.

Doctor also compares ownership-proven lifecycle sessions with live Compose instances. `doctor --fix`
can remove a proven session only when its corresponding instance is absent and a second liveness and
ownership check still agrees. Foreign or ambiguous same-name sessions remain non-destructive findings.

For fixed-port helpers such as SSM/database/search tunnels, lifecycle config can also declare a
`singleton` listener set. This lets Hack reuse an already-running equivalent helper or fail fast on
partial conflicts instead of launching a competing duplicate supervisor. The intent is to reduce local
Expand Down
30 changes: 29 additions & 1 deletion docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,16 +179,35 @@ Lifecycle output is now surfaced across CLI/runtime views:
5. Run `docker compose up` (or `up -d` when `--detach`).
6. Run `lifecycle.up.after` hooks.

Before starting persistent work, Hack reconciles the expected lifecycle session with
`.hack/.internal/lifecycle/state.json`:

- A session whose mux ownership token, saved definition hash, and live process windows all match is
adopted without starting duplicate processes.
- A token-owned stale session is replaced. Pre-ownership tmux sessions are replaced only when saved
state, checkout path, creation time, and window count provide deterministic legacy ownership proof.
- A same-name session without matching ownership proof is left untouched and `hack up` fails with an
actionable collision error.

If the operation later fails in Compose or `up.after`, Hack removes only the lifecycle session created
by that operation. The same exact-token cleanup runs for supported `SIGINT` and `SIGTERM` paths, including
signals received while the mux session is still being initialized.

### `hack down`

1. Run `lifecycle.down.before` hooks.
2. Run `docker compose down`.
3. Stop lifecycle processes by killing the lifecycle session.
4. Run `lifecycle.down.after` hooks.

If `lifecycle.down.before` fails, shutdown is aborted before Compose or lifecycle processes are
stopped. `hack restart` preserves the same guard semantics during its down phase.

### `hack restart`

`hack restart` performs the same lifecycle steps as `hack down` followed by `hack up`.
From the primary checkout, it targets only the base Compose/lifecycle instance. A linked worktree uses
its isolated derived branch instance, and `--branch <name>` targets only that explicit branch.

### `--json`

Expand All @@ -212,10 +231,19 @@ Lifecycle session name:
Notes:
- If no mux backend is available, lifecycle process startup fails with an actionable error.
- Teardown is implemented by killing the lifecycle session; anything running inside that session will be stopped.
- Current lifecycle sessions carry the same random ownership token in mux metadata and persisted state.
Cleanup requires an exact token match; deterministic names alone never authorize session teardown.
- Healthy-session adoption also matches a stable SHA-256 fingerprint of the effective overlay and
sorted lifecycle environment. Environment names, keys, and values are never persisted; changing an
overlay or value replaces the owned session so host helpers cannot retain stale configuration.
- For tmux-backed lifecycle sessions, Hack also persists the pane PID and the wrapped command's actual
process-group metadata to `.hack/.internal/lifecycle/state.json`, with per-hook/process output logged to
`.hack/.internal/lifecycle/*.log`. If tmux pane state disappears before teardown, `hack down` still uses that persisted metadata to clean up any live lifecycle process groups instead of leaving orphaned host processes behind.
- `hack doctor` reports stale lifecycle state when the persisted lifecycle entry no longer has a live mux session and points operators to `hack down` so cleanup and state removal happen through the supported path. `hack doctor --fix` does not tear down lifecycle sessions itself — use `hack down` for that.
- `hack doctor` reports stale lifecycle state, leaderless process groups, ownership collisions, and
ownership-proven sessions with no running Compose instance. Recently updated sessions are treated
as possible in-flight startups for five minutes and stay untouched. `hack doctor --fix` reaps only
established orphans after rechecking runtime liveness and mux ownership; unverified same-name
sessions stay untouched.

## Tips

Expand Down
2 changes: 2 additions & 0 deletions src/agents/instruction-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [
'For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks.',
"`singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`.",
"Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.",
"Lifecycle session recovery is ownership-proven: Hack adopts healthy token-, definition-, and environment-matched sessions, replaces owned stale sessions, and refuses to kill same-name sessions without deterministic ownership proof.",
"`hack doctor --fix` reaps an orphan lifecycle session only when mux ownership is proven and its Compose instance is absent; unverified same-name sessions are never modified.",
],
},
{
Expand Down
66 changes: 63 additions & 3 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ import {
repairLegacyComposeEnvFileReferences,
resolveProjectEnvConfig,
} from "../lib/project-env-config.ts";
import { inspectProjectLifecycleHygiene } from "../lib/project-lifecycle-hygiene.ts";
import {
inspectProjectLifecycleHygiene,
repairProjectLifecycleSessions,
} from "../lib/project-lifecycle-hygiene.ts";
import {
findMissingRegistryEntries,
findOrphanRuntimeProjects,
Expand Down Expand Up @@ -1844,10 +1847,13 @@ async function checkProjectLifecycleHygiene({

const inspection = await inspectProjectLifecycleHygiene({
projectDir: ctx.projectDir,
projectRoot: ctx.projectRoot,
});
if (
inspection.staleEntries.length === 0 &&
inspection.orphanedProcessGroups.length === 0
inspection.orphanedProcessGroups.length === 0 &&
inspection.orphanedSessions.length === 0 &&
inspection.unverifiedSessions.length === 0
) {
return {
name: "lifecycle hygiene",
Expand Down Expand Up @@ -1875,12 +1881,29 @@ async function checkProjectLifecycleHygiene({
`${orphanedGroups.size} orphaned lifecycle process group${orphanedGroups.size === 1 ? "" : "s"}`,
]
: []),
...(inspection.orphanedSessions.length > 0
? [
`${inspection.orphanedSessions.length} owned lifecycle session${inspection.orphanedSessions.length === 1 ? "" : "s"} without a running instance`,
]
: []),
...(inspection.unverifiedSessions.length > 0
? [
`${inspection.unverifiedSessions.length} same-name lifecycle session collision${inspection.unverifiedSessions.length === 1 ? "" : "s"} with unverified ownership`,
]
: []),
];

let nextStep = "run: hack down";
if (inspection.orphanedSessions.length > 0) {
nextStep = "run: hack doctor --fix";
} else if (inspection.unverifiedSessions.length > 0) {
nextStep = "manual review required; no session was modified";
}

return {
name: "lifecycle hygiene",
status: "warn",
message: `${details.join("; ")} (run: hack down)`,
message: `${details.join("; ")} (${nextStep})`,
};
}

Expand Down Expand Up @@ -2403,6 +2426,7 @@ async function runDoctorFix(opts: {
return;
}

await maybeRepairProjectLifecycleSessions({ startDir: opts.startDir });
await maybeRepairHackd();

const paths = getGlobalPaths();
Expand Down Expand Up @@ -2680,6 +2704,11 @@ export async function buildDoctorRemediationPlanLines(opts: {
];

const project = await findProjectContext(opts.startDir);
if (project) {
steps.push(
"Reconcile lifecycle sessions and remove only ownership-proven sessions whose Compose instance is absent."
);
}
const trackedGenerated = project
? await inspectTrackedGeneratedFiles({
projectRoot: project.projectRoot,
Expand Down Expand Up @@ -2897,6 +2926,37 @@ function describeLegacyRepairStatus(input: {
return input.didPruneLegacy ? "pruned" : "prune failed";
}

async function maybeRepairProjectLifecycleSessions(opts: {
readonly startDir: string;
}): Promise<void> {
const project = await findProjectContext(opts.startDir);
if (!project) {
return;
}
const repaired = await repairProjectLifecycleSessions({
projectDir: project.projectDir,
projectRoot: project.projectRoot,
});
if (repaired.repairedSessions.length > 0) {
note(
[
`Removed ${repaired.repairedSessions.length} ownership-proven orphan lifecycle session${repaired.repairedSessions.length === 1 ? "" : "s"}:`,
...repaired.repairedSessions.map((sessionName) => `- ${sessionName}`),
].join("\n"),
"lifecycle repair"
);
}
if (repaired.failures.length > 0) {
note(
[
"Lifecycle sessions left unchanged because safe repair could not be re-proven:",
...repaired.failures.map((failure) => `- ${failure}`),
].join("\n"),
"lifecycle repair"
);
}
}

async function maybeInstallMutagenForDoctorFix(): Promise<void> {
if (getMutagenPath()) {
return;
Expand Down
Loading
Loading