fix(serve): apply auto-fixes from /review (#4113)

- canonicalizeWorkspace: narrow catch to ENOENT only, propagate other filesystem errors
- listWorkspaceSessions: add fast-path string equality to avoid realpathSync on every poll
- GET /workspace/:id/sessions: return 400 workspace_mismatch for cross-workspace queries
- SessionNotFoundError: accept optional extra message; clarify agent-crash-on-spawn case
- requireWorkspaceCwd: distinguish empty-string (post-§02 bug) from absent (pre-§02 daemon)
This commit is contained in:
Shaojin Wen 2026-05-15 08:08:44 +08:00
parent b34a28bfca
commit 0c6e963cd2
3 changed files with 47 additions and 11 deletions

View file

@ -223,8 +223,8 @@ export interface HttpAcpBridge {
*/
export class SessionNotFoundError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(`No session with id "${sessionId}"`);
constructor(sessionId: string, extra?: string) {
super(`No session with id "${sessionId}"` + (extra ? `. ${extra}` : ''));
this.name = 'SessionNotFoundError';
this.sessionId = sessionId;
}
@ -1680,12 +1680,14 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
// continuation running (e.g. channel.exited firing during
// a crash spawn, or a direct bridge.killSession call from
// outside the route handler). In those cases byId.get()
// returned undefined; we'd otherwise return
// `{ attached: true, sessionId: <zombie> }` and every
// subsequent prompt/cancel call would 404. Fail loud
// instead so the caller can retry into a fresh spawn.
// returned undefined. Fail loud with a descriptive error
// so the caller can distinguish "immediate agent death"
// from a stale sessionId and retry into a fresh spawn.
if (!attachedEntry) {
throw new SessionNotFoundError(session.sessionId);
throw new SessionNotFoundError(
session.sessionId,
'the agent child likely crashed during initialization — retry to spawn a new session',
);
}
if (req.modelServiceId) {
// Same swallow as above — we picked up an in-flight
@ -1897,7 +1899,16 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
listWorkspaceSessions(workspaceCwd) {
if (!path.isAbsolute(workspaceCwd)) return [];
const key = canonicalizeWorkspace(workspaceCwd);
// fast-path: under §02 single-workspace, string equality
// with boundWorkspace avoids a realpathSync syscall on
// every poll. If the literal doesn't match, canonicalize
// to handle symlink aliases; if that still doesn't match,
// this daemon doesn't own the workspace.
const key =
workspaceCwd === boundWorkspace
? boundWorkspace
: canonicalizeWorkspace(workspaceCwd);
if (key !== boundWorkspace) return [];
const out: BridgeSessionSummary[] = [];
for (const entry of byId.values()) {
if (entry.workspaceCwd === key) {
@ -2321,8 +2332,19 @@ export function canonicalizeWorkspace(p: string): string {
// entire bridge-side path resolution anyway, but if Stage 2
// ever lands without that change, switch to the async version.
return realpathSync.native(resolved);
} catch {
return resolved;
} catch (err) {
// Only fall back to path.resolve for ENOENT (path doesn't exist
// yet). Other filesystem errors (EACCES, EIO, ELOOP) should
// propagate — swallowing them would hide transient I/O failures
// behind misleading workspace_mismatch rejections.
if (
err &&
typeof err === 'object' &&
(err as { code?: unknown }).code === 'ENOENT'
) {
return resolved;
}
throw err;
}
}

View file

@ -434,6 +434,18 @@ export function createServeApp(
.json({ error: '`:id` must decode to an absolute workspace path' });
return;
}
// #3803 §02: reject cross-workspace queries so orchestrators
// don't mistake "no sessions here" for "workspace is idle".
const key = canonicalizeWorkspace(workspaceCwd);
if (key !== boundWorkspace) {
res.status(400).json({
error: `Workspace mismatch: daemon is bound to "${boundWorkspace}"`,
code: 'workspace_mismatch',
boundWorkspace,
requestedWorkspace: key,
});
return;
}
const sessions = bridge.listWorkspaceSessions(workspaceCwd);
res.status(200).json({ sessions });
});

View file

@ -91,7 +91,9 @@ export function requireWorkspaceCwd(caps: DaemonCapabilities): string {
if (typeof caps.workspaceCwd !== 'string' || caps.workspaceCwd.length === 0) {
throw new DaemonCapabilityMissingError(
'workspaceCwd',
'introduced in #3803 §02 (1 daemon = 1 workspace)',
caps.workspaceCwd === ''
? 'daemon returned an empty workspaceCwd (post-§02 daemon with a bug)'
: 'daemon predates #3803 §02 (1 daemon = 1 workspace); upgrade it',
);
}
return caps.workspaceCwd;