Skip to content

Commit 62b1c8c

Browse files
ualtinokalfonso-aft
andcommitted
fix: bash wait-detach fans out on root-key miss instead of silently dropping
The user-message detach signal resolved the session directory and required an exact live-transport key match; any canonicalization or cwd-fallback mismatch made it return without a trace, so a wait:true bash kept blocking through user messages. The command carries its own session_id and the Rust side scopes by session, so a root-key miss now fans the signal out to every live bridge, and both the fallback and the no-bridge case log. Co-authored-by: Alfonso <289616620+alfonso-aft@users.noreply.github.com>
1 parent 6ede983 commit 62b1c8c

6 files changed

Lines changed: 84 additions & 8 deletions

File tree

packages/aft-bridge/src/pool.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ export class BridgePool implements AftTransportPool {
195195
return entry.bridge;
196196
}
197197

198+
/** All live bridges, for session-scoped signals that must not depend on
199+
* exact root-key resolution (the command itself carries the session ID). */
200+
activeBridges(): BinaryBridge[] {
201+
if (this.shutdownCalled) return [];
202+
const alive: BinaryBridge[] = [];
203+
for (const entry of this.bridges.values()) {
204+
if (entry.bridge.isAlive()) alive.push(entry.bridge);
205+
}
206+
return alive;
207+
}
208+
198209
/**
199210
* Get or create the bridge for `projectRoot`.
200211
*

packages/aft-bridge/src/revivable-transport.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export class RevivableTransportPool implements AftTransportPool {
4545
return transport;
4646
}
4747

48+
/** Delegate to the active pool: session-scoped signal fan-out reaches the
49+
* same live transports the underlying pool would report. */
50+
activeBridges(): AftProjectTransport[] {
51+
return this.activePool.activeBridges();
52+
}
53+
4854
getActiveBridgeForRoot(projectRoot: string): RevivableProjectTransport | null {
4955
const key = canonicalizeProjectRoot(projectRoot);
5056
const bridge = this.currentBridge(key);

packages/aft-bridge/src/subc-transport.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,14 @@ export class SubcTransportPool implements AftTransportPool {
535535
return this.transports.get(key) ?? null;
536536
}
537537

538+
/** All live per-root facades, for session-scoped signals that must not
539+
* depend on exact root-key resolution (the command carries the session ID
540+
* and the module scopes by session, so fan-out is safe). */
541+
activeBridges(): SubcTransport[] {
542+
if (!this.client || this.shuttingDown) return [];
543+
return [...this.transports.values()];
544+
}
545+
538546
async toolCall(
539547
projectRoot: string,
540548
runtime: { sessionID?: string },

packages/aft-bridge/src/transport.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export interface AftProjectTransport {
5252
export interface AftTransportPool {
5353
getBridge(projectRoot: string): AftProjectTransport;
5454
getActiveBridgeForRoot(projectRoot: string): AftProjectTransport | null;
55+
/**
56+
* All currently-live project transports, for session-scoped signals (e.g.
57+
* bash wait-detach) that must reach their target even when the caller's
58+
* root-key resolution disagrees with the key the tool call used. Callers
59+
* must only send session-scoped, read-only commands through these.
60+
*/
61+
activeBridges(): AftProjectTransport[];
5562
toolCall(
5663
projectRoot: string,
5764
runtime: BridgeToolCallRuntime,

packages/opencode-plugin/src/__tests__/bash-wait-detach.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ describe("bash wait detach helper", () => {
3737
});
3838
});
3939

40-
test("user-message detach is skipped without a session or active bridge", async () => {
40+
test("user-message detach is skipped without a session or any live bridge", async () => {
4141
const send = mock(async () => ({ success: true }));
4242
const pool = {
4343
getActiveBridgeForRoot: () => null,
44+
activeBridges: () => [],
4445
};
4546

4647
await signalBashWaitDetachForProject(
@@ -56,4 +57,32 @@ describe("bash wait detach helper", () => {
5657

5758
expect(send).not.toHaveBeenCalled();
5859
});
60+
61+
test("root-key miss fans out to every live bridge instead of dropping", async () => {
62+
const sends: string[] = [];
63+
const bridgeFor = (label: string) => ({
64+
send: mock(async (command: string, params: Record<string, unknown>) => {
65+
sends.push(`${label}:${command}:${String(params.session_id)}`);
66+
return { success: true };
67+
}),
68+
});
69+
const bridgeA = bridgeFor("a");
70+
const bridgeB = bridgeFor("b");
71+
const pool = {
72+
// Exact root resolution misses (the silent-drop bug this guards):
73+
getActiveBridgeForRoot: () => null,
74+
activeBridges: () => [bridgeA, bridgeB],
75+
};
76+
77+
await signalBashWaitDetachForProject(
78+
pool as unknown as Parameters<typeof signalBashWaitDetachForProject>[0],
79+
"/repo-that-does-not-match",
80+
"session-3",
81+
);
82+
83+
expect(sends.sort()).toEqual([
84+
"a:bash_wait_detach:session-3",
85+
"b:bash_wait_detach:session-3",
86+
]);
87+
});
5988
});

packages/opencode-plugin/src/bash-wait-detach.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { AftProjectTransport, AftTransportPool } from "@cortexkit/aft-bridge";
2-
import { warn } from "./logger.js";
2+
import { log, warn } from "./logger.js";
33
import { BASH_TRANSPORT_TIMEOUT_MS } from "./tools/_shared.js";
44

5-
type ActiveBridgePool = Pick<AftTransportPool, "getActiveBridgeForRoot">;
5+
type ActiveBridgePool = Pick<AftTransportPool, "getActiveBridgeForRoot" | "activeBridges">;
66

77
async function sendBashWaitDetach(bridge: AftProjectTransport, sessionID: string): Promise<void> {
88
const response = await bridge.send(
@@ -21,13 +21,28 @@ export async function signalBashWaitDetachForProject(
2121
sessionID: string | undefined,
2222
): Promise<void> {
2323
if (!sessionID) return;
24-
const bridge = pool.getActiveBridgeForRoot(projectRoot);
25-
if (!bridge) return;
26-
try {
27-
await sendBashWaitDetach(bridge, sessionID);
28-
} catch (err) {
24+
// Root-key resolution can disagree with the key the bash tool call used
25+
// (canonicalization, worktrees, cwd fallbacks). The command is scoped by
26+
// session_id on the Rust side, so when the exact root has no live bridge,
27+
// fan out to every live bridge instead of silently dropping the signal.
28+
const exact = pool.getActiveBridgeForRoot(projectRoot);
29+
const targets = exact ? [exact] : pool.activeBridges();
30+
if (targets.length === 0) {
31+
warn(`[bash_wait_detach] no live bridge for session ${sessionID} (root ${projectRoot})`);
32+
return;
33+
}
34+
const results = await Promise.allSettled(
35+
targets.map((bridge: AftProjectTransport) => sendBashWaitDetach(bridge, sessionID)),
36+
);
37+
const failed = results.filter((result: PromiseSettledResult<void>) => result.status === "rejected");
38+
if (failed.length === results.length) {
39+
const err = (failed[0] as PromiseRejectedResult).reason;
2940
warn(
3041
`[bash_wait_detach] failed for session ${sessionID}: ${err instanceof Error ? err.message : String(err)}`,
3142
);
43+
} else {
44+
log(
45+
`[bash_wait_detach] signaled for session ${sessionID} via ${results.length - failed.length} bridge(s)${exact ? "" : " (root-key fallback)"}`,
46+
);
3247
}
3348
}

0 commit comments

Comments
 (0)