From e3c461a7e326a58d354b7d5dfa35e2eba19f87bf Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:50:10 -0800 Subject: [PATCH] fix(startup): tell a seat that starts with held messages about them (#2437) A seat that starts with held messages is now told about them once: it gets one notice saying how many were kept and not delivered, and to read them with `rig seat held-messages `. The same line appears in the launch's warnings. Nothing is replayed. Handover and runtime-switch successors don't reach this point yet, and a follow-up gives them the same notice. Reported in #2431 by @billbenetti. --- .../daemon/src/domain/startup-orchestrator.ts | 11 +++++++++++ .../daemon/test/startup-orchestrator.test.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/daemon/src/domain/startup-orchestrator.ts b/packages/daemon/src/domain/startup-orchestrator.ts index ad98b29e5..020525141 100644 --- a/packages/daemon/src/domain/startup-orchestrator.ts +++ b/packages/daemon/src/domain/startup-orchestrator.ts @@ -31,6 +31,7 @@ import { join } from "node:path"; import { guidanceFileIsLink, readFootprintAnswer, readGuidanceFile, routerLineSkipReason, type FootprintAnswer, type RouterLineWriteResult } from "./footprint-choice.js"; import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID, describeSkillRouterFootprint, seatSkillsLineEdited, refusedSeatSkillsRootForNode, seatCatalogKeptNote, seatRouterKeptNote, seatSkillPathsForNode, seatSkillPluginDirForNode, writeSeatSkillCatalog, writeSeatSkillRouter } from "./seat-skill-storage.js"; import { recordConversationPromptForNode, recordForkConversationPromptForNode, seatLaunchAdditionsForLaunch, systemPromptPendingNoteForNode } from "./seat-launch-additions.js"; +import { OutboxHandler } from "./outbox-handler.js"; // Why a startup prompt's submission could not be confirmed, as told to the operator when the // proof line is held back. @@ -1021,6 +1022,16 @@ export class StartupOrchestrator { : undefined; this.eventBus.emit({ type: "node.startup_ready", rigId: input.rigId, nodeId: input.nodeId, ...(submission ? { submission } : {}) }); + // Held messages are never replayed, so a seat that starts with some is told once, as the launch is (#2431). + let heldMessages = 0; + try { heldMessages = new OutboxHandler(this.db).retentionUsage(input.nodeId).count; } catch { /* no held-message store: nothing to tell */ } + if (heldMessages && input.binding.tmuxSession) { + const seat = input.sessionName ?? input.binding.tmuxSession; + const notice = `${heldMessages} held message${heldMessages === 1 ? " was" : "s were"} kept for this seat and not delivered: read ${heldMessages === 1 ? "it" : "them"} with rig seat held-messages ${seat}.`; + warnings.push(notice); + await this.sessionTransport.send(input.binding.tmuxSession, notice, { openrigNotice: true }).catch(() => undefined); + } + return { ok: true, startupStatus: "ready", continuityOutcome, ...(submission ? { submission } : {}), ...(routerLine ? { routerLine } : {}), ...(resumedProcess ? { resumedProcess } : {}) }; } diff --git a/packages/daemon/test/startup-orchestrator.test.ts b/packages/daemon/test/startup-orchestrator.test.ts index 47d605108..82e1f9835 100644 --- a/packages/daemon/test/startup-orchestrator.test.ts +++ b/packages/daemon/test/startup-orchestrator.test.ts @@ -6,6 +6,10 @@ import { COMMUNITY_HARNESS_RUNG_INVENTORY, COMMUNITY_RUNNER_RUNG_INVENTORY, runt import { EventBus } from "../src/domain/event-bus.js"; import { RigRepository } from "../src/domain/rig-repository.js"; import { StartupOrchestrator, isRecoverableStartupFailure, type StartupInput, type StartupResult } from "../src/domain/startup-orchestrator.js"; +import { OutboxHandler } from "../src/domain/outbox-handler.js"; +import { SessionTransport } from "../src/domain/session-transport.js"; +import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js"; +import { seatDeliveryGuardSchema } from "../src/db/migrations/087_seat_delivery_guard.js"; import type { RuntimeAdapter, NodeBinding, ResolvedStartupFile, ProjectionResult, StartupDeliveryResult, ReadinessResult } from "../src/domain/runtime-adapter.js"; import { resolveConcreteHint } from "../src/domain/runtime-adapter.js"; import type { ProjectionPlan } from "../src/domain/projection-planner.js"; @@ -728,6 +732,21 @@ describe("StartupOrchestrator", () => { expect(result.warnings).toEqual(["AGENTS.md is untracked", "plugin exclude skipped"]); }); + it("tells a seat that starts with held messages, once, and names it in the launch's warnings (#2431)", async () => { + const seed = seedSession(); + db.exec(outboxEntriesSchema.sql); db.exec(seatDeliveryGuardSchema.sql); // as the guard suites add the held-message store + new OutboxHandler(db).retain({ outboxId: "held-1", senderSession: "planner@test-rig", destinationSession: "r01-impl", body: "Real work for you" }, + { nodeId: seed.nodeId, session: "r01-impl", occupant: null, pane: null }); + const send = vi.spyOn(SessionTransport.prototype, "send").mockResolvedValue({ ok: true, sent: true } as never); + try { + const result = await createOrchestrator().startNode(makeInput(seed)); + const notice = "1 held message was kept for this seat and not delivered: read it with rig seat held-messages r01-impl."; + expect(result).toMatchObject({ ok: true, startupStatus: "ready" }); + expect(result.warnings).toContain(notice); + expect(send.mock.calls.filter(([, text]) => text === notice)).toEqual([["r01-impl", notice, { openrigNotice: true }]]); + } finally { send.mockRestore(); } + }); + it("keeps a challenge-only transport failure best-effort", async () => { const seed = seedSession(); const tmux = mockTmux({ sendText: vi.fn(async () => ({ ok: false as const, message: "fixture transport failure" })) });