Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/terminal-end-binding-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Fence both compensation paths by their original close generation and exact proposed marker. Preserve End's retained tombstone or coordinator-confirmed removal and keep sibling sessions untouched.
4 changes: 4 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,7 @@ Restored pending children are hidden before the server starts accepting requests
An exact attested no-reply seed can be removed from an older empty user-only
checkpoint without inflating task counts. Clearing a seed-only record requires
the exact previous revision; compacted tasks and recorded real replies remain.

A failed Terminal binding publication can roll back only its original close
generation and exact proposed marker. A queued End retains its durable tombstone
or confirmed removal, and other sessions keep their own process and binding.
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("retained Terminal cleanup and bound restart cancellation", () => {
await rm(root, { recursive: true, force: true });
});

function fixture() {
function fixture(closeRecorded = true) {
const request = { cwd: root, harness: "claude-code" as const };
const path = join(root, "sessions.json");
const writeBinding = vi.fn(async (file: string, text: string) => {
Expand Down Expand Up @@ -88,7 +88,7 @@ describe("retained Terminal cleanup and bound restart cancellation", () => {
adapters: { "claude-code": adapter },
resolveAgentMapIdentity: resolveIdentity,
writeSubsessionBindingRegistry: writeBinding,
onSubsessionUserClosed: async () => {},
onSubsessionUserClosed: closeRecorded ? async () => {} : undefined,
spawnPty,
isPidAlive,
});
Expand Down Expand Up @@ -315,4 +315,99 @@ describe("retained Terminal cleanup and bound restart cancellation", () => {
expect(manager.getSubsessionBinding(id)).toBeNull();
},
);

it.each([
["create", false],
["create", true],
["resume", false],
["resume", true],
] as const)(
"failed %s publication cannot roll back queued End (coordinator recorded=%s)",
async (kind, closeRecorded) => {
const { request, path, writeBinding, canResume, spawnPty } =
fixture(closeRecorded);
const id = "00000000-0000-4000-8000-000000000335";
const siblingId = "00000000-0000-4000-8000-000000000336";
const marker = {
sessionId: id,
projectId: "project-1",
parentSessionId: "parent-1",
bindingId: "binding-primary",
incarnation: 1,
spawnEpoch: 1,
};
const next = { ...marker, incarnation: 2, spawnEpoch: 2 };
const siblingMarker = {
...marker,
sessionId: siblingId,
bindingId: "binding-sibling",
};
const trusted = {
agentMapIdentity: (sessionId: string) => ({
sessionId,
projectId: "project-1",
userId: "user-1",
}),
};
if (kind === "resume") {
await manager.createReserved(id, request, marker, trusted);
await manager.setAgentSessionId(
id,
"vendor-1",
"startup",
manager.getRuntimeEpoch(id)!,
);
spawns[0]!.exit();
await manager.flush();
canResume.mockResolvedValue(true);
}
await manager.createReserved(siblingId, request, siblingMarker, trusted);
const siblingEpoch = manager.getRuntimeEpoch(siblingId);
const siblingSpawn = spawns.at(-1)!;
const spawnCount = spawns.length;
const held = gate();
const failure = new Error("binding publication acknowledgement failed");
writeBinding.mockImplementationOnce(async (file, text) => {
await held.wait();
await writeFile(file, text);
throw failure;
});
const preparing =
kind === "create"
? manager.createReserved(id, request, marker, trusted)
: manager.resumeBound(id, marker, next);
const rejected = expect(preparing).rejects.toBe(failure);
await held.entered;
const closing = manager.close(id);
const endedMarker = kind === "create" ? marker : next;
expect(manager.wasSubsessionClosedByUser(endedMarker)).toBe(true);
held.release();
await Promise.all([closing, rejected]);

const expectedMarker = closeRecorded ? null : endedMarker;
expect(manager.getSubsessionBinding(id)).toEqual(expectedMarker);
expect(manager.getRuntimeEpoch(siblingId)).toBe(siblingEpoch);
expect(siblingSpawn.kill).not.toHaveBeenCalled();
expect(spawnPty).toHaveBeenCalledTimes(spawnCount);
expect(
JSON.parse(await readFile(`${path}.subsession-bindings.json`, "utf8")),
).toEqual({
version: 1,
markers: {
[siblingId]: siblingMarker,
...(expectedMarker ? { [id]: expectedMarker } : {}),
},
closedSessionIds: closeRecorded ? [] : [id],
});
siblingSpawn.exit();
await manager.flush();
fixture(closeRecorded);
await manager.init();
expect(manager.getSubsessionBinding(id)).toEqual(expectedMarker);
expect(manager.getSubsessionBinding(siblingId)).toEqual(siblingMarker);
expect(manager.wasSubsessionClosedByUser(endedMarker)).toBe(
!closeRecorded,
);
},
);
});
14 changes: 11 additions & 3 deletions packages/harness/src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1959,11 +1959,16 @@ export class SessionManager {
// A failed spawn may leave the exact next marker durably committed.
// Retrying that same transition must not require the old marker again.
if (!sameSubsessionBinding(current, bindingTransition.next)) {
this.subsessionBindings.set(id, bindingTransition.next);
const next = bindingTransition.next;
this.subsessionBindings.set(id, next);
try {
await this.persistSubsessionBindings();
} catch (error) {
this.subsessionBindings.set(id, current);
if (
(this.closeGenerations.get(id) ?? 0) === closeGeneration &&
this.subsessionBindings.get(id) === next
)
this.subsessionBindings.set(id, current);
throw error;
}
}
Expand Down Expand Up @@ -4042,7 +4047,10 @@ export class SessionManager {
try {
await this.persistSubsessionBindings();
} catch (error) {
if (this.subsessionBindings.get(reservedSessionId) === marker)
if (
(this.closeGenerations.get(reservedSessionId) ?? 0) === closeGeneration &&
this.subsessionBindings.get(reservedSessionId) === marker
)
this.subsessionBindings.delete(reservedSessionId);
throw error;
}
Expand Down
Loading