fix(queue): propagate transfer error + tighten WSError narrowing

- `Service.TransferSession` now returns an error so callers can fail
  closed instead of silently leaving entries orphaned on the previous
  session. The two existing call sites pick the right policy:
  `switchSessionForStep` (reusing an existing session) propagates the
  error so the workflow switch aborts; the new-session path logs and
  continues, since the new session is already committed and rolling
  back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
  without a `code` field are still mapped to a meaningful `Error.message`
  via `rethrowQueueError` (instead of "[object Object]"). Native
  `Error` instances explicitly skip the WS-error branch so they pass
  through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
  so a missing throw fails the test instead of silently passing.
This commit is contained in:
José Almeida
2026-05-10 21:22:00 +01:00
parent a32ad9a63d
commit 83abe6d743
4 changed files with 37 additions and 7 deletions
@@ -568,7 +568,13 @@ func (s *Service) reuseSessionForStep(ctx context.Context, taskID string, curren
// and gets delivered to the wrong agent the next time that previous
// session is reused (e.g. on the on_turn_complete bounce back).
if s.messageQueue != nil {
s.messageQueue.TransferSession(ctx, currentSession.ID, existing.ID)
if err := s.messageQueue.TransferSession(ctx, currentSession.ID, existing.ID); err != nil {
// Fail closed: the workflow switch reuses an existing session, but
// orphaning a queued hand-off prompt on the previous session would
// silently misroute the next prompt. Stop here and surface the
// error so the caller can decide whether to retry.
return nil, fmt.Errorf("transfer queued state to reused session: %w", err)
}
}
s.completeAndStopSession(ctx, taskID, currentSession)
@@ -650,7 +656,17 @@ func (s *Service) createNewSessionForStep(ctx context.Context, taskID string, cu
// pending move from the old session to the new one — the queue is keyed by
// session ID, and without this the prompt would never reach the new agent.
if s.messageQueue != nil {
s.messageQueue.TransferSession(ctx, currentSession.ID, newSession.ID)
if err := s.messageQueue.TransferSession(ctx, currentSession.ID, newSession.ID); err != nil {
s.logger.Error("transfer queue to new session failed; queued prompts on the previous session will not be drained",
zap.String("from_session_id", currentSession.ID),
zap.String("to_session_id", newSession.ID),
zap.Error(err))
// Continue anyway: the new session is already created and committed
// upstream. Failing closed here would leave the workflow in a
// half-switched state (new session exists but caller thinks it
// failed). The error is surfaced via logs and the orphaned entries
// stay safely in the old session for manual recovery.
}
}
// Promote the new session to primary so it's loaded when navigating back to this task.
@@ -176,19 +176,22 @@ func (s *Service) GetStatus(ctx context.Context, sessionID string) *QueueStatus
}
// TransferSession moves any queued messages and pending move from one session
// to another. Used by workflow session switches (when a target step has a
// different agent profile and switchSessionForStep creates a new session).
func (s *Service) TransferSession(ctx context.Context, oldSessionID, newSessionID string) {
// to another. Used by workflow session switches. Returns an error so the
// caller can fail closed instead of silently leaving entries orphaned on the
// old session — a transfer that no-ops without a signal would let the workflow
// step move forward while the queue sticks behind.
func (s *Service) TransferSession(ctx context.Context, oldSessionID, newSessionID string) error {
if err := s.repo.TransferSession(ctx, oldSessionID, newSessionID); err != nil {
s.logger.Error("transfer session failed",
zap.String("from_session_id", oldSessionID),
zap.String("to_session_id", newSessionID),
zap.Error(err))
return
return err
}
s.logger.Info("transferred queue between sessions",
zap.String("from_session_id", oldSessionID),
zap.String("to_session_id", newSessionID))
return nil
}
// SetPendingMove records a pending move for a session (replaces any existing one).
@@ -11,6 +11,7 @@ describe("rethrowQueueError", () => {
}),
).toThrow(QueueFullError);
expect.assertions(5);
try {
rethrowQueueError({
code: "queue_full",
@@ -18,6 +19,7 @@ describe("rethrowQueueError", () => {
});
} catch (err) {
const qf = err as QueueFullError;
expect(qf).toBeInstanceOf(QueueFullError);
expect(qf.queueSize).toBe(9);
expect(qf.max).toBe(10);
expect(qf.code).toBe("queue_full");
@@ -25,10 +27,12 @@ describe("rethrowQueueError", () => {
});
it("defaults missing queue_size / max to 0 when details are sparse", () => {
expect.assertions(3);
try {
rethrowQueueError({ code: "queue_full" });
} catch (err) {
const qf = err as QueueFullError;
expect(qf).toBeInstanceOf(QueueFullError);
expect(qf.queueSize).toBe(0);
expect(qf.max).toBe(0);
}
+8 -1
View File
@@ -33,7 +33,14 @@ type WSError = {
};
function asWSError(err: unknown): WSError | undefined {
if (typeof err === "object" && err !== null && "code" in err) {
// Some WS error payloads omit `code` and only carry `message`/`details`;
// narrowing on `code` alone would drop those and stringify the whole object
// as the eventual Error message ("[object Object]"). Real Error instances
// are skipped so they pass through unchanged in `rethrowQueueError`.
if (typeof err !== "object" || err === null || err instanceof Error) {
return undefined;
}
if ("code" in err || "message" in err || "details" in err) {
return err as WSError;
}
return undefined;