Skip to content

Commit e85e223

Browse files
authored
fix(runtime): recognize Compose completion gates (#65)
## Summary - infer successful one-shot services from Compose `depends_on.condition: service_completed_successfully` - preserve strict failure handling for unrelated services that merely exit zero - refresh lifecycle docs and agent-integration revision so stale installed guidance is detectable ## Root cause Hack v3.3.6 only accepted exited-zero services when they were dependency installers or carried `hack.service.one-shot=true`. Valid Compose completion gates such as Event Agent `db-ops` were therefore reported as `E_STARTUP_INCOMPLETE` after the stack had started successfully. ## Verification - `bun test` — 893 pass, 5 skip, 0 fail - `bun run check` - `bun run typecheck` - `bun run build` - focused startup/classifier regressions — 16 pass, 0 fail
1 parent c44ea13 commit e85e223

11 files changed

Lines changed: 307 additions & 33 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ Use `hack` as the primary interface for local-first development.
1212

1313
## Integration freshness
1414

15-
- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale.
15+
- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale.
1616
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
1717
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
1818
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
19-
- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee).
19+
- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee).
2020

2121
## Product boundary
2222

@@ -125,7 +125,7 @@ Use `hack` as the primary interface for local-first development.
125125
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
126126
- 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.
127127
- `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.
128-
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
128+
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
129129
- Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`.
130130
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
131131
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.

.cursor/rules/hack.mdc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
77

88
## Integration freshness
99

10-
- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale.
10+
- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale.
1111
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
1212
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
1313
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
14-
- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee).
14+
- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee).
1515

1616
## Product boundary
1717

@@ -73,7 +73,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
7373
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
7474
- 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.
7575
- `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.
76-
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
76+
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
7777
- Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`.
7878
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
7979
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ Most formatting and common issues are automatically fixed by Biome. Run `bun x u
210210
Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces).
211211

212212
Integration freshness:
213-
- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale.
213+
- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale.
214214
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
215215
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
216216
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
217-
- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee).
217+
- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee).
218218

219219
Product boundary:
220220
- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon.
@@ -310,7 +310,7 @@ Lifecycle + startup:
310310
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
311311
- 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.
312312
- `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.
313-
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
313+
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
314314
- Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`.
315315
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
316316
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,11 @@ This project uses Obsidian for project context, specs, research, and progress tr
8080
Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces).
8181

8282
Integration freshness:
83-
- These instructions were generated by hack CLI v3.3.5; treat cached rules from another version as potentially stale.
83+
- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale.
8484
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
8585
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
8686
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
87-
- Content revision: `b8663fad3ef4` (version alone is not a freshness guarantee).
87+
- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee).
8888

8989
Product boundary:
9090
- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon.
@@ -180,7 +180,7 @@ Lifecycle + startup:
180180
- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.
181181
- 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.
182182
- `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.
183-
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
183+
- After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.
184184
- Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`.
185185
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
186186
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.

docs/lifecycle.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ its isolated derived branch instance, and `--branch <name>` targets only that ex
220220
`E_COMPOSE_FAILED`. If Compose exits successfully but one or more containers remain `created`, exit
221221
non-zero, or enter another failed runtime state, Hack returns `E_STARTUP_INCOMPLETE`. Successful
222222
one-shot services (`exited` with code 0) are reported under `services.completed` and do not fail startup.
223+
Hack recognizes dependency installers, services labeled `hack.service.one-shot=true`, and services
224+
referenced by another service with Compose `condition: service_completed_successfully`. An unrelated
225+
service that merely exits zero is still treated as incomplete startup.
223226
Lifecycle failures are one of the primary consumers of the `--json` error envelope,
224227
since hook and process startup are common failure points around `hack up`.
225228

src/agents/instruction-source.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [
206206
"Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.",
207207
"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.",
208208
"`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.",
209-
"After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.",
209+
"After `hack up` or `hack restart`, running services and successful one-shot services (`exited` with code 0) count as successful. Hack recognizes dependency installers, `hack.service.one-shot=true`, and services referenced by Compose `condition: service_completed_successfully`; other states return `E_STARTUP_INCOMPLETE`, and `hack doctor` warns about containers stuck in `Created`.",
210210
"Detached startup is bounded; a hung Compose operation returns `E_STARTUP_TIMEOUT`, terminates its process group, and `hack doctor --fix` can start exact containers left in `Created`.",
211211
"Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.",
212212
"Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.",

src/agents/integration-revision.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
* source test recomputes this value and fails whenever guidance changes
44
* without a revision update.
55
*/
6-
export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "b8663fad3ef4";
6+
export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "eef2b0142b95";

src/commands/project.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,13 +755,15 @@ async function readComposeServiceStates(opts: {
755755
readonly composeProjectName: string | null;
756756
readonly profiles: readonly string[];
757757
readonly services?: readonly string[];
758+
readonly env?: Readonly<Record<string, string>>;
758759
}): Promise<readonly ComposeServiceState[]> {
759760
const res = await composeRuntimeBackend.psJson({
760761
composeFiles: [opts.project.composeFile],
761762
composeProject: opts.composeProjectName,
762763
profiles: opts.profiles,
763764
cwd: dirname(opts.project.composeFile),
764765
all: true,
766+
env: opts.env,
765767
});
766768
if (res.exitCode !== 0) {
767769
return [];
@@ -6114,10 +6116,17 @@ async function runUpCommand({
61146116
composeProjectName,
61156117
profiles,
61166118
services: targetServices,
6119+
env: envOverrides.env,
6120+
});
6121+
const effectiveProfiles = resolveEffectiveComposeProfiles({
6122+
cliProfiles: profiles,
6123+
composeEnv: envOverrides.env,
61176124
});
61186125
const successfulCompletionServices = new Set(
61196126
await discoverSuccessfulCompletionServices({
61206127
composeFile: project.composeFile,
6128+
activeProfiles: effectiveProfiles,
6129+
selectedServices: serviceScoped ? targetServices : undefined,
61216130
})
61226131
);
61236132
const startup = classifyComposeStartupState(states, {
@@ -6786,10 +6795,17 @@ async function runTargetedServiceRestart(opts: {
67866795
composeProjectName: opts.composeProjectName,
67876796
profiles: opts.profiles,
67886797
services: opts.services,
6798+
env: envOverrides.env,
6799+
});
6800+
const effectiveProfiles = resolveEffectiveComposeProfiles({
6801+
cliProfiles: opts.profiles,
6802+
composeEnv: envOverrides.env,
67896803
});
67906804
const successfulCompletionServices = new Set(
67916805
await discoverSuccessfulCompletionServices({
67926806
composeFile: opts.project.composeFile,
6807+
activeProfiles: effectiveProfiles,
6808+
selectedServices: opts.services,
67936809
})
67946810
);
67956811
const startup = classifyComposeStartupState(states, {
@@ -7049,10 +7065,16 @@ async function runRestartCommand({
70497065
project,
70507066
composeProjectName,
70517067
profiles,
7068+
env: preflightEnv.env,
7069+
});
7070+
const effectiveProfiles = resolveEffectiveComposeProfiles({
7071+
cliProfiles: profiles,
7072+
composeEnv: preflightEnv.env,
70527073
});
70537074
const successfulCompletionServices = new Set(
70547075
await discoverSuccessfulCompletionServices({
70557076
composeFile: project.composeFile,
7077+
activeProfiles: effectiveProfiles,
70567078
})
70577079
);
70587080
const startup = classifyComposeStartupState(states, {
@@ -8064,6 +8086,20 @@ function parseCsvList(value: string | undefined): string[] {
80648086
return out;
80658087
}
80668088

8089+
/** Resolve the profiles Compose activates after CLI-over-environment precedence. */
8090+
function resolveEffectiveComposeProfiles(opts: {
8091+
readonly cliProfiles: readonly string[];
8092+
readonly composeEnv: Readonly<Record<string, string>>;
8093+
}): string[] {
8094+
if (opts.cliProfiles.length > 0) {
8095+
return [...opts.cliProfiles];
8096+
}
8097+
const composeProfiles = Object.hasOwn(opts.composeEnv, "COMPOSE_PROFILES")
8098+
? opts.composeEnv.COMPOSE_PROFILES
8099+
: process.env.COMPOSE_PROFILES;
8100+
return parseCsvList(composeProfiles);
8101+
}
8102+
80678103
function parseLogTimeRange(opts: {
80688104
readonly since: string | undefined;
80698105
readonly until: string | undefined;

src/lib/registry-credential-preflight.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ export async function discoverDependencyBootstrapServices(opts: {
121121
/** Services that may legitimately exit zero after startup instead of staying running. */
122122
export async function discoverSuccessfulCompletionServices(opts: {
123123
readonly composeFile: string;
124+
readonly activeProfiles?: readonly string[];
125+
readonly selectedServices?: readonly string[];
124126
}): Promise<readonly string[]> {
125127
const bootstrapServices = await discoverDependencyBootstrapServices(opts);
126128
const text = await readTextFile(opts.composeFile);
@@ -144,9 +146,41 @@ export async function discoverSuccessfulCompletionServices(opts: {
144146
})
145147
)
146148
.map(([service]) => service);
147-
return [...new Set([...bootstrapServices, ...explicitOneShots])].sort(
148-
(left, right) => left.localeCompare(right)
149+
const activeProfiles = new Set(opts.activeProfiles ?? []);
150+
const allProfilesActive = activeProfiles.has("*");
151+
const selectedServices = opts.selectedServices
152+
? new Set(opts.selectedServices)
153+
: null;
154+
const completionDependencies = Object.entries(services).flatMap(
155+
([service, value]) => {
156+
if (!(isRecord(value) && isRecord(value.depends_on))) {
157+
return [];
158+
}
159+
const dependentIsActive = selectedServices
160+
? selectedServices.has(service)
161+
: !Array.isArray(value.profiles) ||
162+
allProfilesActive ||
163+
value.profiles.some(
164+
(profile) =>
165+
typeof profile === "string" && activeProfiles.has(profile)
166+
);
167+
return Object.entries(value.depends_on)
168+
.filter(
169+
([dependencyService, dependency]) =>
170+
isRecord(dependency) &&
171+
dependency.condition === "service_completed_successfully" &&
172+
(dependentIsActive || selectedServices?.has(dependencyService))
173+
)
174+
.map(([service]) => service);
175+
}
149176
);
177+
return [
178+
...new Set([
179+
...bootstrapServices,
180+
...explicitOneShots,
181+
...completionDependencies,
182+
]),
183+
].sort((left, right) => left.localeCompare(right));
150184
} catch {
151185
return bootstrapServices;
152186
}

0 commit comments

Comments
 (0)