Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ its isolated derived branch instance, and `--branch <name>` targets only that ex
`E_COMPOSE_FAILED`. If Compose exits successfully but one or more containers remain `created`, exit
non-zero, or enter another failed runtime state, Hack returns `E_STARTUP_INCOMPLETE`. Successful
one-shot services (`exited` with code 0) are reported under `services.completed` and do not fail startup.
Hack recognizes dependency installers, services labeled `hack.service.one-shot=true`, and services
referenced by another service with Compose `condition: service_completed_successfully`. An unrelated
service that merely exits zero is still treated as incomplete startup.
Lifecycle failures are one of the primary consumers of the `--json` error envelope,
since hook and process startup are common failure points around `hack up`.

Expand Down
2 changes: 1 addition & 1 deletion src/agents/instruction-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [
"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.",
"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`.",
"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`.",
"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`.",
"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.",
"Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.",
Expand Down
2 changes: 1 addition & 1 deletion src/agents/integration-revision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
* source test recomputes this value and fails whenever guidance changes
* without a revision update.
*/
export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "b8663fad3ef4";
export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "eef2b0142b95";
Comment thread
roodboi marked this conversation as resolved.
5 changes: 5 additions & 0 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6118,6 +6118,8 @@ async function runUpCommand({
const successfulCompletionServices = new Set(
await discoverSuccessfulCompletionServices({
composeFile: project.composeFile,
activeProfiles: profiles,
Comment thread
roodboi marked this conversation as resolved.
Outdated
selectedServices: serviceScoped ? targetServices : undefined,
})
);
const startup = classifyComposeStartupState(states, {
Expand Down Expand Up @@ -6790,6 +6792,8 @@ async function runTargetedServiceRestart(opts: {
const successfulCompletionServices = new Set(
await discoverSuccessfulCompletionServices({
composeFile: opts.project.composeFile,
activeProfiles: opts.profiles,
selectedServices: opts.services,
})
);
const startup = classifyComposeStartupState(states, {
Expand Down Expand Up @@ -7053,6 +7057,7 @@ async function runRestartCommand({
const successfulCompletionServices = new Set(
await discoverSuccessfulCompletionServices({
composeFile: project.composeFile,
activeProfiles: profiles,
})
);
const startup = classifyComposeStartupState(states, {
Expand Down
43 changes: 41 additions & 2 deletions src/lib/registry-credential-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export async function discoverDependencyBootstrapServices(opts: {
/** Services that may legitimately exit zero after startup instead of staying running. */
export async function discoverSuccessfulCompletionServices(opts: {
readonly composeFile: string;
readonly activeProfiles?: readonly string[];
readonly selectedServices?: readonly string[];
}): Promise<readonly string[]> {
const bootstrapServices = await discoverDependencyBootstrapServices(opts);
const text = await readTextFile(opts.composeFile);
Expand All @@ -144,9 +146,46 @@ export async function discoverSuccessfulCompletionServices(opts: {
})
)
.map(([service]) => service);
return [...new Set([...bootstrapServices, ...explicitOneShots])].sort(
(left, right) => left.localeCompare(right)
const activeProfiles = new Set(opts.activeProfiles ?? []);
const allProfilesActive = activeProfiles.has("*");
const selectedServices = opts.selectedServices
? new Set(opts.selectedServices)
: null;
const completionDependencies = Object.entries(services).flatMap(
([service, value]) => {
if (!(isRecord(value) && isRecord(value.depends_on))) {
return [];
}
if (selectedServices) {
if (!selectedServices.has(service)) {
return [];
}
Comment thread
roodboi marked this conversation as resolved.
Outdated
} else if (
Array.isArray(value.profiles) &&
!allProfilesActive &&
!value.profiles.some(
(profile) =>
typeof profile === "string" && activeProfiles.has(profile)
)
) {
return [];
}
return Object.entries(value.depends_on)
.filter(
([, dependency]) =>
isRecord(dependency) &&
dependency.condition === "service_completed_successfully"
)
.map(([service]) => service);
}
);
return [
...new Set([
...bootstrapServices,
...explicitOneShots,
...completionDependencies,
]),
].sort((left, right) => left.localeCompare(right));
} catch {
return bootstrapServices;
}
Expand Down
48 changes: 43 additions & 5 deletions tests/project-up-startup-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ test("up returns failure when compose reports no services after exit zero", asyn
);
});

test("up accepts running and successfully completed one-shot services", async () => {
test("up accepts running services and successful Compose completion gates", async () => {
const projectRoot = await createProject();
psRows.push(
JSON.stringify({ Service: "api", State: "running", ExitCode: 0 }),
Expand All @@ -173,6 +173,28 @@ test("up accepts running and successfully completed one-shot services", async ()
expect(upServiceSelections).toEqual([undefined]);
});

test("up rejects an exited service referenced only by an inactive profile", async () => {
const projectRoot = await createProject();
psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 }));

const exitCode = await runDetachedUp({ projectRoot });

expect(exitCode).toBe(1);
expect(errorMessages).toContain(
"Startup incomplete for startup-state-test: api did not reach running or successful completion"
);
});

test("up accepts completion gates from wildcard-enabled profiles", async () => {
const projectRoot = await createProject();
psRows.push(JSON.stringify({ Service: "api", State: "exited", ExitCode: 0 }));

const exitCode = await runDetachedUp({ projectRoot, profiles: ["*"] });

expect(exitCode).toBe(0);
expect(errorMessages).toEqual([]);
});

test("registry credentials must exist in the bootstrap service scope", async () => {
const projectRoot = await createProject({ registryTokenScope: "api" });

Expand Down Expand Up @@ -262,7 +284,9 @@ test("up warns before an auto-derived branch retargets the same worktree", async

async function runDetachedUp(opts: {
readonly projectRoot: string;
readonly profiles?: readonly string[];
}): Promise<number> {
const profile = opts.profiles?.join(",");
const input = {
ctx: { cwd: opts.projectRoot, cli: CLI_SPEC },
args: {
Expand All @@ -272,13 +296,20 @@ async function runDetachedUp(opts: {
env: "base",
branch: undefined,
detach: true,
profile: undefined,
profile,
target: undefined,
json: false,
},
positionals: {},
raw: {
argv: ["--path", opts.projectRoot, "--env", "base", "--detach"],
argv: [
"--path",
opts.projectRoot,
"--env",
"base",
"--detach",
...(profile ? ["--profile", profile] : []),
],
positionals: [],
},
},
Expand Down Expand Up @@ -370,10 +401,17 @@ async function createProject(opts?: {
"services:",
" api:",
" image: alpine:3.20",
" depends_on:",
" migrate:",
" condition: service_completed_successfully",
" migrate:",
" image: alpine:3.20",
" labels:",
' hack.service.one-shot: "true"',
" profiled-worker:",
" image: alpine:3.20",
" profiles: [benchmark]",
" depends_on:",
" api:",
" condition: service_completed_successfully",
...(opts?.registryTokenScope
? [
" deps:",
Expand Down
29 changes: 28 additions & 1 deletion tests/registry-credential-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ test("dependency bootstrap detection is name agnostic", async () => {
]);
});

test("successful completion requires installer semantics or an explicit one-shot label", async () => {
test("successful completion recognizes installers, labels, and Compose completion gates", async () => {
const projectRoot = await createTempProject();
const composeFile = resolve(projectRoot, "compose.yml");
await writeFile(
Expand All @@ -80,14 +80,41 @@ test("successful completion requires installer semantics or an explicit one-shot
" image: app",
" labels:",
' hack.service.one-shot: "true"',
" database-init:",
" image: app",
" api:",
" image: app",
" depends_on:",
" database-init:",
" condition: service_completed_successfully",
" exited-api:",
" image: app",
" profiled-worker:",
" image: app",
" profiles: [benchmark]",
" depends_on:",
" exited-api:",
" condition: service_completed_successfully",
"",
].join("\n")
);

expect(await discoverSuccessfulCompletionServices({ composeFile })).toEqual([
"database-init",
"installer",
"migrate",
]);
expect(
await discoverSuccessfulCompletionServices({
composeFile,
activeProfiles: ["benchmark"],
})
).toEqual(["database-init", "exited-api", "installer", "migrate"]);
expect(
await discoverSuccessfulCompletionServices({
composeFile,
activeProfiles: ["benchmark"],
selectedServices: ["api"],
})
).toEqual(["database-init", "installer", "migrate"]);
});
Loading