Skip to content

Commit 69f6caa

Browse files
authored
Merge pull request #60 from hack-dance/fix/field-findings-round3
fix: field findings round 3 — daemon lifecycle, non-interactive trust/fix, tickets gating
2 parents 143a857 + 103575a commit 69f6caa

22 files changed

Lines changed: 1372 additions & 59 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Use `hack` as the primary interface for local-first development.
6565
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
6666
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
6767
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
68-
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
68+
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
6969

7070
## Linked git worktrees
7171

.cursor/rules/hack.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
2929
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
3030
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
3131
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
32-
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
32+
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
3333

3434
## Linked git worktrees
3535

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ Project files (managed vs generated):
257257
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
258258
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
259259
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
260-
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
260+
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
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.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ Project files (managed vs generated):
127127
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
128128
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
129129
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
130-
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
130+
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
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.

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ same repo.
292292
- `hack.config.json`
293293
- `hack.branches.json` (optional)
294294
- `.gitignore` (committed, self-healing on `init`/`up`; covers machine-local generated files —
295-
`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`. If generated files
295+
`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`. If generated files
296296
leaked into git, `hack doctor --fix` untracks them without deleting them from disk.)
297297
- `hack.env.default.yaml` plus optional `hack.env.<overlay>.yaml` (committed env)
298298
- `hack.env.local.yaml` / `hack.env.<overlay>.local.yaml` (worktree-local overrides)

docs/cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ materialized `.hack/.env` or `.hack/.env.state.json` is stale and should be rege
128128
## Project files
129129

130130
Hack owns a committed `.hack/.gitignore` (self-healing on `init`/`up`) that ignores machine-local
131-
generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`). Keep
131+
generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`,
132+
`tickets/`). Keep
132133
it committed. If generated files ever leak into git, `hack doctor --fix` untracks them (the files
133134
stay on disk). See [Architecture](architecture.md) for the full file map.
134135

docs/env.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ worktrees inherit the rules with zero setup. It covers (patterns relative to
5151
- `.env.state.json`
5252
- `hack.env.local.yaml`
5353
- `hack.env.*.local.yaml`
54+
- `tickets/` (local tickets-extension cache)
5455

5556
How it is maintained:
5657

src/agents/instruction-source.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [
113113
"Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.",
114114
"Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).",
115115
"Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.",
116-
"Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).",
116+
"Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).",
117117
],
118118
},
119119
{

src/commands/daemon.ts

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ import {
1414
installLaunchdService,
1515
kickstartLaunchdService,
1616
type LaunchdServiceStatus,
17+
repairLaunchdProgramIfInvalid,
1718
uninstallLaunchdService,
1819
} from "../daemon/launchd.ts";
1920
import { type DaemonPaths, resolveDaemonPaths } from "../daemon/paths.ts";
20-
import { removeFileIfExists, waitForProcessExit } from "../daemon/process.ts";
21+
import {
22+
findOrphanDaemonProcesses,
23+
removeFileIfExists,
24+
terminateOrphanDaemonProcesses,
25+
waitForProcessExit,
26+
} from "../daemon/process.ts";
2127
import { runDaemon } from "../daemon/server.ts";
2228
import {
2329
buildDaemonRepairMessage,
@@ -220,11 +226,38 @@ async function handleDaemonStart({
220226
const paths = resolveDaemonPaths({});
221227
const status = await readDaemonStatus({ paths });
222228

223-
if (status.running) {
224-
logger.success({
225-
message: `hackd already running (pid ${status.pid ?? "unknown"})`,
229+
if (status.running && status.pid !== null) {
230+
const api = await checkDaemonApi({
231+
socketExists: status.socketExists,
232+
paths,
226233
});
227-
return 0;
234+
if (api.reachable && !api.compatible) {
235+
// An incompatible daemon (usually a pre-upgrade binary) must be
236+
// replaced, not reported as success — leaving it running is how
237+
// machines end up with doctor/status contradictions.
238+
logger.warn({
239+
message: `Replacing incompatible hackd (pid ${status.pid})`,
240+
});
241+
await stopDaemonProcess({ pid: status.pid, paths });
242+
} else {
243+
logger.success({
244+
message: `hackd already running (pid ${status.pid})`,
245+
});
246+
return 0;
247+
}
248+
}
249+
250+
// Daemons that outlived their pid file (e.g. a launchd-managed instance
251+
// surviving a manual clear/restart) hold the API socket invisibly and
252+
// make every freshly spawned daemon exit cleanly. Sweep them first.
253+
const orphans = await findOrphanDaemonProcesses({
254+
trackedPid: status.pid,
255+
});
256+
if (orphans.length > 0) {
257+
logger.warn({
258+
message: `Stopping orphaned hackd process(es) not tracked by the pid file: ${orphans.join(", ")}`,
259+
});
260+
await terminateOrphanDaemonProcesses({ pids: orphans });
228261
}
229262

230263
await removeFileIfExists({ path: paths.socketPath });
@@ -235,6 +268,43 @@ async function handleDaemonStart({
235268
return 0;
236269
}
237270

271+
// When launchd manages the daemon, start through launchd — spawning a
272+
// bare process next to a loaded agent means two process managers fight
273+
// over one socket and pid file.
274+
const launchdStatus = await resolveLaunchdStatus({ paths });
275+
if (launchdStatus?.loaded) {
276+
const repair = await repairLaunchdProgramIfInvalid({ paths });
277+
if (repair === "repaired") {
278+
logger.warn({
279+
message:
280+
"Repaired launchd service: its program path was invalid (stale or compiled-binary virtual path)",
281+
});
282+
}
283+
// launchd owns the daemon: never spawn a bare process next to it —
284+
// two process managers for one socket is how machines end up with
285+
// start-then-exit flapping and stale-pid contradictions.
286+
const kick = await kickstartLaunchdService();
287+
if (!kick.ok) {
288+
logger.error({
289+
message: `launchd kickstart failed: ${kick.error ?? "unknown error"} | Check: hack daemon logs --tail 200`,
290+
});
291+
return 1;
292+
}
293+
const startedViaLaunchd = await waitForDaemonStart({
294+
paths,
295+
timeoutMs: 8000,
296+
});
297+
if (startedViaLaunchd) {
298+
logger.success({ message: "hackd started (launchd)" });
299+
return 0;
300+
}
301+
logger.warn({
302+
message:
303+
"launchd kickstarted hackd but it did not report ready yet | Check: hack daemon logs --tail 200",
304+
});
305+
return 1;
306+
}
307+
238308
const invocation = await resolveHackInvocation();
239309
const cmd = [...invocation.args, "daemon", "start", "--foreground"];
240310
const proc = Bun.spawn([invocation.bin, ...cmd], {
@@ -256,6 +326,31 @@ async function handleDaemonStart({
256326
return 0;
257327
}
258328

329+
async function stopDaemonProcess(opts: {
330+
readonly pid: number;
331+
readonly paths: DaemonPaths;
332+
}): Promise<void> {
333+
try {
334+
process.kill(opts.pid, "SIGTERM");
335+
} catch {
336+
// Already gone.
337+
}
338+
const exited = await waitForProcessExit({
339+
pid: opts.pid,
340+
timeoutMs: 2000,
341+
pollMs: 200,
342+
});
343+
if (!exited) {
344+
try {
345+
process.kill(opts.pid, "SIGKILL");
346+
} catch {
347+
// Already gone.
348+
}
349+
}
350+
await removeFileIfExists({ path: opts.paths.pidPath });
351+
await removeFileIfExists({ path: opts.paths.socketPath });
352+
}
353+
259354
async function handleDaemonStop({
260355
args: _args,
261356
}: {
@@ -506,11 +601,26 @@ async function handleDaemonClear({
506601
return 1;
507602
}
508603

604+
const orphans = await findOrphanDaemonProcesses({
605+
trackedPid: status.pid,
606+
});
607+
if (orphans.length > 0) {
608+
logger.warn({
609+
message: `Stopping orphaned hackd process(es): ${orphans.join(", ")}`,
610+
});
611+
await terminateOrphanDaemonProcesses({ pids: orphans });
612+
}
613+
509614
const pidExists = await pathExists(paths.pidPath);
510615
const socketExists = await pathExists(paths.socketPath);
511616

512617
if (!(pidExists || socketExists)) {
513-
logger.info({ message: "No stale hackd state found" });
618+
logger.info({
619+
message:
620+
orphans.length > 0
621+
? "Cleared orphaned hackd process(es); no stale files found"
622+
: "No stale hackd state found",
623+
});
514624
return 0;
515625
}
516626

@@ -549,10 +659,12 @@ async function handleDaemonRestart({
549659

550660
async function waitForDaemonStart({
551661
paths,
662+
timeoutMs,
552663
}: {
553664
readonly paths: ReturnType<typeof resolveDaemonPaths>;
665+
readonly timeoutMs?: number;
554666
}): Promise<boolean> {
555-
const deadline = Date.now() + 2000;
667+
const deadline = Date.now() + (timeoutMs ?? 2000);
556668
while (Date.now() < deadline) {
557669
const status = await readDaemonStatus({ paths });
558670
if (status.running && status.socketExists) {

0 commit comments

Comments
 (0)