Skip to content

Commit 57f2358

Browse files
committed
fix(runtime): clean descendant lifecycle groups
1 parent f87e5cf commit 57f2358

4 files changed

Lines changed: 25 additions & 13 deletions

File tree

docs/architecture.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,10 @@ Lifecycle state is checkout-local at `.hack/.internal/lifecycle/state.json`. Eac
126126
has its own entry, so starting a branch instance does not replace the base instance's entry in the
127127
same checkout. Cleanup reconciles the saved process group with the live process table; if the group
128128
leader exited while members remain, `hack doctor` reports the orphan. `hack down` terminates the
129-
persisted leaderless group only while a matching lifecycle mux session still proves ownership; without
130-
that session, cleanup stays non-destructive because the numeric PGID may have been reused. A live group
131-
leader without its saved pane PID is likewise not trusted as lifecycle ownership.
129+
persisted leaderless group and descendant process groups only while a matching lifecycle mux session
130+
still proves ownership; without that session, cleanup stays non-destructive because the numeric PGID
131+
may have been reused. A live group leader without its saved pane PID is likewise not trusted as
132+
lifecycle ownership.
132133

133134
For fixed-port helpers such as SSM/database/search tunnels, lifecycle config can also declare a
134135
`singleton` listener set. This lets Hack reuse an already-running equivalent helper or fail fast on

src/lib/project-lifecycle-processes.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,20 @@ export function resolveLeaderlessPersistedLifecycleProcessGroupIds(opts: {
134134
const groupLeaderStillExists = opts.snapshot.some(
135135
(row) => row.pid === processGroupId
136136
);
137-
const groupHasMembers = opts.snapshot.some(
138-
(row) => row.processGroupId === processGroupId
139-
);
137+
const groupMemberPids = opts.snapshot
138+
.filter((row) => row.processGroupId === processGroupId)
139+
.map((row) => row.pid);
140140
if (
141141
!(persistedPaneStillExists || groupLeaderStillExists) &&
142-
groupHasMembers
142+
groupMemberPids.length > 0
143143
) {
144-
groups.add(processGroupId);
144+
const descendantGroups = collectDescendantProcessGroupIds({
145+
snapshot: opts.snapshot,
146+
rootPids: groupMemberPids,
147+
});
148+
for (const descendantGroup of descendantGroups) {
149+
groups.add(descendantGroup);
150+
}
145151
}
146152
}
147153

tests/project-lifecycle-hygiene.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ test("inspectLifecycleStateEntries reports leaderless groups behind a live mux s
5858
snapshot: [
5959
{ pid: 501, ppid: 1, processGroupId: 500 },
6060
{ pid: 502, ppid: 501, processGroupId: 500 },
61+
{ pid: 503, ppid: 502, processGroupId: 503 },
6162
],
6263
});
6364

6465
expect(inspection.staleEntries).toEqual([]);
65-
expect(inspection.orphanedProcessGroups).toEqual([500]);
66+
expect(inspection.orphanedProcessGroups).toEqual([500, 503]);
6667
});

tests/project-lifecycle-processes.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ test("resolveLifecycleProcessGroupIdsForTmuxState ignores recycled process group
188188
expect(groups).toEqual([]);
189189
});
190190

191-
test("resolveLifecycleProcessGroupIdsForTmuxState recovers a persisted leaderless group", () => {
191+
test("resolveLifecycleProcessGroupIdsForTmuxState recovers a persisted leaderless group and descendants", () => {
192192
const groups = resolveLifecycleProcessGroupIdsForTmuxState({
193193
lifecycleEntry: {
194194
composeProject: "event-agent",
@@ -211,10 +211,11 @@ test("resolveLifecycleProcessGroupIdsForTmuxState recovers a persisted leaderles
211211
snapshot: [
212212
{ pid: 501, ppid: 1, processGroupId: 500 },
213213
{ pid: 502, ppid: 501, processGroupId: 500 },
214+
{ pid: 503, ppid: 502, processGroupId: 503 },
214215
],
215216
});
216217

217-
expect(groups).toEqual([500]);
218+
expect(groups).toEqual([500, 503]);
218219
});
219220

220221
test("resolvePersistedLifecycleProcessGroupIds recovers live groups without a mux session", () => {
@@ -297,13 +298,14 @@ test("resolveLifecycleStopProcessGroupIds skips a leaderless persisted group wit
297298
snapshot: [
298299
{ pid: 501, ppid: 1, processGroupId: 500 },
299300
{ pid: 502, ppid: 501, processGroupId: 500 },
301+
{ pid: 503, ppid: 502, processGroupId: 503 },
300302
],
301303
});
302304

303305
expect(groups).toEqual([]);
304306
});
305307

306-
test("resolveLifecycleStopProcessGroupIds recovers a leaderless persisted group with a live mux session", () => {
308+
test("resolveLifecycleStopProcessGroupIds recovers a leaderless persisted group and descendants with a live mux session", () => {
307309
const groups = resolveLifecycleStopProcessGroupIds({
308310
matchedLiveSession: true,
309311
lifecycleEntry: {
@@ -326,10 +328,12 @@ test("resolveLifecycleStopProcessGroupIds recovers a leaderless persisted group
326328
snapshot: [
327329
{ pid: 501, ppid: 1, processGroupId: 500 },
328330
{ pid: 502, ppid: 501, processGroupId: 500 },
331+
{ pid: 503, ppid: 502, processGroupId: 503 },
332+
{ pid: 504, ppid: 503, processGroupId: 504 },
329333
],
330334
});
331335

332-
expect(groups).toEqual([500]);
336+
expect(groups).toEqual([500, 503, 504]);
333337
});
334338

335339
test("wrapLifecyclePersistentCommand uses external kill for process-group cleanup", () => {

0 commit comments

Comments
 (0)