Merge pull request #8061 from SivanCola/fix/session-inbox-durability

Persist Session Inbox for durable queued instructions / 持久化会话收件箱以恢复排队指令
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-10 18:03:24 +08:00
commit 1f1e2e3aff
77 files changed
+8967 -641

No files matched your search

@@ -1112,6 +1112,25 @@ console.log("\ncomposer goal toggle");
{
const dom = installDom();
let nextInboxID = 0;
const steerItemIDs: string[] = [];
const deletedItemIDs: string[] = [];
mockApp({
InboxSnapshot: async () => ({
revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
}),
EnqueueInboxFollowup: async () => ({
itemId: `durable-${++nextInboxID}`, disposition: "queued_followup", position: nextInboxID, paused: false,
}),
SteerInboxItem: async (_tabID, itemID) => {
steerItemIDs.push(itemID);
return { itemId: itemID, disposition: "steer_accepted", position: 1, paused: false };
},
DeleteInboxItem: async (_tabID, itemID) => {
deletedItemIDs.push(itemID);
},
});
const { root, calls, rerender } = await renderComposer({
running: true,
onSend: (displayText, submitText) => {
@@ -1150,8 +1169,9 @@ console.log("\ncomposer goal toggle");
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
eq(steerItemIDs.join(","), "durable-1", "guide attempts admission with the existing durable item ID");
eq(calls.send.join(","), "", "guide never opens a duplicate frontend submit");
ok(document.querySelector(".composer-guidance-item") === null, "accepted durable steer clears after backend admission");
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
@@ -1166,6 +1186,7 @@ console.log("\ncomposer goal toggle");
dismissButton.click();
await flushTimers();
});
eq(deletedItemIDs.join(","), "durable-2", "dismiss deletes the durable backend item before clearing the shelf");
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
@@ -1196,6 +1217,20 @@ console.log("\ncomposer goal toggle");
{
const dom = installDom();
const steerItemIDs: string[] = [];
mockApp({
InboxSnapshot: async () => ({
revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
}),
EnqueueInboxFollowup: async () => ({
itemId: "durable-activating", disposition: "queued_followup", position: 1, paused: false,
}),
SteerInboxItem: async (_tabID, itemID) => {
steerItemIDs.push(itemID);
return { itemId: itemID, disposition: "steer_accepted", position: 1, paused: false };
},
});
const { root, calls, rerender } = await renderComposer({
running: true,
submitDisabled: true,
@@ -1223,7 +1258,8 @@ console.log("\ncomposer goal toggle");
await flushTimers();
});
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
eq(steerItemIDs.join(","), "durable-activating", "queued durable item can be guided while controllerReady is false");
eq(calls.send.join(","), "", "controllerReady gap does not create a duplicate submit");
await act(async () => {
root.unmount();
@@ -1233,16 +1269,35 @@ console.log("\ncomposer goal toggle");
{
// A backend steer rejection means the turn crossed its final admission
// boundary. Keep the guidance item, then submit it as a normal follow-up
// after TurnDone instead of treating the rejected call as consumed.
// boundary. The same durable item becomes a follow-up; the Controller, not
// the browser, owns its later FIFO dispatch and ack.
const dom = installDom();
let steerAttempts = 0;
let backendQueued = false;
mockApp({
InboxSnapshot: async () => ({
revision: backendQueued ? 1 : 2,
paused: false,
recovered: false,
items: backendQueued ? [{
id: "durable-late", intent: "followup", state: "queued", preview: "preserve this late guidance", byteSize: 27, position: 1,
}] : [],
itemsCount: backendQueued ? 1 : 0,
bytes: backendQueued ? 27 : 0,
maxItems: 64,
maxBytes: 64 * 1024 * 1024,
}),
EnqueueInboxFollowup: async () => {
backendQueued = true;
return { itemId: "durable-late", disposition: "queued_followup", position: 1, paused: false };
},
SteerInboxItem: async (_tabID, itemID) => {
steerAttempts += 1;
return { itemId: itemID, disposition: "queued_followup", position: 1, paused: false };
},
});
const { root, calls, rerender } = await renderComposer({
running: true,
onSteer: async () => {
steerAttempts += 1;
throw new Error("turn ended before guidance could be applied");
},
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
@@ -1269,10 +1324,10 @@ console.log("\ncomposer goal toggle");
eq(calls.send.length, 0, "rejected steer does not open a provider turn");
ok(document.querySelector(".composer-guidance-item") !== null, "rejected steer remains queued");
backendQueued = false; // Controller dispatched and durably acked after TurnDone.
await rerender({ running: false });
await waitFor("rejected steer sent as follow-up", () => calls.send.length === 1);
eq(calls.send[0], "preserve this late guidance", "late guidance becomes the next explicit user turn");
ok(document.querySelector(".composer-guidance-item") === null, "follow-up clears only after successful send");
await waitFor("acked durable follow-up removed from shelf", () => document.querySelector(".composer-guidance-item") === null);
eq(calls.send.length, 0, "late durable follow-up is never resubmitted by the frontend");
await act(async () => {
root.unmount();
@@ -1281,67 +1336,29 @@ console.log("\ncomposer goal toggle");
}
{
// TurnDone can reach the frontend before an in-flight TrySteer rejection.
// Once that rejection settles, the preserved guidance must be re-evaluated
// as the next explicit turn instead of remaining stranded on the shelf.
// A message queued while a turn is running remains durable. Natural
// completion is dispatched by the Controller, so the frontend must only
// reconcile the eventual durable ack and never call onSend itself.
const dom = installDom();
let steerAttempts = 0;
let rejectSteer: (error: Error) => void = () => {};
const pendingSteer = new Promise<void>((_, reject) => {
rejectSteer = reject;
});
pendingSteer.catch(() => {});
const { root, calls, rerender } = await renderComposer({
running: true,
onSteer: () => {
steerAttempts += 1;
return pendingSteer;
},
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
return Promise.resolve();
let backendQueued = false;
mockApp({
InboxSnapshot: async () => ({
revision: backendQueued ? 1 : 2,
paused: false,
recovered: false,
items: backendQueued ? [{
id: "durable-natural", intent: "followup", state: "queued", preview: "keep going after this finishes", byteSize: 30, position: 1,
}] : [],
itemsCount: backendQueued ? 1 : 0,
bytes: backendQueued ? 30 : 0,
maxItems: 64,
maxBytes: 64 * 1024 * 1024,
}),
EnqueueInboxFollowup: async () => {
backendQueued = true;
return { itemId: "durable-natural", disposition: "queued_followup", position: 1, paused: false };
},
});
await rerender({ insertRequest: { id: 72, text: "retry after TurnDone wins", mode: "replace" } });
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await act(async () => {
sendButton.click();
await flushTimers();
});
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("deferred steer guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(steerAttempts, 1, "deferred guidance starts one strict steer admission");
await rerender({ running: false });
eq(calls.send.length, 0, "TurnDone waits for the in-flight steer result before follow-up");
await act(async () => {
rejectSteer(new Error("turn ended before guidance could be applied"));
await flushTimers();
});
await waitFor("deferred rejected steer sent as follow-up", () => calls.send.length === 1);
eq(calls.send[0], "retry after TurnDone wins", "deferred rejection becomes the next explicit user turn");
eq(steerAttempts, 1, "deferred rejection is not retried as another steer");
ok(document.querySelector(".composer-guidance-item") === null, "deferred follow-up clears after successful send");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// Reproduces #6210: a message queued while a turn is running, without the
// explicit "guide" steer click, must not vanish when the turn ends on its
// own — it is the user's next turn, so it should send automatically.
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
onSend: (displayText, submitText) => {
@@ -1363,12 +1380,12 @@ console.log("\ncomposer goal toggle");
eq(calls.send.length, 0, "queuing while running does not send immediately");
ok(document.querySelector(".composer-guidance-item") !== null, "queued message shows in the guidance shelf");
backendQueued = false; // Controller completed and acked the next FIFO turn.
await rerender({ running: false });
await waitFor("queued guidance auto-sent on natural completion", () => calls.send.length === 1);
await waitFor("durable guidance ack reconciled", () => document.querySelector(".composer-guidance-item") === null);
eq(calls.send.join(","), "keep going after this finishes", "queued guidance is sent automatically once the turn ends naturally, not discarded");
eq(calls.submit.join(","), "keep going after this finishes", "auto-sent guidance submits the same text it was queued with");
ok(document.querySelector(".composer-guidance-item") === null, "guidance shelf clears once the queued message is sent");
eq(calls.send.length, 0, "natural completion never triggers a duplicate frontend submit");
eq(calls.submit.length, 0, "durable body remains backend-owned through dispatch");
await act(async () => {
root.unmount();
@@ -1377,19 +1394,29 @@ console.log("\ncomposer goal toggle");
}
{
// A normal follow-up failure must remain user-controlled. The steer-race
// re-arm above must not turn ordinary onSend failures into a retry loop.
// Stop passes only this Composer's durable IDs to the controller, then folds
// their visible text back into the draft instead of leaving a hidden turn
// that could run after cancellation.
const dom = installDom();
let followupAttempts = 0;
let cancelledItemIDs: string[] = [];
mockApp({
InboxSnapshot: async () => ({
revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
}),
EnqueueInboxFollowup: async () => ({
itemId: "durable-cancel", disposition: "queued_followup", position: 1, paused: false,
}),
});
const { root, rerender } = await renderComposer({
running: true,
onSend: () => {
followupAttempts += 1;
return Promise.reject(new Error("controller is not ready"));
onCancel: (itemIDs = []) => {
cancelledItemIDs = itemIDs;
return undefined;
},
});
await rerender({ insertRequest: { id: 81, text: "keep failed follow-up", mode: "replace" } });
await rerender({ insertRequest: { id: 81, text: "keep cancelled follow-up", mode: "replace" } });
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await act(async () => {
@@ -1397,14 +1424,16 @@ console.log("\ncomposer goal toggle");
await flushTimers();
});
await rerender({ running: false });
await waitFor("queued follow-up attempts once", () => followupAttempts === 1);
const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stopButton) throw new Error("running composer stop button did not render");
await act(async () => {
await flushTimers();
stopButton.click();
await flushTimers();
});
eq(followupAttempts, 1, "failed normal follow-up is not retried automatically");
ok(document.querySelector(".composer-guidance-item") !== null, "failed normal follow-up remains on the shelf");
eq(cancelledItemIDs.join(","), "durable-cancel", "stop scopes backend discard to the Composer-owned durable ID");
ok(document.querySelector(".composer-guidance-item") === null, "stop clears the local shelf after handing off durable IDs");
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
ok(textarea?.value.includes("keep cancelled follow-up") === true, "stop restores queued guidance to the editable draft");
await act(async () => {
root.unmount();
@@ -1413,11 +1442,28 @@ console.log("\ncomposer goal toggle");
}
{
// #6210 follow-up: if the turn ends naturally while the controller is
// still activating/hydrating (submitDisabled), onSend would silently
// no-op — auto-send must wait for submitDisabled to clear instead of
// firing into that window and losing the queued message anyway.
// Controller activation state is irrelevant to browser-side dispatch now:
// the durable item remains visible until an authoritative consume/ack event.
const dom = installDom();
let backendReadyQueued = false;
mockApp({
InboxSnapshot: async () => ({
revision: backendReadyQueued ? 1 : 0,
paused: false,
recovered: false,
items: backendReadyQueued ? [{
id: "durable-ready", intent: "followup", state: "queued", preview: "keep going once ready", byteSize: 21, position: 1,
}] : [],
itemsCount: backendReadyQueued ? 1 : 0,
bytes: backendReadyQueued ? 21 : 0,
maxItems: 64,
maxBytes: 64 * 1024 * 1024,
}),
EnqueueInboxFollowup: async () => {
backendReadyQueued = true;
return { itemId: "durable-ready", disposition: "queued_followup", position: 1, paused: false };
},
});
const { root, calls, rerender } = await renderComposer({
running: true,
submitDisabled: false,
@@ -1448,10 +1494,14 @@ console.log("\ncomposer goal toggle");
ok(document.querySelector(".composer-guidance-item") !== null, "queued message stays on the shelf while not ready");
await rerender({ submitDisabled: false });
await waitFor("queued guidance auto-sent once the controller becomes ready", () => calls.send.length === 1);
await act(async () => {
await flushTimers();
});
eq(calls.send.length, 0, "controller readiness never triggers frontend auto-submit");
ok(document.querySelector(".composer-guidance-item") !== null, "durable item remains until backend consume/ack");
eq(calls.send.join(","), "keep going once ready", "queued guidance sends once submitDisabled clears, instead of being lost");
ok(document.querySelector(".composer-guidance-item") === null, "guidance shelf clears once the delayed send completes");
await rerender({ guidanceConsumedKey: "durable-ready-acked", guidanceConsumedText: "keep going once ready" });
ok(document.querySelector(".composer-guidance-item") === null, "backend consume event clears the acknowledged item");
await act(async () => {
root.unmount();
@@ -2241,16 +2291,23 @@ console.log("\ncomposer goal toggle");
}
{
// An entity-only submit while a turn is running must queue as guidance,
// rendered with its slash form — not be dropped silently while
// clearSubmittedDraft wipes the composer.
// Entity-only input must remain structured while queued during a run.
const dom = installDom();
let queued: { submit?: string; invocations?: StructuredInvocationSubmit["invocations"] } = {};
mockApp({
Commands: async () => [
{ name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
InboxSnapshot: async () => ({
revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
}),
EnqueueInboxFollowupWithInvocations: async (_tabId, _display, input, invocations) => {
queued = { submit: input, invocations };
return { itemId: "durable-entity", disposition: "queued_followup", position: 1, paused: false };
},
});
const { root, calls, rerender } = await renderComposer();
await replaceComposerDraft(rerender, 4000, "/writing-plans");
@@ -2263,7 +2320,6 @@ console.log("\ncomposer goal toggle");
});
const queueRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!queueRichInput) throw new Error("rich composer did not render for the running-queue entity");
await rerender({ running: true });
await act(async () => {
queueRichInput.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
@@ -2274,8 +2330,9 @@ console.log("\ncomposer goal toggle");
document.querySelector(".composer-guidance-item__text")?.textContent?.includes("/superpowers:writing-plans") === true,
"the queued guidance shows the entity's slash form instead of dropping it silently",
);
eq(`${queued.invocations?.[0]?.name}:${queued.invocations?.[0]?.kind}`, "superpowers:writing-plans:skill", "queued guidance preserves the selected skill invocation");
eq(queued.submit, "", "entity-only guidance keeps an empty explicit task instead of degrading to slash text");
ok(document.querySelector(".composer__rich-input") === null, "queueing an entity-only submit clears the draft");
await act(async () => {
root.unmount();
});
@@ -0,0 +1,232 @@
// Run: tsx src/__tests__/composer-inbox-recovery.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, check: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
if (check()) return;
await act(async () => { await flushTimers(); });
}
ok(false, label);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.InputEvent = dom.window.InputEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.File = dom.window.File;
globalThis.FileReader = dom.window.FileReader;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
globalThis.ResizeObserver = TestResizeObserver;
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
return dom;
}
function installBridgeApp(methods: Record<string, unknown>) {
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
main: {
App: {
Commands: async () => [],
Models: async () => [],
ModelsForTab: async () => [],
ListDir: async () => [],
ListDirForTab: async () => [],
SearchFileRefs: async () => [],
SearchFileRefsForTab: async () => [],
...methods,
},
},
};
}
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof Composer>[0] = {
running: false,
collaborationMode: "normal" as CollaborationMode,
toolApprovalMode: "ask" as ToolApprovalMode,
tokenMode: "full" as TokenMode,
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
tabId: "tab-a",
sessionKey: "session-a",
onSend: () => {},
onCancel: () => undefined,
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: () => {},
onSetToolApprovalMode: () => {},
onToggleYoloApprovalMode: () => {},
onClearGoal: () => {},
onSwitchModel: () => {},
onSetEffort: () => {},
onSetTokenMode: () => {},
ready: true,
...props,
};
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<Composer {...currentProps} />
</ToastProvider>
</LocaleProvider>,
);
await flushTimers();
});
};
await paint();
return { root, rerender: paint };
}
function recoveredSnapshot(count = 3) {
const items = Array.from({ length: count }, (_, index) => ({
id: `recovered-${index + 1}`,
intent: "followup",
state: "uncertain",
preview: `Recovered instruction ${index + 1}`,
byteSize: 128,
position: index + 1,
}));
return {
revision: 1,
paused: true,
recovered: true,
recoveredCount: count,
items,
itemsCount: items.length,
bytes: items.length * 128,
maxItems: 64,
maxBytes: 64 * 1024 * 1024,
};
}
console.log("\ncomposer inbox recovery");
{
const dom = installDom();
let snapshot = recoveredSnapshot();
const pauseCalls: Array<{ tabId: string; paused: boolean }> = [];
installBridgeApp({
InboxSnapshot: async () => snapshot,
SetInboxPaused: async (tabId: string, paused: boolean) => {
pauseCalls.push({ tabId, paused });
if (!paused) snapshot = { ...snapshot, paused: false, recovered: false };
},
});
const { root } = await renderComposer();
await waitFor("recovery banner rendered", () => document.querySelector(".composer-inbox-recovery") !== null);
ok(document.querySelector(".composer-inbox-recovery")?.textContent?.includes("Recovered 3 pending instructions") === true, "banner reports recovered count");
ok(document.querySelectorAll(".composer-guidance-item").length === 2, "queue starts with bounded preview");
const review = document.querySelector(".composer-inbox-recovery .btn") as HTMLButtonElement;
await act(async () => { review.click(); await flushTimers(); });
ok(document.querySelectorAll(".composer-guidance-item").length === 3, "review action expands the recovered queue");
const buttons = Array.from(document.querySelectorAll(".composer-inbox-recovery .btn")) as HTMLButtonElement[];
await act(async () => { buttons[2].click(); await flushTimers(); });
ok(pauseCalls.at(-1)?.paused === true, "keep paused confirms the server-side pause");
ok(document.querySelector(".composer-inbox-recovery") !== null, "keep paused preserves the resume path");
ok(buttons[2].textContent === "Paused" && buttons[2].disabled, "confirmed pause is visible and idempotent");
await act(async () => { buttons[1].click(); await flushTimers(); });
ok(pauseCalls.at(-1)?.paused === false, "continue resumes the durable inbox");
await waitFor("recovery banner cleared after resume", () => document.querySelector(".composer-inbox-recovery") === null);
ok(document.querySelector(".composer-inbox-recovery") === null, "successful resume clears the recovery banner");
await act(async () => { root.unmount(); });
dom.window.close();
}
{
const dom = installDom();
let resolveTabA!: (value: ReturnType<typeof recoveredSnapshot>) => void;
const tabAPromise = new Promise<ReturnType<typeof recoveredSnapshot>>((resolve) => { resolveTabA = resolve; });
installBridgeApp({
InboxSnapshot: async (tabId: string) => tabId === "tab-a"
? tabAPromise
: { ...recoveredSnapshot(0), paused: false, recovered: false },
SetInboxPaused: async () => {},
});
const { root, rerender } = await renderComposer();
await rerender({ tabId: "tab-b", sessionKey: "session-b" });
resolveTabA(recoveredSnapshot(2));
await act(async () => { await flushTimers(); });
ok(document.querySelector(".composer-inbox-recovery") === null, "stale snapshot cannot show recovery controls on another session");
await act(async () => { root.unmount(); });
dom.window.close();
}
if (failed > 0) {
process.stderr.write(`\n${failed} failed, ${passed} passed\n`);
process.exit(1);
}
process.stdout.write(`\n${passed} passed\n`);
@@ -387,6 +387,33 @@ console.log("\ncomposer session draft");
// running source session.
const dom = installDom();
const sent: Array<{ tab: string; text: string }> = [];
const inboxByTab = new Map<string, Array<{ id: string; preview: string }>>();
installBridgeApp({
InboxSnapshot: async (tabId: string) => {
const items = (inboxByTab.get(tabId) ?? []).map((item, position) => ({
...item,
intent: "followup",
state: "queued",
byteSize: item.preview.length,
position: position + 1,
}));
return {
revision: items.length,
paused: false,
recovered: false,
items,
itemsCount: items.length,
bytes: items.reduce((sum, item) => sum + item.byteSize, 0),
maxItems: 64,
maxBytes: 64 * 1024 * 1024,
};
},
EnqueueInboxFollowup: async (tabId: string, display: string) => {
const item = { id: "durable-tab-a", preview: display };
inboxByTab.set(tabId, [...(inboxByTab.get(tabId) ?? []), item]);
return { itemId: item.id, disposition: "queued_followup", position: 1, paused: false };
},
});
const { root, rerender } = await renderComposer({
running: true,
tabId: "tab-a",
@@ -424,13 +451,13 @@ console.log("\ncomposer session draft");
});
ok(document.querySelector(".composer-guidance-item") !== null, "session A restores its queued guidance after switching back");
inboxByTab.delete("tab-a"); // Controller dispatched and durably acked it.
await rerender({ running: false });
await act(async () => {
await flushTimers();
});
eq(sent.length, 1, "session A sends its queued guidance when its own turn finishes");
eq(sent[0]?.tab, "tab-a", "restored guidance stays routed to session A");
eq(sent[0]?.text, "follow up in A", "restored guidance keeps its original text");
eq(sent.length, 0, "session A completion does not duplicate the Controller-owned follow-up");
ok(document.querySelector(".composer-guidance-item") === null, "session A clears the durable item after its backend ack");
await act(async () => {
root.unmount();
@@ -0,0 +1,25 @@
// Run: tsx src/__tests__/inbox-recovery-preview.test.ts
import assert from "node:assert/strict";
import { inboxRecoveryPreviewSnapshot, setInboxRecoveryPreviewPaused } from "../lib/inboxRecoveryPreview";
const paused = inboxRecoveryPreviewSnapshot(true);
assert.equal(paused.paused, true);
assert.equal(paused.recovered, true);
assert.equal(paused.recoveredCount, 30);
assert.equal(paused.itemsCount, 30);
assert.equal(paused.items.length, 30);
assert.equal(paused.bytes, 30 * 1024 * 1024);
assert.equal(new Set(paused.items.map((item) => item.id)).size, paused.items.length);
assert.ok(paused.items.every((item) => item.state === "uncertain" && item.intent === "followup"));
const resumed = inboxRecoveryPreviewSnapshot(false);
assert.equal(resumed.paused, false);
assert.equal(resumed.recovered, true);
setInboxRecoveryPreviewPaused(false);
assert.equal(inboxRecoveryPreviewSnapshot().paused, false);
setInboxRecoveryPreviewPaused(true);
assert.equal(inboxRecoveryPreviewSnapshot().paused, true);
process.stdout.write("inbox recovery preview: PASS\n");
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { coalescesQuery, invalidateSharedQuery, resetQueryCoalescing, shareQuery } from "../lib/queryCoalesce";
import { coalescesQuery, invalidateSharedQuery, maybeShare, resetQueryCoalescing, shareQuery } from "../lib/queryCoalesce";
function deferred<T>() {
let resolve!: (v: T) => void;
@@ -30,6 +30,17 @@ async function differentArgumentsStayIndependent() {
assert.equal(runs, 2, "another tab is another question");
}
async function mutationsInvalidateInflightAnswers() {
resetQueryCoalescing();
const stale = deferred<string>();
const first = shareQuery("MetaForTab", ["tab-1"], () => stale.promise);
await maybeShare("NewSessionForTab", ["tab-1"], async () => undefined);
const fresh = await shareQuery("MetaForTab", ["tab-1"], async () => "fresh");
assert.equal(fresh, "fresh", "a post-mutation query must not inherit the old session request");
stale.resolve("stale");
assert.equal(await first, "stale");
}
// A stale answer is worse than a slow one: a failure must not be inherited.
async function rejectionIsNotSharedOnward() {
resetQueryCoalescing();
@@ -71,6 +82,7 @@ function onlyReadOnlyQueriesAreCoalesced() {
const tests: Array<[string, () => unknown]> = [
["identical in-flight calls share one answer", identicalCallsInFlightShareOneAnswer],
["different arguments stay independent", differentArgumentsStayIndependent],
["mutations invalidate in-flight answers", mutationsInvalidateInflightAnswers],
["a rejection is not shared onward", rejectionIsNotSharedOnward],
["external state boundaries invalidate settled answers", invalidationStartsAFreshRead],
["settled tab lists do not cross mutations", settledTabListsAreNotShared],
+168 -124
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { ArrowRight, ArrowUp, AtSign, Check, ChevronDown, ChevronUp, ChevronsUpDown, CornerDownRight, Equal, Eye, FilePlus2, FileText, Flag, Folder, Gauge, Hash, List, MessageSquare, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
import { ArrowRight, ArrowUp, AtSign, Check, ChevronsUpDown, CornerDownRight, Equal, Eye, FilePlus2, FileText, Flag, Folder, Gauge, Hash, List, MessageSquare, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
import { asArray } from "../lib/array";
import { filterAtMatches } from "../lib/atMatches";
import { DedupIndex, sha256 } from "../lib/attachDedup";
import { app, onFilesDropped } from "../lib/bridge";
import { enqueueInboxGuidance } from "../lib/inboxSubmit";
import { canUsePromptHistory, composerEnterAction, composerEscapeAction, composerMenuKeyAction, insertComposerNewline, isFnKeyEvent, isImeKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
import { SPINNER_WORDS, useI18n, type Translator } from "../lib/i18n";
@@ -45,6 +46,7 @@ import { Markdown } from "./Markdown";
import { CodeViewer } from "./CodeViewer";
import { ContextWindowRing } from "./ContextWindowRing";
import { ImageViewer } from "./ImageViewer";
import type { PendingGuidance } from "./ComposerGuidanceShelf";
import {
RichComposerInput,
slashQueryAt,
@@ -94,19 +96,13 @@ const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
const ComposerGuidanceShelf = lazy(() => import("./ComposerGuidanceShelf").then((module) => ({ default: module.ComposerGuidanceShelf })));
type PastedBlock = {
label: string;
text: string;
};
type PendingGuidance = {
id: number;
text: string;
submitText: string;
structured?: StructuredInvocationSubmit;
};
type FileRefSearchCacheEntry = {
entries: DirEntry[];
cachedAt: number;
@@ -127,7 +123,7 @@ type ComposerDraft = {
savedText: string;
pendingGuidance: PendingGuidance[];
guidanceExpanded: boolean;
guidanceSendingId: number | null;
guidanceSendingId: string | null;
pendingPaste: number;
submitting: boolean;
};
@@ -583,7 +579,7 @@ export function Composer({
onSteer?: (submitText: string, tabId?: string) => void | Promise<void>;
// Returns the un-sent text when cancelling before the server replied (so it can
// be restored to the input); undefined for a normal cancel.
onCancel: () => string | undefined;
onCancel: (queuedItemIDs?: string[]) => string | undefined;
onCycleMode: () => void;
onSetMode: (mode: Mode) => void;
onSetCollaborationMode: (mode: CollaborationMode) => void;
@@ -711,13 +707,12 @@ export function Composer({
const [selectedTextRefs, setSelectedTextRefs] = useState<SelectedTextReference[]>([]);
const [pendingGuidance, setPendingGuidance] = useState<PendingGuidance[]>([]);
const [guidanceExpanded, setGuidanceExpanded] = useState(false);
const [guidanceSendingId, setGuidanceSendingId] = useState<number | null>(null);
const [guidanceSendingId, setGuidanceSendingId] = useState<string | null>(null);
const [guidanceRetryNonce, setGuidanceRetryNonce] = useState(0);
const [guidanceDraftKey, setGuidanceDraftKey] = useState(draftKey);
const pendingGuidanceRef = useRef<PendingGuidance[]>([]);
const guidanceExpandedRef = useRef(false);
const guidanceSendingIdRef = useRef<number | null>(null);
const nextGuidanceId = useRef(1);
const guidanceSendingIdRef = useRef<string | null>(null);
const [loadingPastChats, setLoadingPastChats] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [inputMenuPoint, setInputMenuPoint] = useState<ContextMenuPoint | null>(null);
@@ -1090,7 +1085,7 @@ export function Composer({
draftsBySessionRef.current[targetDraftKey] = draft;
};
const updateGuidanceSendingIdForDraft = (targetDraftKey: string, next: number | null) => {
const updateGuidanceSendingIdForDraft = (targetDraftKey: string, next: string | null) => {
if (targetDraftKey === activeDraftKeyRef.current) {
guidanceSendingIdRef.current = next;
setGuidanceSendingId(next);
@@ -1171,39 +1166,65 @@ export function Composer({
wasRunningByDraftRef.current[draftKey] = running;
}, [draftKey, running, text]);
// A message queued while a turn was running (without the explicit "guide"
// steer click) is the user's next turn, not scratch text to discard — send
// it once the turn is done. Gated on submitDisabled, not just running:
// if the turn ends while the controller is still activating/hydrating,
// App's onSend silently no-ops on !controllerReady, but sendQueuedGuidance
// still removes the item as if it had sent — so wait for submitDisabled to
// clear instead of firing into that no-op window (#6210 follow-up). Once
// both conditions hold, a successful send removes the head and starts a
// new turn, which flips `running` true then false again, re-running this
// effect to drain the shelf one item at a time; a failed send is left in
// place (dismissible via the trash button) rather than silently dropped.
// guidanceDraftKey identifies which session the rendered queue belongs to:
// during a tab switch React still renders once with the previous queue, and
// that stale render must never submit through the new session's onSend.
// Legacy/local preview items still need the old frontend-owned send path.
// Durable items are dispatched exactly once by the Controller after TurnDone;
// Durable items are dispatched and acknowledged only by the Controller.
// This compatibility path drains legacy local preview items, while the
// draft-key guard prevents a stale tab render from submitting through the
// newly selected session's onSend.
useEffect(() => {
// Never auto-send guidance while a decision surface owns the footer —
// the draft must stay intact until the user finishes the decision.
if (guidanceDraftKey !== draftKey || running || submitDisabled || suspendedByDecision) return;
const next = pendingGuidance[0];
if (next) void sendQueuedGuidance(next, draftKey);
if (next?.id.startsWith("local-")) void sendQueuedGuidance(next, draftKey);
}, [draftKey, guidanceDraftKey, guidanceRetryNonce, running, submitDisabled, pendingGuidance, suspendedByDecision]);
useEffect(() => {
if (guidanceDraftKey !== draftKey || !running || !guidanceQueuePreviewKey) return;
setGuidanceExpanded(false);
updatePendingGuidanceForDraft(
draftKey,
() =>
guidanceQueuePreviewKey
.split("\n")
.map((text) => ({ id: nextGuidanceId.current++, text, submitText: text })),
);
}, [draftKey, guidanceDraftKey, guidanceQueuePreviewKey, running]);
if (guidanceDraftKey !== draftKey) return;
let live = true;
const fallback = guidanceQueuePreviewKey
.split("\n")
.filter(Boolean)
.map((text, i) => ({ id: `local-${i}`, text, submitText: text }));
// Older Wails bindings and focused component tests do not expose the new
// inbox methods yet. Preserve their local preview contract.
if (typeof app.InboxSnapshot !== "function") {
updatePendingGuidanceForDraft(draftKey, () => fallback);
setGuidanceExpanded(false);
return;
}
// Refresh shelf from the durable server snapshot (metadata only). Running
// transitions are included so Controller-owned dispatch/ack is reflected.
void app.InboxSnapshot(tabId || "").then((snap) => {
if (!live) return;
const durable = (snap?.items ?? []).map((it: { id: string; preview: string; state?: string; intent?: string; source?: string }) => ({
id: it.id,
text: it.preview,
submitText: "",
state: it.state,
intent: it.intent,
source: it.source,
recoveredCount: snap?.paused && snap?.recovered
? (snap.recoveredCount || snap.items.length)
: undefined,
}));
updatePendingGuidanceForDraft(draftKey, () => durable.length > 0 ? durable : fallback);
setGuidanceExpanded(false);
}).catch(() => {
if (!live) return;
// Fallback: local preview lines without durable ids (will re-enqueue).
updatePendingGuidanceForDraft(
draftKey,
() =>
guidanceQueuePreviewKey
.split("\n")
.filter(Boolean)
.map((text, i) => ({ id: `local-${i}`, text, submitText: text })),
);
});
return () => { live = false; };
}, [draftKey, guidanceDraftKey, guidanceQueuePreviewKey, running, tabId, guidanceRetryNonce]);
useEffect(() => {
if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
@@ -2115,13 +2136,28 @@ export function Composer({
const guidanceText = displayText.trim() || (structured?.display.trim() ?? "");
const guidanceSubmitText = submitText.trim();
if (guidanceText) {
const id = nextGuidanceId.current++;
updatePendingGuidanceForDraft(submitDraftKey, (items) => [
...items,
{ id, text: guidanceText, submitText: guidanceSubmitText || guidanceText, structured },
]);
// Durable follow-up: only clear the composer after a durable receipt.
try {
const receipt = await enqueueInboxGuidance(app, submitTabId || "", guidanceText, guidanceSubmitText, structured);
if (receipt?.error) throw new Error(receipt.error);
updatePendingGuidanceForDraft(submitDraftKey, (items) => [
...items,
{
id: receipt.itemId,
text: guidanceText.slice(0, 120),
submitText: "",
intent: "followup",
state: "queued",
source: "desktop",
structured,
},
]);
clearSubmittedDraft(submitDraftKey);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "warn");
// Keep draft on durable failure.
}
}
clearSubmittedDraft(submitDraftKey);
return;
}
await onSend(displayText, submitText, submitTabId, structured);
@@ -2139,39 +2175,76 @@ export function Composer({
targetTabId = tabId,
) => {
if (targetDraftKey !== activeDraftKeyRef.current || disabled || readOnly || guidanceSendingIdRef.current !== null) return;
const durable = !item.id.startsWith("local-");
if (running && item.structured) return;
const displayText = item.text.trim();
const submitText = item.submitText.trim() || displayText;
if (!displayText || !submitText) return;
const attemptedSteer = running && onSteer !== undefined;
let retryRejectedSteer = false;
const selfDispatched = selfDispatchedGuidanceByDraftRef.current[targetDraftKey] ?? [];
selfDispatched.push(submitText);
selfDispatchedGuidanceByDraftRef.current[targetDraftKey] = selfDispatched;
updateGuidanceSendingIdForDraft(targetDraftKey, item.id);
try {
if (attemptedSteer) await onSteer(submitText, targetTabId);
else await onSend(displayText, submitText, targetTabId, item.structured);
updatePendingGuidanceForDraft(targetDraftKey, (items) => items.filter((queued) => queued.id !== item.id));
if (running && durable) {
const receipt = await app.SteerInboxItem(targetTabId || "", item.id);
if (receipt?.error) throw new Error(receipt.error);
if (receipt?.disposition === "steer_accepted") {
updatePendingGuidanceForDraft(targetDraftKey, (items) => items.filter((queued) => queued.id !== item.id));
} else {
// Rejected steers remain the same durable follow-up item. The
// Controller owns its later FIFO dispatch.
updatePendingGuidanceForDraft(targetDraftKey, (items) =>
items.map((queued) => queued.id === item.id
? { ...queued, intent: "followup", state: "queued" }
: queued),
);
setGuidanceRetryNonce((value) => value + 1);
}
return;
}
if (durable) return;
// Prefer durable inbox paths: load body by id only when needed.
let displayText = item.text.trim();
let submitText = item.submitText.trim();
if (!submitText || submitText === displayText) {
try {
const env = await app.ReadInboxItem(targetTabId || "", item.id);
displayText = (env.displayText || env.submitText || displayText).trim();
submitText = (env.submitText || displayText).trim();
} catch {
// Fall back to preview-only shelf text.
}
}
if (!displayText || !submitText) return;
const attemptedSteer = running && onSteer !== undefined;
const selfDispatched = selfDispatchedGuidanceByDraftRef.current[targetDraftKey] ?? [];
selfDispatched.push(submitText);
selfDispatchedGuidanceByDraftRef.current[targetDraftKey] = selfDispatched;
if (attemptedSteer) {
await onSteer(submitText, targetTabId);
updatePendingGuidanceForDraft(targetDraftKey, (items) => items.filter((queued) => queued.id !== item.id));
} else {
await onSend(displayText, submitText, targetTabId, item.structured);
updatePendingGuidanceForDraft(targetDraftKey, (items) => items.filter((queued) => queued.id !== item.id));
}
window.setTimeout(() => {
takeSelfDispatchedGuidance(submitText, targetDraftKey);
}, 5000);
} catch (error) {
retryRejectedSteer = attemptedSteer;
takeSelfDispatchedGuidance(submitText, targetDraftKey);
showToast(error instanceof Error ? error.message : String(error), "warn");
} finally {
const current = targetDraftKey === activeDraftKeyRef.current
? guidanceSendingIdRef.current
: draftsBySessionRef.current[targetDraftKey]?.guidanceSendingId;
if (current === item.id) updateGuidanceSendingIdForDraft(targetDraftKey, null);
// TurnDone may render while TrySteer is still pending. That render cannot
// auto-send because the guidance item is marked in flight, so re-run the
// idle-queue effect after a rejected steer settles. Ordinary onSend
// failures intentionally do not re-arm, avoiding an automatic retry loop.
if (retryRejectedSteer && targetDraftKey === activeDraftKeyRef.current) {
setGuidanceRetryNonce((value) => value + 1);
}
};
const dismissQueuedGuidance = async (item: PendingGuidance) => {
try {
if (!item.id.startsWith("local-")) {
await app.DeleteInboxItem(tabId || "", item.id);
}
updatePendingGuidanceForDraft(
activeDraftKeyRef.current,
(items) => items.filter((queued) => queued.id !== item.id),
);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "warn");
}
};
@@ -2676,7 +2749,11 @@ export function Composer({
// handleCancel stops the in-flight turn; if it was cancelled before the server
// replied, the just-sent text is handed back so we drop it back into the input.
const handleCancel = () => {
const restored = onCancel();
const ownedGuidance = pendingGuidance.filter((item) => item.id.startsWith("local-") || item.source === "desktop");
const durableItemIDs = ownedGuidance
.map((item) => item.id)
.filter((id) => !id.startsWith("local-"));
const restored = onCancel(durableItemIDs);
if (goalModeOn && activeGoal) onClearGoal();
// A user-requested cancel must not let the natural-completion effect submit
// the queued follow-up. Fold it back into the draft: cancelling means "stop
@@ -2684,14 +2761,15 @@ export function Composer({
// honors for un-sent text. Structured items fold back as their slash form
// (structured.display is valid /name syntax) so the invocation survives the
// round trip instead of degrading to its bare task text.
const queued = pendingGuidance
const queued = ownedGuidance
.map((item) => item.structured?.display ?? item.text)
.filter((part) => part.trim() !== "");
if (queued.length === 0) {
if (typeof restored === "string") setTextCaretEnd(restored);
return;
}
updatePendingGuidanceForDraft(activeDraftKeyRef.current, () => []);
const ownedIDs = new Set(ownedGuidance.map((item) => item.id));
updatePendingGuidanceForDraft(activeDraftKeyRef.current, (items) => items.filter((item) => !ownedIDs.has(item.id)));
setGuidanceExpanded(false);
const base = typeof restored === "string" ? restored : text;
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
@@ -3721,9 +3799,6 @@ export function Composer({
: goalModeOn && !activeGoal
? t("composer.goalInputPlaceholder")
: t("composer.placeholder");
const hiddenGuidanceCount = Math.max(0, pendingGuidance.length - 2);
const visibleGuidance = guidanceExpanded ? pendingGuidance : pendingGuidance.slice(0, 2);
const showGuidanceExpander = pendingGuidance.length > 2;
const composerMetaClass = [
"composer-meta",
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
@@ -4223,59 +4298,28 @@ export function Composer({
) : null
)}
{pendingGuidance.length > 0 && (
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
<div className="composer-guidance-head">
<span className="composer-guidance-head__label">
<CornerDownRight size={14} />
<span>{t("composer.guidanceCount", { n: pendingGuidance.length })}</span>
</span>
</div>
<div className="composer-guidance-list">
{visibleGuidance.map((item) => (
<div className="composer-guidance-item" key={item.id}>
<CornerDownRight size={14} className="composer-guidance-item__icon" />
<span className="composer-guidance-item__text">{item.text}</span>
<Tooltip label={t("composer.guidanceSend")}>
<button
className="composer-guidance-item__guide"
type="button"
aria-label={t("composer.guidanceSend")}
disabled={!running || disabled || readOnly || guidanceSendingId !== null || Boolean(item.structured)}
onClick={() => void sendQueuedGuidance(item)}
>
<CornerDownRight size={13} />
<span>{t("composer.guidanceMode")}</span>
</button>
</Tooltip>
<Tooltip label={t("composer.guidanceDismiss")}>
<button
className="composer-guidance-item__action"
type="button"
aria-label={t("composer.guidanceDismiss")}
disabled={guidanceSendingId === item.id}
onClick={() => updatePendingGuidanceForDraft(
activeDraftKeyRef.current,
(items) => items.filter((queued) => queued.id !== item.id),
)}
>
<Trash2 size={14} />
</button>
</Tooltip>
</div>
))}
{showGuidanceExpander && (
<button
className="composer-guidance-more"
type="button"
aria-expanded={guidanceExpanded}
onClick={() => setGuidanceExpanded((value) => !value)}
>
{guidanceExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
<span>{guidanceExpanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenGuidanceCount })}</span>
</button>
)}
</div>
</div>
<Suspense fallback={null}>
<ComposerGuidanceShelf
recovery={pendingGuidance[0]?.recoveredCount ? {
draftKey,
tabId: tabId || "",
count: pendingGuidance[0].recoveredCount,
} : null}
recoveryDisabled={Boolean(disabled || readOnly)}
items={pendingGuidance}
expanded={guidanceExpanded}
running={running}
disabled={Boolean(disabled)}
readOnly={readOnly}
sendingId={guidanceSendingId}
onReview={() => setGuidanceExpanded(true)}
onRecoveryResumed={() => setGuidanceRetryNonce((value) => value + 1)}
onRecoveryError={(error) => showToast(error instanceof Error ? error.message : String(error), "warn")}
onToggleExpanded={() => setGuidanceExpanded((value) => !value)}
onSend={(item) => void sendQueuedGuidance(item)}
onDismiss={(item) => void dismissQueuedGuidance(item)}
/>
</Suspense>
)}
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0 || selectedTextRefs.length > 0) && (
<div className="composer-context" aria-label={t("composer.contextItems")}>
@@ -0,0 +1,126 @@
import { ChevronDown, ChevronUp, CornerDownRight, Trash2 } from "lucide-react";
import { useI18n } from "../lib/i18n";
import type { StructuredInvocationSubmit } from "../lib/invocationDisplay";
import { InboxRecoveryBanner } from "./InboxRecoveryBanner";
import { Tooltip } from "./Tooltip";
export type PendingGuidance = {
id: string;
text: string;
submitText: string;
state?: string;
intent?: string;
source?: string;
recoveredCount?: number;
structured?: StructuredInvocationSubmit;
};
export type InboxRecoveryNotice = {
draftKey: string;
tabId: string;
count: number;
};
export function ComposerGuidanceShelf({
recovery,
recoveryDisabled,
items,
expanded,
running,
disabled,
readOnly,
sendingId,
onReview,
onRecoveryResumed,
onRecoveryError,
onToggleExpanded,
onSend,
onDismiss,
}: {
recovery: InboxRecoveryNotice | null;
recoveryDisabled: boolean;
items: PendingGuidance[];
expanded: boolean;
running: boolean;
disabled: boolean;
readOnly: boolean;
sendingId: string | null;
onReview: () => void;
onRecoveryResumed: () => void;
onRecoveryError: (error: unknown) => void;
onToggleExpanded: () => void;
onSend: (item: PendingGuidance) => void;
onDismiss: (item: PendingGuidance) => void;
}) {
const { t } = useI18n();
const visible = expanded ? items : items.slice(0, 2);
const hiddenCount = Math.max(0, items.length - 2);
return (
<>
{recovery && (
<InboxRecoveryBanner
key={`${recovery.draftKey}:${recovery.tabId}`}
count={recovery.count}
disabled={recoveryDisabled}
tabId={recovery.tabId}
onReview={onReview}
onResumed={onRecoveryResumed}
onError={onRecoveryError}
/>
)}
{items.length > 0 && (
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
<div className="composer-guidance-head">
<span className="composer-guidance-head__label">
<CornerDownRight size={14} />
<span>{t("composer.guidanceCount", { n: items.length })}</span>
</span>
</div>
<div className="composer-guidance-list">
{visible.map((item) => (
<div className="composer-guidance-item" key={item.id}>
<CornerDownRight size={14} className="composer-guidance-item__icon" />
<span className="composer-guidance-item__text">{item.text}</span>
<Tooltip label={t("composer.guidanceSend")}>
<button
className="composer-guidance-item__guide"
type="button"
aria-label={t("composer.guidanceSend")}
disabled={!running || disabled || readOnly || sendingId !== null || Boolean(item.structured)}
onClick={() => onSend(item)}
>
<CornerDownRight size={13} />
<span>{t("composer.guidanceMode")}</span>
</button>
</Tooltip>
<Tooltip label={t("composer.guidanceDismiss")}>
<button
className="composer-guidance-item__action"
type="button"
aria-label={t("composer.guidanceDismiss")}
disabled={sendingId === item.id}
onClick={() => onDismiss(item)}
>
<Trash2 size={14} />
</button>
</Tooltip>
</div>
))}
{items.length > 2 && (
<button
className="composer-guidance-more"
type="button"
aria-expanded={expanded}
onClick={onToggleExpanded}
>
{expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
<span>{expanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenCount })}</span>
</button>
)}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from "react";
import { ShieldAlert } from "lucide-react";
import { app } from "../lib/bridge";
import { useI18n } from "../lib/i18n";
const COPY = {
en: ["Recovered {n} pending instructions", "The inbox is paused. Review queued work before continuing.", "Review queue", "Continue", "Keep paused", "Paused"],
zh: ["已恢复 {n} 条待处理指令", "收件箱当前已暂停,请检查队列后再继续。", "查看队列", "继续执行", "保持暂停", "已暂停"],
"zh-TW": ["已恢復 {n} 條待處理指令", "收件匣目前已暫停,請檢查佇列後再繼續。", "查看佇列", "繼續執行", "保持暫停", "已暫停"],
} as const;
export function InboxRecoveryBanner({
count,
disabled,
tabId,
onReview,
onResumed,
onError,
}: {
count: number;
disabled: boolean;
tabId: string;
onReview: () => void;
onResumed: () => void;
onError: (error: unknown) => void;
}) {
const { locale } = useI18n();
const [titleTemplate, body, review, resume, keepPaused, paused] = COPY[locale];
const title = titleTemplate.replace("{n}", String(count));
const [busy, setBusy] = useState(false);
const [keptPaused, setKeptPaused] = useState(false);
const mountedRef = useRef(true);
useEffect(() => () => { mountedRef.current = false; }, []);
const setPaused = async (paused: boolean) => {
if (busy) return;
setBusy(true);
try {
await app.SetInboxPaused(tabId, paused);
if (!mountedRef.current) return;
if (paused) setKeptPaused(true);
else onResumed();
} catch (error) {
if (mountedRef.current) onError(error);
} finally {
if (mountedRef.current) setBusy(false);
}
};
return (
<section className="composer-guidance-shelf composer-inbox-recovery" role="status" aria-label={title}>
<div className="banner banner--warning banner--actionable" style={{ flexWrap: "wrap" }}>
<ShieldAlert size={17} aria-hidden="true" />
<span className="banner__msg">
<strong>{title}</strong>
{` — ${body}`}
</span>
<span className="banner__spacer" />
<button className="btn btn--small" type="button" disabled={busy} onClick={onReview}>
{review}
</button>
<button className="btn btn--primary btn--small" type="button" disabled={disabled || busy} onClick={() => void setPaused(false)}>
{resume}
</button>
<button className="btn btn--small" type="button" disabled={disabled || busy || keptPaused} onClick={() => void setPaused(true)}>
{keptPaused ? paused : keepPaused}
</button>
</div>
</section>
);
}
+78 -3
View File
@@ -203,8 +203,43 @@ export interface AppBindings {
RunShellForTab(tabID: string, command: string): Promise<void>;
Steer(text: string): Promise<void>;
SteerForTab(tabID: string, text: string): Promise<void>;
InboxSnapshot(tabID: string): Promise<{
revision: number;
paused: boolean;
recovered: boolean;
recoveredCount?: number;
items: Array<{
id: string;
intent: string;
state: string;
preview: string;
byteSize: number;
source?: string;
position: number;
blockReason?: string;
}>;
itemsCount: number;
bytes: number;
maxItems: number;
maxBytes: number;
}>;
EnqueueInboxFollowup(tabID: string, display: string, submit: string, idempotency: string): Promise<{ itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }>;
EnqueueInboxFollowupWithInvocations(tabID: string, display: string, submit: string, invocations: InvocationRequest[], idempotency: string): Promise<{ itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }>;
EnqueueInboxSteer(tabID: string, display: string, submit: string, idempotency: string): Promise<{ itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }>;
SteerInboxItem(tabID: string, itemID: string): Promise<{
itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string;
}>;
ReadInboxItem(tabID: string, id: string): Promise<{ id: string; displayText: string; rawText: string; submitText: string }>;
UpdateInboxItem(tabID: string, id: string, display: string, submit: string): Promise<void>;
DeleteInboxItem(tabID: string, id: string): Promise<void>;
MoveInboxItem(tabID: string, id: string, toIndex: number): Promise<void>;
SetInboxPaused(tabID: string, paused: boolean): Promise<void>;
RetryInboxItem(tabID: string, id: string): Promise<void>;
RefreshInboxItem(tabID: string, id: string): Promise<void>;
InboxHasItems(tabID: string): Promise<boolean>;
Cancel(): Promise<void>;
CancelTab(tabID: string): Promise<void>;
CancelTabWithInboxItems(tabID: string, itemIDs: string[]): Promise<void>;
Approve(id: string, allow: boolean, session: boolean, persist: boolean): Promise<void>;
ApproveTab(tabID: string, id: string, allow: boolean, session: boolean, persist: boolean): Promise<void>;
ResolvePlanDecision(id: string, action: "start_execution" | "revise_plan" | "exit_plan"): Promise<void>;
@@ -1121,11 +1156,11 @@ function browserPreviewEffectiveShell(prefer = "auto"): "bash" | "git-bash" | "p
return browserPlatformOverride() === "windows" ? "git-bash" : "bash";
}
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" | "notice" | "deepseek_upgrade" | "bench" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "recovery" | "sandbox_escape" | "notice" | "deepseek_upgrade" | "bench" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (typeof import.meta.env !== "undefined" && import.meta.env.DEV && (value === "recovery" || value === "inbox-recovery")) return "recovery"; if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (value === "running" || value === "busy" || value === "streaming") return "running";
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
if (value === "notice" || value === "notices" || value === "notice-preview") return "notice";
@@ -1294,7 +1329,7 @@ function mockExternalOpenerIconDataURL(color: string, label: string): string {
function makeMockApp(): AppBindings {
const scenario = mockScenario();
const freshMock = scenario === "fresh";
const guidanceMock = scenario === "guidance";
const guidanceMock = scenario === "guidance", recoveryMock = typeof import.meta.env !== "undefined" && import.meta.env.DEV && scenario === "recovery";
const runningMock = scenario === "running" || guidanceMock;
const sandboxEscapeMock = scenario === "sandbox_escape";
const noticePreviewMock = scenario === "notice";
@@ -2888,6 +2923,43 @@ function makeMockApp(): AppBindings {
async SteerForTab(_tabID, _text) {
await this.Steer(_text);
},
async InboxSnapshot(_tabID) { if (recoveryMock) return (await import("./inboxRecoveryPreview")).inboxRecoveryPreviewSnapshot();
return {
revision: 0,
paused: false,
recovered: false,
items: [],
itemsCount: 0,
bytes: 0,
maxItems: 64,
maxBytes: 64 * 1024 * 1024,
};
},
async EnqueueInboxFollowup(_tabID, _display, _submit, _idempotency) {
return { itemId: `mock-${Date.now()}`, disposition: "queued_followup", position: 1, paused: false };
},
async EnqueueInboxFollowupWithInvocations(_tabID, _display, _submit, _invocations, _idempotency) {
return { itemId: `mock-invocation-${Date.now()}`, disposition: "queued_followup", position: 1, paused: false };
},
async EnqueueInboxSteer(_tabID, display, submit, _idempotency) {
const itemId = `mock-steer-${Date.now()}`;
emit({ kind: "steer", text: submit || display, itemId });
return { itemId, disposition: "steer_accepted", position: 1, paused: false };
},
async SteerInboxItem(_tabID, itemID) {
emit({ kind: "steer", itemId: itemID });
return { itemId: itemID, disposition: "steer_accepted", position: 1, paused: false };
},
async ReadInboxItem(_tabID, id) {
return { id, displayText: "", rawText: "", submitText: "" };
},
async UpdateInboxItem() {},
async DeleteInboxItem() {},
async MoveInboxItem() {},
async SetInboxPaused(_tabID, paused) { if (recoveryMock) (await import("./inboxRecoveryPreview")).setInboxRecoveryPreviewPaused(paused); },
async RetryInboxItem() {},
async RefreshInboxItem() {},
async InboxHasItems() { return recoveryMock; },
async Cancel() {
cancelled = true;
emitMockTurnDone();
@@ -2895,6 +2967,9 @@ function makeMockApp(): AppBindings {
async CancelTab(_tabID) {
await withMockTabScope(_tabID, () => this.Cancel());
},
async CancelTabWithInboxItems(_tabID, _itemIDs) {
await withMockTabScope(_tabID, () => this.Cancel());
},
async Approve(_id, allow, session, persist) {
if (!pendingApprovalPreview) return;
pendingApprovalPreview = false;
@@ -0,0 +1,37 @@
const RECOVERED_INSTRUCTION_COUNT = 30;
const RECOVERED_INSTRUCTION_BYTES = 1024 * 1024;
const PREVIEWS = [
"分析知识库原文并整理关键结论、引用位置与待确认事项。",
"检查仓库实现,补充回归测试并汇报验证结果。",
"对比现有设计与需求,提出最小改动方案后执行。",
] as const;
let recoveryPaused = true;
export function setInboxRecoveryPreviewPaused(paused: boolean): void {
recoveryPaused = paused;
}
export function inboxRecoveryPreviewSnapshot(paused = recoveryPaused) {
const items = Array.from({ length: RECOVERED_INSTRUCTION_COUNT }, (_, index) => ({
id: `recovered-${index + 1}`,
intent: "followup",
state: "uncertain",
preview: `${index + 1}. ${PREVIEWS[index % PREVIEWS.length]}`,
byteSize: RECOVERED_INSTRUCTION_BYTES,
source: "desktop",
position: index + 1,
}));
return {
revision: 1,
paused,
recovered: true,
recoveredCount: RECOVERED_INSTRUCTION_COUNT,
items,
itemsCount: items.length,
bytes: items.length * RECOVERED_INSTRUCTION_BYTES,
maxItems: 64,
maxBytes: 64 * 1024 * 1024,
};
}
+23
View File
@@ -0,0 +1,23 @@
import type { AppBindings } from "./bridge";
import type { StructuredInvocationSubmit } from "./invocationDisplay";
type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations">;
export function enqueueInboxGuidance(
binding: InboxEnqueueBindings,
tabId: string,
display: string,
submit: string,
structured?: StructuredInvocationSubmit,
) {
if (structured) {
return binding.EnqueueInboxFollowupWithInvocations(
tabId,
structured.display.trim() || display,
structured.input.trim(),
structured.invocations,
"",
);
}
return binding.EnqueueInboxFollowup(tabId, display, submit || display, "");
}
+15 -4
View File
@@ -32,6 +32,11 @@ const COALESCED = new Set([
"BalanceForTab",
]);
// Any command that can change backend-visible state starts a new query epoch.
// Prefixes match the generated binding names while leaving ordinary reads
// (List/Get/Meta/History/Context/Jobs/Checkpoints) available for coalescing.
const MUTATION_PREFIX = /^(Activate|Add|Answer|Apply|Approve|Cancel|Clear|Close|Connect|Create|Delete|Ensure|Fetch|Install|New|Open|Refresh|Remove|Rename|Reorder|Replay|Resolve|Resume|Run|Save|Send|Set|Start|Steer|Stop|Switch|Trash|Try|Update|Upgrade)/;
type Entry = { promise: Promise<unknown>; at: number };
const inflight = new Map<string, Entry>();
@@ -44,12 +49,19 @@ export function coalescesQuery(method: string): boolean {
return COALESCED.has(method);
}
export function invalidatesQueryCoalescing(method: string): boolean {
return MUTATION_PREFIX.test(method);
}
/**
* maybeShare is the bridge's single entry point: it decides whether a call is
* shareable and shares it, so the proxy never has to know the allowlist.
*/
export function maybeShare(method: string, args: unknown[], run: () => unknown): unknown {
if (!coalescesQuery(method)) return run();
if (!coalescesQuery(method)) {
if (invalidatesQueryCoalescing(method)) inflight.clear();
return run();
}
return shareQuery(method, args, async () => run());
}
@@ -57,7 +69,8 @@ export function maybeShare(method: string, args: unknown[], run: () => unknown):
* shareQuery returns the in-flight answer for an identical call made inside the
* window, or runs it. A rejection is never shared beyond its own settlement:
* the next caller retries rather than inheriting a stale failure. Callers that
* cross an external state boundary must explicitly invalidate that query.
* cross an external state boundary may explicitly invalidate that query, while
* bridge mutations invalidate the whole burst automatically.
*/
export function shareQuery<T>(method: string, args: unknown[], run: () => Promise<T>): Promise<T> {
const key = `${method}|${safeKey(args)}`;
@@ -72,8 +85,6 @@ export function shareQuery<T>(method: string, args: unknown[], run: () => Promis
forget(key, promise);
return;
}
// A settled answer stays shareable for the rest of the window: the
// duplicates this exists for arrive within a few milliseconds of it.
setTimeout(() => forget(key, promise), WINDOW_MS);
},
() => forget(key, promise),
+2
View File
@@ -310,6 +310,8 @@ export interface WireEvent {
/** Optional: "headers" | "stream". Older clients ignore unknown fields. */
retryScope?: "headers" | "stream";
streamAttempt?: WireStreamAttempt;
/** Durable session-inbox item id for steer / TurnDone correlation. */
itemId?: string;
workspace?: WireWorkspaceChanged;
tabId?: string; // Go's tabEventSink tags events for the correct per-tab reducer.
runtimeEpoch?: string;
+15 -11
View File
@@ -3697,12 +3697,13 @@ export function useController() {
const steerForTab = useCallback(async (tabId: string, text: string) => {
if (!tabId) throw new Error(t("composer.workspaceStarting"));
// No optimistic user bubble: rewind/fork map turns by counting user items,
// and a steer is not a backend turn — the Steer event's ↪ notice is the
// visible confirmation (#3660). Keep backend rejection as a rejected
// promise: Composer retains the guidance item until TurnDone, then sends it
// as a normal follow-up instead of clearing running state prematurely.
await app.SteerForTab(tabId, text);
// Durable steer first: body is on disk before admission. Rejected steers
// become follow-ups automatically (disposition queued_followup).
const receipt = await app.EnqueueInboxSteer(tabId, text, text, "");
if (receipt?.error) throw new Error(receipt.error);
if (receipt?.disposition && receipt.disposition !== "steer_accepted") {
throw new Error("the turn ended before guidance could be applied; it will remain queued for the next turn");
}
}, []);
const steer = useCallback(async (text: string) => {
@@ -3729,29 +3730,32 @@ export function useController() {
dispatchTo(activeTabId, { type: "extension_notifications_drained" });
}, [activeTabId, dispatchTo]);
const cancelTab = useCallback((tabId: string) => {
const cancelTab = useCallback((tabId: string, inboxItemIDs: string[] = []) => {
const cancelHydrateGeneration = bumpCancelHydrateSeq(tabId);
app.CancelTab(tabId)
const cancelRequest = inboxItemIDs.length > 0
? app.CancelTabWithInboxItems(tabId, inboxItemIDs)
: app.CancelTab(tabId);
cancelRequest
.then(() => scheduleCancelReconcile(tabId, 0, cancelHydrateGeneration))
.catch((error) => {
dispatchTo(tabId, { type: "local_notice", level: "warn", text: `Cancel failed: ${errorMessage(error)}` });
});
}, [bumpCancelHydrateSeq, dispatchTo, scheduleCancelReconcile]);
const cancel = useCallback((): string | undefined => {
const cancel = useCallback((inboxItemIDs: string[] = []): string | undefined => {
const cur = stateRef.current;
const tabId = activeTabId;
if (cur.running && cur.pendingUser !== undefined) {
const text = cur.pendingUser;
if (tabId) {
dispatchTo(tabId, { type: "unsend" });
cancelTab(tabId);
cancelTab(tabId, inboxItemIDs);
}
return text;
}
if (tabId) {
dispatchTo(tabId, { type: "cancel_requested" });
cancelTab(tabId);
cancelTab(tabId, inboxItemIDs);
}
return undefined;
}, [activeTabId, cancelTab, dispatchTo]);
+318
View File
@@ -0,0 +1,318 @@
package main
import (
"fmt"
"strings"
"reasonix/internal/control"
"reasonix/internal/sessioninbox"
)
// InboxItemView is the Wails-facing metadata row (never full body).
type InboxItemView struct {
ID string `json:"id"`
Intent string `json:"intent"`
State string `json:"state"`
Preview string `json:"preview"`
ByteSize int64 `json:"byteSize"`
Source string `json:"source,omitempty"`
BlockReason string `json:"blockReason,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
Position int `json:"position"`
}
// InboxSnapshotView is the Wails-facing queue snapshot.
type InboxSnapshotView struct {
Revision int64 `json:"revision"`
Paused bool `json:"paused"`
Recovered bool `json:"recovered"`
RecoveredN int `json:"recoveredCount,omitempty"`
Items []InboxItemView `json:"items"`
ItemsCount int `json:"itemsCount"`
Bytes int64 `json:"bytes"`
MaxItems int `json:"maxItems"`
MaxBytes int64 `json:"maxBytes"`
}
// InboxReceiptView is returned after durable enqueue/steer.
type InboxReceiptView struct {
ItemID string `json:"itemId"`
Disposition string `json:"disposition"`
Position int `json:"position"`
Paused bool `json:"paused"`
Idempotent bool `json:"idempotent,omitempty"`
Error string `json:"error,omitempty"`
}
// InboxEnvelopeView is the full body for the editor (fetched by id only).
type InboxEnvelopeView struct {
ID string `json:"id"`
DisplayText string `json:"displayText"`
RawText string `json:"rawText"`
SubmitText string `json:"submitText"`
}
func inboxSnapshotView(snap sessioninbox.InboxSnapshot) InboxSnapshotView {
items := make([]InboxItemView, 0, len(snap.Items))
for i, it := range snap.Items {
items = append(items, InboxItemView{
ID: it.ID,
Intent: string(it.Intent),
State: string(it.State),
Preview: it.Preview,
ByteSize: it.ByteSize,
Source: it.Source,
BlockReason: it.BlockReason,
CreatedAt: it.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
Position: i + 1,
})
}
return InboxSnapshotView{
Revision: snap.Revision,
Paused: snap.Paused,
Recovered: snap.Recovered,
RecoveredN: snap.RecoveredN,
Items: items,
ItemsCount: len(items),
Bytes: snap.Capacity.Bytes,
MaxItems: snap.Capacity.MaxItems,
MaxBytes: snap.Capacity.MaxBytes,
}
}
func (a *App) inboxCtrl(tabID string) (control.SessionAPI, error) {
tab, ctrl := a.tabAndCtrlByID(tabID)
if a.tabIsReadOnly(tab) {
return nil, readOnlyChannelErr()
}
if ctrl == nil {
return nil, a.workspaceNotReadyErr(tab)
}
return ctrl, nil
}
// InboxSnapshot returns durable inbox metadata for a tab (no bodies).
func (a *App) InboxSnapshot(tabID string) (InboxSnapshotView, error) {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return InboxSnapshotView{}, err
}
return inboxSnapshotView(ctrl.InboxSnapshot()), nil
}
// EnqueueInboxFollowup durably queues a follow-up for the tab.
func (a *App) EnqueueInboxFollowup(tabID, display, submit, idempotency string) (InboxReceiptView, error) {
return a.enqueueInbox(tabID, sessioninbox.IntentFollowup, display, submit, nil, idempotency, false)
}
// EnqueueInboxFollowupWithInvocations preserves rich-composer Skill/Subagent
// entities in the durable envelope instead of degrading them to slash text.
func (a *App) EnqueueInboxFollowupWithInvocations(tabID, display, submit string, invocations []InvocationRequest, idempotency string) (InboxReceiptView, error) {
return a.enqueueInbox(tabID, sessioninbox.IntentFollowup, display, submit, invocations, idempotency, false)
}
// EnqueueInboxSteer durably queues and attempts mid-turn steer.
func (a *App) EnqueueInboxSteer(tabID, display, submit, idempotency string) (InboxReceiptView, error) {
return a.enqueueInbox(tabID, sessioninbox.IntentSteer, display, submit, nil, idempotency, true)
}
// SteerInboxItem attempts to apply an existing durable queue item to the
// current turn. It never creates a second entry for the same instruction.
func (a *App) SteerInboxItem(tabID, itemID string) (InboxReceiptView, error) {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return InboxReceiptView{}, err
}
rec, err := ctrl.TrySteerInboxItem(strings.TrimSpace(itemID))
if err != nil {
return InboxReceiptView{Error: err.Error()}, err
}
a.emitInboxChanged(tabID)
return InboxReceiptView{
ItemID: rec.ItemID,
Disposition: string(rec.Disposition),
Position: rec.Position,
Paused: rec.Paused,
Idempotent: rec.Idempotent,
}, nil
}
// CancelTabWithInboxItems cancels the turn and atomically discards only the
// durable pending items currently shown by that tab's Composer.
func (a *App) CancelTabWithInboxItems(tabID string, itemIDs []string) error {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return err
}
if err := ctrl.CancelWithInboxItems(itemIDs, "desktop"); err != nil {
return err
}
a.emitInboxChanged(tabID)
return nil
}
func (a *App) enqueueInbox(tabID string, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool) (InboxReceiptView, error) {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return InboxReceiptView{}, err
}
if ensurer, ok := ctrl.(interface{ EnsureSessionPath() }); ok {
ensurer.EnsureSessionPath()
}
submit = strings.TrimSpace(submit)
display = strings.TrimSpace(display)
if submit == "" && len(invocations) == 0 {
submit = display
}
if display == "" {
display = submit
}
req := control.InboxRequest{
Intent: intent,
Display: display,
Raw: submit,
Submit: submit,
Source: "desktop",
Idempotency: strings.TrimSpace(idempotency),
Invocations: controlInvocationRequests(invocations),
}
var rec sessioninbox.InboxReceipt
if trySteer {
rec, err = ctrl.TryEnqueueAndSteer(req)
} else {
rec, err = ctrl.TryEnqueueFollowup(req)
}
if err != nil {
return InboxReceiptView{Error: err.Error()}, err
}
a.emitInboxChanged(tabID)
return InboxReceiptView{
ItemID: rec.ItemID,
Disposition: string(rec.Disposition),
Position: rec.Position,
Paused: rec.Paused,
Idempotent: rec.Idempotent,
}, nil
}
// ReadInboxItem returns the full envelope for editing.
func (a *App) ReadInboxItem(tabID, id string) (InboxEnvelopeView, error) {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return InboxEnvelopeView{}, err
}
meta, env, err := ctrl.ReadInboxItem(id)
if err != nil {
return InboxEnvelopeView{}, err
}
return InboxEnvelopeView{
ID: meta.ID,
DisplayText: env.DisplayText,
RawText: env.RawText,
SubmitText: env.SubmitText,
}, nil
}
// UpdateInboxItem rewrites a durable entry and re-freezes refs.
func (a *App) UpdateInboxItem(tabID, id, display, submit string) error {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return err
}
if _, err := ctrl.UpdateInboxItem(id, display, submit, submit); err != nil {
return err
}
a.emitInboxChanged(tabID)
return nil
}
// DeleteInboxItem removes a durable entry.
func (a *App) DeleteInboxItem(tabID, id string) error {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return err
}
if err := ctrl.DeleteInboxItem(id); err != nil {
return err
}
a.emitInboxChanged(tabID)
return nil
}
// MoveInboxItem reorders (toIndex is 0-based).
func (a *App) MoveInboxItem(tabID, id string, toIndex int) error {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return err
}
if err := ctrl.MoveInboxItem(id, toIndex); err != nil {
return err
}
a.emitInboxChanged(tabID)
return nil
}
// SetInboxPaused pauses or resumes dispatch.
func (a *App) SetInboxPaused(tabID string, paused bool) error {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return err
}
if err := ctrl.SetInboxPaused(paused); err != nil {
return err
}
a.emitInboxChanged(tabID)
return nil
}
// RetryInboxItem resets uncertain/blocked items to queued.
func (a *App) RetryInboxItem(tabID, id string) error {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return err
}
if err := ctrl.RetryInboxItem(id); err != nil {
return err
}
a.emitInboxChanged(tabID)
return nil
}
// RefreshInboxReferences re-freezes @-refs for an item.
func (a *App) RefreshInboxItem(tabID, id string) error {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return err
}
if err := ctrl.RefreshInboxReferences(id); err != nil {
return err
}
a.emitInboxChanged(tabID)
return nil
}
// SteerForTab still works for compatibility; prefer EnqueueInboxSteer so the
// guidance is durable before admission.
func (a *App) emitInboxChanged(tabID string) {
if a == nil || a.ctx == nil {
return
}
runtimeEventsEmitFallback(a.ctx, "InboxChanged", map[string]string{"tabId": tabID})
}
// ClearSessionConfirm checks for a non-empty inbox before clear.
func (a *App) InboxHasItems(tabID string) (bool, error) {
ctrl, err := a.inboxCtrl(tabID)
if err != nil {
return false, err
}
return len(ctrl.InboxSnapshot().Items) > 0, nil
}
// FormatInboxRecoveryNotice builds the recovery banner text.
func FormatInboxRecoveryNotice(n int) string {
if n <= 0 {
return ""
}
return fmt.Sprintf("Recovered %d pending instruction(s). Inbox is paused — review before resuming.", n)
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"path/filepath"
"testing"
"reasonix/internal/control"
"reasonix/internal/event"
)
func TestDurableInvocationFollowupPreservesEmptyExplicitTask(t *testing.T) {
dir := t.TempDir()
ctrl := control.New(control.Options{
SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Sink: event.Discard,
})
if err := ctrl.SetInboxPaused(true); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(ctrl, "")
rec, err := app.EnqueueInboxFollowupWithInvocations(
"test", "/init", "", []InvocationRequest{{Name: "init", Kind: "skill"}}, "desktop-submit-1",
)
if err != nil {
t.Fatal(err)
}
_, env, err := ctrl.ReadInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if env.SubmitText != "" || env.RawText != "" {
t.Fatalf("entity-only durable task degraded to text: submit=%q raw=%q", env.SubmitText, env.RawText)
}
if len(env.Invocations) != 1 || env.Invocations[0].Name != "init" || env.Invocations[0].Kind != "skill" {
t.Fatalf("durable invocation metadata = %+v", env.Invocations)
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"fmt"
"os"
"path/filepath"
)
func restoreTrashedSessionFile(dir, path string) error {
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
if err != nil {
return err
}
target := filepath.Join(dir, key)
replaceDiscardableTranscript := false
if _, err := os.Stat(target); err == nil {
discardable, err := liveSessionDiscardable(target)
if err != nil {
return err
}
if !discardable {
return fmt.Errorf("session already exists: %s", key)
}
replaceDiscardableTranscript = true
} else if !os.IsNotExist(err) {
return err
}
// Preflight every owned destination before removing the verified-empty
// transcript. A newly created inbox or other sidecar may already hold work
// even while the transcript itself is still discardable.
if err := checkRestoreSessionArtifactConflicts(target, key, replaceDiscardableTranscript); err != nil {
return err
}
if err := checkRestoreSubagentConflicts(dir, itemDir); err != nil {
return err
}
if replaceDiscardableTranscript {
if err := removeDesktopSessionArtifacts(target); err != nil {
return err
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, artifact := range sessionTrashArtifacts(target, key) {
if err := movePathIfExists(filepath.Join(itemDir, artifact.name), artifact.src); err != nil {
return err
}
}
if err := restoreSubagentArtifacts(dir, itemDir); err != nil {
return err
}
return os.RemoveAll(itemDir)
}
func checkRestoreSessionArtifactConflicts(target, key string, allowDiscardableTranscript bool) error {
for _, artifact := range sessionTrashArtifacts(target, key) {
if allowDiscardableTranscript && artifact.src == target {
continue
}
if _, err := os.Lstat(artifact.src); err == nil {
return fmt.Errorf("session artifact already exists: %s", artifact.name)
} else if !os.IsNotExist(err) {
return err
}
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/sessioninbox"
"reasonix/internal/store"
)
func TestRestoreTrashedSessionFileRejectsActiveInboxConflict(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil {
t.Fatal(err)
}
oldReceipt := enqueueSessionInboxForRestoreTest(t, sessionPath, "old queued work")
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write live stub: %v", err)
}
activeReceipt := enqueueSessionInboxForRestoreTest(t, sessionPath, "active queued work")
err := restoreTrashedSessionFile(dir, trashPath)
if err == nil || !strings.Contains(err.Error(), "session artifact already exists: session.inbox") {
t.Fatalf("restore conflict error = %v, want active inbox conflict", err)
}
if info, err := os.Stat(sessionPath); err != nil || info.Size() != 0 {
t.Fatalf("active transcript stub should remain empty, info=%v err=%v", info, err)
}
assertSessionInboxItemForRestoreTest(t, sessionPath, activeReceipt.ItemID, "active queued work")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("trash transcript should remain after rejected restore: %v", err)
}
assertSessionInboxItemForRestoreTest(t, trashPath, oldReceipt.ItemID, "old queued work")
}
func TestRestoreTrashedSessionFileRejectsOrphanSidecarConflict(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "orphan-sidecar.jsonl")
if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil {
t.Fatal(err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write live stub: %v", err)
}
metaPath := store.SessionMeta(sessionPath)
if err := os.WriteFile(metaPath, []byte("active metadata"), 0o600); err != nil {
t.Fatalf("write active sidecar: %v", err)
}
err := restoreTrashedSessionFile(dir, trashPath)
if err == nil || !strings.Contains(err.Error(), filepath.Base(metaPath)) {
t.Fatalf("restore conflict error = %v, want orphan sidecar conflict", err)
}
if got, err := os.ReadFile(metaPath); err != nil || string(got) != "active metadata" {
t.Fatalf("active sidecar = %q, %v; want preserved metadata", got, err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("active transcript stub should remain: %v", err)
}
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("trash transcript should remain after rejected restore: %v", err)
}
}
func TestRestoreTrashedSessionFilePreservesStubOnSubagentConflict(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "subagent-conflict.jsonl")
if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil {
t.Fatal(err)
}
ref := "sa_20260102_030405_000000000_aabbccddeeff"
writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write live stub: %v", err)
}
activeSubagent := filepath.Join(dir, "subagents", ref+".jsonl")
if err := os.MkdirAll(filepath.Dir(activeSubagent), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(activeSubagent, []byte("active subagent"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreTrashedSessionFile(dir, trashPath); err == nil {
t.Fatal("restore should reject an active subagent conflict")
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("active transcript stub should survive preflight failure: %v", err)
}
if got, err := os.ReadFile(activeSubagent); err != nil || string(got) != "active subagent" {
t.Fatalf("active subagent = %q, %v; want preserved data", got, err)
}
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("trash transcript should remain after rejected restore: %v", err)
}
}
func enqueueSessionInboxForRestoreTest(t *testing.T, sessionPath, text string) sessioninbox.InboxReceipt {
t.Helper()
inbox, err := sessioninbox.Open(sessionPath, sessioninbox.Limits{})
if err != nil {
t.Fatalf("open inbox: %v", err)
}
receipt, err := inbox.Enqueue(sessioninbox.EnqueueRequest{
Envelope: sessioninbox.PromptEnvelope{DisplayText: text, SubmitText: text},
})
inbox.Close()
if err != nil {
t.Fatalf("enqueue inbox item: %v", err)
}
return receipt
}
func assertSessionInboxItemForRestoreTest(t *testing.T, sessionPath, itemID, want string) {
t.Helper()
inbox, err := sessioninbox.Open(sessionPath, sessioninbox.Limits{})
if err != nil {
t.Fatalf("reopen inbox: %v", err)
}
defer inbox.Close()
_, envelope, err := inbox.ReadItem(itemID)
if err != nil {
t.Fatalf("read inbox item %s: %v", itemID, err)
}
if envelope.SubmitText != want {
t.Fatalf("inbox item %s = %q, want %q", itemID, envelope.SubmitText, want)
}
}
+1 -37
View File
@@ -193,6 +193,7 @@ func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
{src: store.SessionInboxDir(sessionPath), name: stem + ".inbox"},
}
}
@@ -531,43 +532,6 @@ func trashedSessionDeletedAt(path string) int64 {
return meta.DeletedAt
}
func restoreTrashedSessionFile(dir, path string) error {
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
if err != nil {
return err
}
target := filepath.Join(dir, key)
if _, err := os.Stat(target); err == nil {
discardable, err := liveSessionDiscardable(target)
if err != nil {
return err
}
if !discardable {
return fmt.Errorf("session already exists: %s", key)
}
if err := removeDesktopSessionArtifacts(target); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := checkRestoreSubagentConflicts(dir, itemDir); err != nil {
return err
}
for _, artifact := range sessionTrashArtifacts(target, key) {
if err := movePathIfExists(filepath.Join(itemDir, artifact.name), artifact.src); err != nil {
return err
}
}
if err := restoreSubagentArtifacts(dir, itemDir); err != nil {
return err
}
return os.RemoveAll(itemDir)
}
func purgeTrashedSessionFile(dir, path string) error {
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
if err != nil {
+42 -14
View File
@@ -195,22 +195,50 @@ Call the advertised method while `session/prompt` is active:
}
```
A successful `{}` result means the active turn accepted the guidance. Reasonix
adds it as a user message before the next safe model-call boundary, without
cancelling the turn or consuming an extra tool-step budget. The message is
persisted in normal history; transcript replay shows the original user text,
not Reasonix's internal steer marker.
A persistent session returns an item id and disposition:
```json
{"itemId":"inbox-item-id","disposition":"steer_accepted"}
```
Reasonix durably commits the guidance before returning. `steer_accepted` means
the active turn accepted it; `queued_followup` means that admission lost a race
or no turn was active, so the same item remains queued for a later turn. A
pathless compatibility session may omit `itemId` and still returns
`steer_accepted`. Applied guidance is persisted in normal history; transcript
replay shows the original user text, not Reasonix's internal steer marker.
| Condition | JSON-RPC result |
| --- | --- |
| Active prompt accepted the guidance | `{}` |
| Active prompt accepted durable guidance | `{"itemId":"...","disposition":"steer_accepted"}` |
| Guidance persisted but active admission was rejected | `{"itemId":"...","disposition":"queued_followup"}` |
| Unknown session or empty prompt | `-32602 InvalidParams` |
| Session has no active prompt | `-32600 InvalidRequest` |
| Pathless compatibility session has no active prompt | `-32600 InvalidRequest` |
| Client calls `session/steer` | `-32601 MethodNotFound` |
On `InvalidRequest`, the guidance was not queued. A client may wait for the
active prompt to finish and offer the text as a normal new prompt, but it should
not silently report the failed steer as accepted.
On `InvalidRequest`, the compatibility session did not queue the guidance.
## Durable session inbox extension
Discover the versioned queue at
`agentCapabilities._meta["reasonix.io"].sessionInbox`. Schema version 1
advertises method names in its `methods` map; clients must use those advertised
names rather than constructing vendor method strings.
| Key | Purpose | Main parameters |
| --- | --- | --- |
| `enqueue` | Persist a follow-up or steer | `sessionId`, `text`, optional `intent`, `idempotencyKey` |
| `list` | Read metadata, capacity, pause and recovery state | `sessionId` |
| `get` | Read one full envelope on demand | `sessionId`, `itemId` |
| `update` / `delete` | Edit or delete pending work | `sessionId`, `itemId` |
| `move` | Reorder pending work | `sessionId`, `itemId`, zero-based `toIndex` |
| `setPaused` | Pause or resume dispatch | `sessionId`, `paused` |
| `retry` / `refresh` | Retry uncertain work or re-freeze references | `sessionId`, `itemId` |
`enqueue` returns `itemId`, `disposition`, `position`, `paused`, and
`idempotent`. List responses contain previews and byte counts, never prompt
bodies. A recovered inbox is paused; clients should let users inspect it before
calling `setPaused` with `false`.
## Runtime reload and extension surface
@@ -242,8 +270,8 @@ any other slash command.
| --- | --- | --- |
| Existing ACP v1 methods | Their names and response shapes are unchanged. | Compatible |
| Capability `_meta` | Unknown metadata may be ignored. | Compatible |
| Persisted transcripts | No new persisted schema is required. | Compatible |
| CLI, Desktop, and Bot steering | Their existing idle fallback remains unchanged. | Compatible |
| Persisted transcripts | Transcript schema is unchanged; the inbox is a versioned sidecar. | Compatible |
| CLI, Desktop, and Bot steering | Rejected steers remain durable follow-ups. | Compatible |
Steering appends a user-requested message to normal conversation history. It
does not change the system prompt, tool schemas, tool order, or other stable
@@ -260,7 +288,7 @@ earlier prefix remains reusable.
a prompt is running.
5. Show steer UI only when the Reasonix capability is advertised and a prompt
is active.
6. Treat a successful steer response as queued guidance, not immediate model
completion.
6. Branch on the steer `disposition`; both accepted steer and queued follow-up
are durable, but only the former can affect the active turn.
7. Use `session/close` for resource cleanup and `session/delete` only when the
user intends to remove persisted history.
+38 -10
View File
@@ -180,19 +180,46 @@ agentCapabilities._meta["reasonix.io"].sessionSteer.method
}
```
成功返回 `{}` 表示活动回合已接受引导。Reasonix 会在下一个安全的模型调用边界前把它
作为 user message 加入上下文,不会取消回合,也不会额外消耗工具步骤预算。该消息会进入
正常历史;回放 transcript 时显示用户原文,不显示 Reasonix 内部 steer marker。
持久化会话会返回 item id 和 disposition:
```json
{"itemId":"inbox-item-id","disposition":"steer_accepted"}
```
Reasonix 会先完成持久化再返回。`steer_accepted` 表示活动回合已接受;
`queued_followup` 表示 admission 竞争失败或当前没有活动回合,同一条持久化消息会保留为
后续回合。无路径兼容会话可能不返回 `itemId`,但仍返回 `steer_accepted`。已应用的消息
会进入正常历史;回放 transcript 时显示用户原文,不显示内部 steer marker。
| 条件 | JSON-RPC 结果 |
| --- | --- |
| 活动 prompt 接受引导 | `{}` |
| 活动 prompt 接受持久化引导 | `{"itemId":"...","disposition":"steer_accepted"}` |
| 引导已持久化但活动 admission 被拒绝 | `{"itemId":"...","disposition":"queued_followup"}` |
| session 不存在或 prompt 为空 | `-32602 InvalidParams` |
| session 没有活动 prompt | `-32600 InvalidRequest` |
| 无路径兼容 session 没有活动 prompt | `-32600 InvalidRequest` |
| 客户端调用 `session/steer` | `-32601 MethodNotFound` |
收到 `InvalidRequest` 时,引导没有入队。客户端可以等待活动 prompt 结束,再让用户把该
文本作为普通新 prompt 提交,但不能把失败的 steer 静默显示为已接受。
收到 `InvalidRequest` 时,兼容会话没有把引导入队。
## 持久化 Session Inbox 扩展
从 `agentCapabilities._meta["reasonix.io"].sessionInbox` 发现带版本的队列。
Schema v1 在 `methods` map 中声明方法名;客户端应使用这里声明的名字,不要自行拼接
vendor method。
| Key | 用途 | 主要参数 |
| --- | --- | --- |
| `enqueue` | 持久化 follow-up 或 steer | `sessionId`、`text`,可选 `intent`、`idempotencyKey` |
| `list` | 读取元数据、容量、暂停和恢复状态 | `sessionId` |
| `get` | 按需读取一条完整 envelope | `sessionId`、`itemId` |
| `update` / `delete` | 编辑或删除待处理项 | `sessionId`、`itemId` |
| `move` | 调整待处理项顺序 | `sessionId`、`itemId`、从 0 开始的 `toIndex` |
| `setPaused` | 暂停或恢复派发 | `sessionId`、`paused` |
| `retry` / `refresh` | 重试不确定项或重新冻结引用 | `sessionId`、`itemId` |
`enqueue` 返回 `itemId`、`disposition`、`position`、`paused` 和 `idempotent`。
List 只返回预览和字节数,不返回正文。恢复出的 Inbox 默认暂停;客户端应先让用户检查,
再用 `setPaused: false` 恢复派发。
## 运行时重载与扩展表面
@@ -220,8 +247,8 @@ Reasonix 还在 `agentCapabilities._meta["reasonix.io"]` 中通告两个扩展
| --- | --- | --- |
| 现有 ACP v1 方法 | 方法名和响应结构不变。 | 兼容 |
| Capability `_meta` | 可以忽略未知 metadata。 | 兼容 |
| 持久化 transcript | 不需要新增持久化 schema。 | 兼容 |
| CLI、Desktop、Bot steer | 保留现有 idle fallback。 | 兼容 |
| 持久化 transcript | transcript schema 不变;Inbox 使用带版本的 sidecar。 | 兼容 |
| CLI、Desktop、Bot steer | 被拒绝的 steer 会保留为持久化 follow-up。 | 兼容 |
Steer 只会把用户请求的消息追加到正常会话历史,不改变 system prompt、工具 schema、工具
顺序或其他稳定的 provider prefix 字节。下一次 provider 请求必然包含这条新消息,和任何
@@ -234,6 +261,7 @@ Steer 只会把用户请求的消息追加到正常会话历史,不改变 syst
3. 使用绝对工作区路径打开会话,并隔离保存各 session id。
4. Prompt 运行期间继续处理 agent 发往客户端的文件、terminal 和权限请求。
5. 只有在 Reasonix 声明 capability 且 prompt 活动时才显示 steer UI。
6. 把成功的 steer 响应理解为“引导已入队”,而不是“模型已立即完成处理”。
6. 按 steer `disposition` 分支;两种结果都已持久化,但只有 `steer_accepted`
能影响活动回合。
7. 用 `session/close` 释放资源;只有用户明确要删除持久化历史时才调用
`session/delete`。
+10 -3
View File
@@ -343,6 +343,10 @@ These commands work in Feishu, Lark, WeChat, and QQ.
| `/queue followup` | Queue mid-run messages as later turns | `/queue followup` |
| `/queue collect` | Merge queued messages into one later turn | `/queue collect` |
| `/queue interrupt` | Cancel the current task and keep the newest message | `/queue interrupt` |
| `/queue list` / `/queue show <n\|id>` | Inspect durable queued work | `/queue list` |
| `/queue delete <n\|id>` / `/queue move <n\|id> <to>` | Delete or reorder pending work | `/queue move 2 1` |
| `/queue pause` / `/queue resume` | Stop or resume automatic dispatch | `/queue resume` |
| `/queue retry <n\|id>` / `/queue refresh <n\|id>` | Retry uncertain work or refresh frozen references | `/queue retry 1` |
| `/projects [query]` | List indexed project workspaces | `/projects reasonix` |
| `/use project <id\|name>` | Route this remote session to an indexed project | `/use project p1` |
| `/use project default` | Clear the project override and return to configured routing | `/use project default` |
@@ -361,9 +365,12 @@ Shortcut replies:
produce guidance.
The default queue mode is `steer`: when the same session is already running, a
new message is injected as mid-turn guidance instead of waiting for the whole
turn to finish. `queue_cap` and `queue_drop` bound backlog growth in config.
`reasonix bot doctor --deep` reports queue, pairing, and role diagnostics.
new message is persisted before it is injected as mid-turn guidance. If the
active turn cannot accept it, the same durable item remains a follow-up.
Capacity is fail-closed at 64 items, 4 MiB per item, and 64 MiB per session;
`queue_drop` no longer removes older work. After recovery the inbox stays
paused until `/queue resume`. `reasonix bot doctor --deep` reports queue,
pairing, and role diagnostics.
Queue modes:
+9 -4
View File
@@ -317,6 +317,10 @@ sequenceDiagram
| `/queue followup` | 运行中消息排队为后续回合 | `/queue followup` |
| `/queue collect` | 合并排队消息为一个后续回合 | `/queue collect` |
| `/queue interrupt` | 取消当前任务并处理最新消息 | `/queue interrupt` |
| `/queue list` / `/queue show <n\|id>` | 查看持久化排队内容 | `/queue list` |
| `/queue delete <n\|id>` / `/queue move <n\|id> <位置>` | 删除或调整待处理项顺序 | `/queue move 2 1` |
| `/queue pause` / `/queue resume` | 暂停或恢复自动派发 | `/queue resume` |
| `/queue retry <n\|id>` / `/queue refresh <n\|id>` | 重试不确定项或刷新冻结引用 | `/queue retry 1` |
| `/projects [关键词]` | 查看已索引项目工作区 | `/projects reasonix` |
| `/use project <id\|名称>` | 将当前远端会话路由到已索引项目 | `/use project p1` |
| `/use project default` | 清除项目覆盖,恢复配置路由 | `/use project default` |
@@ -332,10 +336,11 @@ sequenceDiagram
- `/stop`、`/mode`、`/answer ...` 等 slash 命令不会被 Ask 快捷回复截获。
- 如果没有待处理操作,`1` / `2` 会被当作普通消息或收到提示。
默认队列模式是 `steer`:同一会话正在运行时,新消息会作为当前任务的
mid-turn guidance 注入,而不是等完整回合结束。`queue_cap` 和 `queue_drop`
可以在配置里限制排队堆积;`reasonix bot doctor --deep` 会显示当前队列、
配对和角色诊断信息。
默认队列模式是 `steer`:同一会话正在运行时,新消息会先持久化,再作为当前
任务的 mid-turn guidance 注入;当前回合无法接收时,同一条消息会保留为后续
回合。容量采用失败关闭:每个会话最多 64 条、单条 4 MiB、合计 64 MiB,
`queue_drop` 不再删除旧消息。崩溃恢复后 Inbox 默认暂停,需检查后执行
`/queue resume`。`reasonix bot doctor --deep` 会显示队列、配对和角色诊断信息。
队列模式:
+8 -1
View File
@@ -623,7 +623,8 @@ Chat and transcript shortcuts:
| Key or command | What it does | Notes |
| --- | --- | --- |
| `Enter` | Sends the current message | While a turn is running, non-empty input is queued as follow-up feedback. |
| `Enter` | Sends the current message | While a turn is running, non-empty input is durably queued as a follow-up before the composer clears. |
| `Ctrl+Enter` or `/steer <text>` | Adds guidance to the active turn | The guidance is persisted first; if the turn cannot accept it, it remains a normal follow-up. |
| `Shift+Enter`, `Alt+Enter`, or `Ctrl+J` | Inserts a newline | Plain `Enter` is reserved for send/confirm. |
| Plain `Up` / `Down` while idle | Recalls older or newer submitted prompts | In a running turn, the same keys navigate queued follow-up feedback. |
| `PageUp` / `PageDown` | Scrolls the transcript | Works regardless of the current chat state. |
@@ -642,6 +643,12 @@ Chat and transcript shortcuts:
| `/paste-image` | Pastes a clipboard image | Command form of the same image-only action. |
| A line starting with `!` | Runs a shell command directly | The command runs locally without asking the model. |
`/queue list` shows bounded previews without loading full bodies. Use `/queue
show|edit|delete|move`, `/queue pause|resume`, and `/queue retry|refresh` to
inspect or manage pending work. After crash recovery the inbox is paused, so
review it and run `/queue resume` before dispatch continues. Each item is
limited to 4 MiB; a session accepts at most 64 items and 64 MiB total.
Mode and display shortcuts:
| Key or command | What it does | Notes |
+194
View File
@@ -0,0 +1,194 @@
package acp
import (
"context"
"encoding/json"
"strings"
"reasonix/internal/control"
"reasonix/internal/sessioninbox"
)
func (s *service) sessionAPI(sess *acpSession) (control.SessionAPI, error) {
if sess == nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: "unknown session"}
}
ctrl := sess.currentCtrl()
api, ok := ctrl.(control.SessionAPI)
if !ok {
return nil, &RPCError{Code: ErrInternal, Message: "session controller does not expose inbox"}
}
if ensurer, ok := any(api).(interface{ EnsureSessionPath() }); ok {
ensurer.EnsureSessionPath()
}
return api, nil
}
func (s *service) sessionInboxEnqueue(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxEnqueueParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: sessionInboxEnqueueMethod + ": " + err.Error()}
}
sess := s.session(p.SessionID)
api, err := s.sessionAPI(sess)
if err != nil {
return nil, err
}
text := strings.TrimSpace(p.Text)
if text == "" {
return nil, &RPCError{Code: ErrInvalidParams, Message: sessionInboxEnqueueMethod + ": empty text"}
}
intent := sessioninbox.IntentFollowup
if strings.EqualFold(p.Intent, "steer") {
intent = sessioninbox.IntentSteer
}
req := control.InboxRequest{
Intent: intent,
Display: text,
Raw: text,
Submit: text,
Source: "acp",
Idempotency: strings.TrimSpace(p.IdempotencyKey),
}
var rec sessioninbox.InboxReceipt
if intent == sessioninbox.IntentSteer {
rec, err = api.TryEnqueueAndSteer(req)
} else {
// ACP owns a blocking session/prompt response boundary. Keep follow-ups
// passive here so the prompt handler drains them with the same sink;
// detached Controller dispatch would race the transition between turns.
rec, err = api.EnqueueInbox(req)
}
if err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return map[string]any{
"itemId": rec.ItemID,
"disposition": string(rec.Disposition),
"position": rec.Position,
"paused": rec.Paused,
"idempotent": rec.Idempotent,
}, nil
}
func (s *service) sessionInboxList(_ context.Context, raw json.RawMessage) (any, error) {
var p struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
return api.InboxSnapshot(), nil
}
func (s *service) sessionInboxGet(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxItemParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
meta, env, err := api.ReadInboxItem(p.ItemID)
if err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return map[string]any{"meta": meta, "envelope": env}, nil
}
func (s *service) sessionInboxUpdate(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxUpdateParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
text := strings.TrimSpace(p.Text)
meta, err := api.UpdateInboxItem(p.ItemID, text, text, text)
if err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return meta, nil
}
func (s *service) sessionInboxDelete(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxItemParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
if err := api.DeleteInboxItem(p.ItemID); err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return map[string]any{"ok": true}, nil
}
func (s *service) sessionInboxMove(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxMoveParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
if err := api.MoveInboxItem(p.ItemID, p.ToIndex); err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return map[string]any{"ok": true}, nil
}
func (s *service) sessionInboxSetPaused(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxPauseParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
if err := api.SetInboxPaused(p.Paused); err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return map[string]any{"paused": p.Paused}, nil
}
func (s *service) sessionInboxRetry(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxItemParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
if err := api.RetryInboxItem(p.ItemID); err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return map[string]any{"ok": true}, nil
}
func (s *service) sessionInboxRefresh(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionInboxItemParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: err.Error()}
}
api, err := s.sessionAPI(s.session(p.SessionID))
if err != nil {
return nil, err
}
if err := api.RefreshInboxReferences(p.ItemID); err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: err.Error()}
}
return map[string]any{"ok": true}, nil
}
+47
View File
@@ -0,0 +1,47 @@
package acp
import (
"context"
"errors"
"reasonix/internal/sessioninbox"
)
type acpInboxController interface {
InboxSnapshot() sessioninbox.InboxSnapshot
RunInboxTurn(context.Context, string) error
}
func drainACPInbox(ctx context.Context, ctrl acpController, runErr error) error {
if runErr != nil {
return runErr
}
inbox, ok := ctrl.(acpInboxController)
if !ok {
return nil
}
for {
snap := inbox.InboxSnapshot()
if snap.Paused {
return nil
}
nextID := ""
for _, item := range snap.Items {
if item.State == sessioninbox.StateQueued {
nextID = item.ID
break
}
}
if nextID == "" {
return nil
}
err := inbox.RunInboxTurn(ctx, nextID)
if errors.Is(err, sessioninbox.ErrNotFound) || errors.Is(err, sessioninbox.ErrInvalidState) {
// A concurrent queue edit won the claim; refresh the FIFO snapshot.
continue
}
if err != nil {
return err
}
}
}
+90
View File
@@ -0,0 +1,90 @@
package acp
import (
"context"
"encoding/json"
"testing"
"time"
"reasonix/internal/control"
"reasonix/internal/event"
)
type durableQueueFactory struct {
dir string
behavior func(ctx context.Context, sink event.Sink, input string) error
}
func (f *durableQueueFactory) SessionDir() string { return f.dir }
func (f *durableQueueFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
runner := &fakeRunner{sink: p.Sink, behavior: f.behavior}
return control.New(control.Options{Runner: runner, Sink: p.Sink, SessionDir: f.dir}), nil
}
func TestSessionPromptDrainsDurableFollowupBeforeResponding(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
inputs := make(chan string, 2)
factory := &durableQueueFactory{
dir: t.TempDir(),
behavior: func(ctx context.Context, _ event.Sink, input string) error {
inputs <- input
if input == "start" {
close(started)
select {
case <-release:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
},
}
client, stop := startServer(t, factory)
defer stop()
sessionID := openSession(t, client)
promptCh := client.callAsync("session/prompt", SessionPromptParams{
SessionID: sessionID,
Prompt: []ContentBlock{{Type: "text", Text: "start"}},
})
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("first ACP prompt did not start")
}
enqueue := client.call(t, sessionInboxEnqueueMethod, SessionInboxEnqueueParams{
SessionID: sessionID, Text: "queued followup", Intent: "followup", IdempotencyKey: "acp-msg-1",
})
if enqueue.Error != nil {
t.Fatalf("durable enqueue failed: %+v", enqueue.Error)
}
close(release)
for _, want := range []string{"start", "queued followup"} {
select {
case got := <-inputs:
if got != want {
t.Fatalf("ACP input = %q, want %q", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("missing ACP input %q", want)
}
}
_, promptResp := drainPrompt(t, client, promptCh)
if promptResp.Error != nil {
t.Fatalf("session/prompt errored: %+v", promptResp.Error)
}
listed := client.call(t, sessionInboxListMethod, map[string]string{"sessionId": sessionID})
if listed.Error != nil {
t.Fatalf("inbox list errored: %+v", listed.Error)
}
var snapshot struct {
Items []json.RawMessage `json:"items"`
}
if err := json.Unmarshal(listed.Result, &snapshot); err != nil {
t.Fatal(err)
}
if len(snapshot.Items) != 0 {
t.Fatalf("ACP left completed durable items queued: %s", listed.Result)
}
}
+60 -2
View File
@@ -90,6 +90,8 @@ type AgentCapabilities struct {
// ACP v1 reserves agentCapabilities._meta for vendor capability discovery.
type ReasonixExtensionCapabilities struct {
SessionSteer *SessionSteerCapability `json:"sessionSteer,omitempty"`
// SessionInbox advertises the durable session-level instruction queue.
SessionInbox *SessionInboxCapability `json:"sessionInbox,omitempty"`
// SessionReloadExtensions advertises the vendor runtime-reload method.
SessionReloadExtensions *SessionReloadExtensionsCapability `json:"sessionReloadExtensions,omitempty"`
// ExtensionSurface advertises structured extension-UI surface support:
@@ -102,6 +104,12 @@ type SessionSteerCapability struct {
Method string `json:"method"`
}
// SessionInboxCapability advertises durable inbox methods (schemaVersion 1).
type SessionInboxCapability struct {
SchemaVersion int `json:"schemaVersion"`
Methods map[string]string `json:"methods"`
}
// SessionReloadExtensionsCapability identifies the vendor-namespaced runtime
// reload method.
type SessionReloadExtensionsCapability struct {
@@ -476,12 +484,62 @@ type SessionSteerParams struct {
Prompt []ContentBlock `json:"prompt"`
}
// SessionSteerResult acknowledges that the active turn accepted the guidance.
type SessionSteerResult struct{}
// SessionSteerResult acknowledges durable steer admission.
type SessionSteerResult struct {
ItemID string `json:"itemId,omitempty"`
Disposition string `json:"disposition,omitempty"`
}
// sessionSteerMethod follows ACP v1's reserved vendor-extension namespace.
const sessionSteerMethod = "_reasonix.io/session/steer"
const (
sessionInboxSchemaVersion = 1
sessionInboxEnqueueMethod = "_reasonix.io/session/inbox/enqueue"
sessionInboxListMethod = "_reasonix.io/session/inbox/list"
sessionInboxGetMethod = "_reasonix.io/session/inbox/get"
sessionInboxUpdateMethod = "_reasonix.io/session/inbox/update"
sessionInboxDeleteMethod = "_reasonix.io/session/inbox/delete"
sessionInboxMoveMethod = "_reasonix.io/session/inbox/move"
sessionInboxPauseMethod = "_reasonix.io/session/inbox/setPaused"
sessionInboxRetryMethod = "_reasonix.io/session/inbox/retry"
sessionInboxRefreshMethod = "_reasonix.io/session/inbox/refresh"
)
// SessionInboxEnqueueParams is the durable inbox enqueue request.
type SessionInboxEnqueueParams struct {
SessionID string `json:"sessionId"`
Text string `json:"text"`
Intent string `json:"intent,omitempty"` // followup | steer
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
// SessionInboxItemParams identifies one inbox item.
type SessionInboxItemParams struct {
SessionID string `json:"sessionId"`
ItemID string `json:"itemId"`
}
// SessionInboxUpdateParams rewrites an item body.
type SessionInboxUpdateParams struct {
SessionID string `json:"sessionId"`
ItemID string `json:"itemId"`
Text string `json:"text"`
}
// SessionInboxMoveParams reorders an item (toIndex is 0-based).
type SessionInboxMoveParams struct {
SessionID string `json:"sessionId"`
ItemID string `json:"itemId"`
ToIndex int `json:"toIndex"`
}
// SessionInboxPauseParams toggles pause.
type SessionInboxPauseParams struct {
SessionID string `json:"sessionId"`
Paused bool `json:"paused"`
}
// SessionReloadExtensionsParams addresses one live ACP session.
type SessionReloadExtensionsParams struct {
SessionID string `json:"sessionId"`
+53 -6
View File
@@ -25,6 +25,7 @@ import (
"reasonix/internal/jobs"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/sessioninbox"
"reasonix/internal/store"
"reasonix/internal/tool/builtin"
)
@@ -138,6 +139,15 @@ func Serve(ctx context.Context, r io.Reader, w io.Writer, factory Factory, info
conn.Handle("session/resume", svc.sessionResume)
conn.Handle("session/prompt", svc.sessionPrompt)
conn.Handle(sessionSteerMethod, svc.sessionSteer)
conn.Handle(sessionInboxEnqueueMethod, svc.sessionInboxEnqueue)
conn.Handle(sessionInboxListMethod, svc.sessionInboxList)
conn.Handle(sessionInboxGetMethod, svc.sessionInboxGet)
conn.Handle(sessionInboxUpdateMethod, svc.sessionInboxUpdate)
conn.Handle(sessionInboxDeleteMethod, svc.sessionInboxDelete)
conn.Handle(sessionInboxMoveMethod, svc.sessionInboxMove)
conn.Handle(sessionInboxPauseMethod, svc.sessionInboxSetPaused)
conn.Handle(sessionInboxRetryMethod, svc.sessionInboxRetry)
conn.Handle(sessionInboxRefreshMethod, svc.sessionInboxRefresh)
conn.Handle(sessionReloadExtensionsMethod, svc.sessionReloadExtensions)
conn.Handle(sessionStatusMethod, svc.sessionStatus)
conn.Handle("session/set_config_option", svc.sessionSetConfigOption)
@@ -598,7 +608,21 @@ func (s *service) initialize(_ context.Context, raw json.RawMessage) (any, error
MCPCapabilities: MCPCapabilities{HTTP: true, SSE: false},
Meta: map[string]any{
"reasonix.io": ReasonixExtensionCapabilities{
SessionSteer: &SessionSteerCapability{Method: sessionSteerMethod},
SessionSteer: &SessionSteerCapability{Method: sessionSteerMethod},
SessionInbox: &SessionInboxCapability{
SchemaVersion: sessionInboxSchemaVersion,
Methods: map[string]string{
"enqueue": sessionInboxEnqueueMethod,
"list": sessionInboxListMethod,
"get": sessionInboxGetMethod,
"update": sessionInboxUpdateMethod,
"delete": sessionInboxDeleteMethod,
"move": sessionInboxMoveMethod,
"setPaused": sessionInboxPauseMethod,
"retry": sessionInboxRetryMethod,
"refresh": sessionInboxRefreshMethod,
},
},
SessionReloadExtensions: &SessionReloadExtensionsCapability{Method: sessionReloadExtensionsMethod},
ExtensionSurface: &ExtensionSurfaceCapability{Supported: true, SchemaVersion: reasonixExtensionSurfaceSchemaVersion},
},
@@ -1152,7 +1176,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
s.finishTurn(ctx, sess)
cancel()
}()
runErr := sess.ctrl.RunTurn(runCtx, text)
runErr := drainACPInbox(runCtx, sess.ctrl, sess.ctrl.RunTurn(runCtx, text))
statusEvent := sess.status.finishTurn(
runErr,
@@ -1180,8 +1204,9 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
return res, nil
}
// sessionSteer injects user guidance into an active turn and acknowledges once
// the agent has queued it for the next safe loop boundary.
// sessionSteer durably persists guidance then attempts mid-turn admission.
// Parameter/session errors remain RPC errors; busy rejection returns a
// disposition so clients can keep the durable follow-up.
func (s *service) sessionSteer(_ context.Context, raw json.RawMessage) (any, error) {
var p SessionSteerParams
if err := json.Unmarshal(raw, &p); err != nil {
@@ -1195,10 +1220,32 @@ func (s *service) sessionSteer(_ context.Context, raw json.RawMessage) (any, err
if text == "" {
return nil, &RPCError{Code: ErrInvalidParams, Message: sessionSteerMethod + ": empty prompt"}
}
if !sess.currentCtrl().TrySteer(text) {
ctrl := sess.currentCtrl()
if api, ok := ctrl.(control.SessionAPI); ok {
if ensurer, ok := any(api).(interface{ EnsureSessionPath() }); ok {
ensurer.EnsureSessionPath()
}
// Durable path when the session has a transcript path; ephemeral
// test controllers without persistence fall back to TrySteer.
if api.SessionPath() != "" {
rec, err := api.TryEnqueueAndSteer(control.InboxRequest{
Intent: sessioninbox.IntentSteer,
Display: text,
Raw: text,
Submit: text,
Source: "acp",
})
if err != nil {
return nil, &RPCError{Code: ErrInvalidRequest, Message: sessionSteerMethod + ": " + err.Error()}
}
return SessionSteerResult{ItemID: rec.ItemID, Disposition: string(rec.Disposition)}, nil
}
}
// Compatibility for older controller stubs / pathless sessions.
if !ctrl.TrySteer(text) {
return nil, &RPCError{Code: ErrInvalidRequest, Message: sessionSteerMethod + ": session has no active prompt"}
}
return SessionSteerResult{}, nil
return SessionSteerResult{Disposition: "steer_accepted"}, nil
}
// sessionReloadExtensions rebuilds a session's agent runtime in place —
+64 -19
View File
@@ -444,13 +444,12 @@ type Agent struct {
// final participating run/background job so verification remains isolated.
workspaceLease *workspacelease.Owner
// steerQueue holds mid-turn user messages queued while the agent is
// running. Each is consumed once per loop iteration, persisted to the
// session for history replay, and sent to the model as guidance (not a
// new task). Cache miss for the next API call is unavoidable but limited
// to one call — the prefix stays stable otherwise.
// steerQueue holds mid-turn guidance admitted while the agent is running.
// Entries keep a durable inbox item ID plus a loader so full bodies are not
// retained in the agent heap beyond need. Cache miss for the next API call
// is unavoidable but limited to one call — the prefix stays stable otherwise.
steerMu sync.Mutex
steerQueue []string
steerQueue []steerEntry
steerConsumed bool
// steerRunActive is true while Run is executing. Steer only queues while
// it is set; once the turn's exit flush has drained the queue, later
@@ -926,6 +925,14 @@ func trimLeadingSteerWrapper(content string) (string, bool) {
return content, false
}
// steerEntry is one mid-turn guidance admission.
type steerEntry struct {
itemID string
load func() (string, error)
// text is a fallback when load is nil (legacy Steer(string) path).
text string
}
// Steer queues a message for mid-turn injection. It reports whether an active
// turn accepted the text; on false nothing was queued and the caller must
// deliver it another way (typically as a new turn). Without the active check,
@@ -933,12 +940,18 @@ func trimLeadingSteerWrapper(content string) (string, bool) {
// controller observing running=false would sit in the queue unconsumed and
// unpersisted — invisible to both the model and history.
func (a *Agent) Steer(text string) bool {
return a.SteerItem("", func() (string, error) { return text, nil })
}
// SteerItem queues durable-inbox guidance identified by itemID. load is called
// only when the entry is consumed so the agent does not retain every body.
func (a *Agent) SteerItem(itemID string, load func() (string, error)) bool {
a.steerMu.Lock()
defer a.steerMu.Unlock()
if !a.steerRunActive {
return false
}
a.steerQueue = append(a.steerQueue, text)
a.steerQueue = append(a.steerQueue, steerEntry{itemID: itemID, load: load})
a.steerConsumed = false
return true
}
@@ -950,16 +963,36 @@ func (a *Agent) SteerConsumed() bool {
return a.steerConsumed
}
func (a *Agent) consumeSteer() (string, bool) {
// SetSink replaces the agent's event sink. Controllers use this to wrap the
// sink after construction (e.g. durable inbox observation) without rebuilding
// the agent.
func (a *Agent) SetSink(sink event.Sink) {
if a == nil {
return
}
if nilutil.IsNil(sink) {
sink = event.Discard
}
a.sink = sink
}
func (a *Agent) consumeSteer() (text, itemID string, ok bool) {
a.steerMu.Lock()
defer a.steerMu.Unlock()
if len(a.steerQueue) == 0 {
return "", false
return "", "", false
}
t := a.steerQueue[0]
e := a.steerQueue[0]
a.steerQueue = a.steerQueue[1:]
a.steerConsumed = len(a.steerQueue) == 0
return t, true
if e.load != nil {
t, err := e.load()
if err != nil {
return "", e.itemID, false
}
return t, e.itemID, true
}
return e.text, e.itemID, true
}
// closeSteerIntakeIfIdle atomically closes the normal-completion race between
@@ -990,8 +1023,14 @@ func (a *Agent) flushSteerQueue() {
}
a.steerRunActive = false
a.steerMu.Unlock()
for _, text := range pending {
a.RecordUnappliedSteer(text)
for _, e := range pending {
text := e.text
if e.load != nil {
if t, err := e.load(); err == nil {
text = t
}
}
a.RecordUnappliedSteer(text, e.itemID)
}
}
@@ -1004,11 +1043,16 @@ func UnappliedSteerNotice(text string) string {
// RecordUnappliedSteer stores guidance that could not affect its intended
// in-flight turn. The orphan-tool sentinel makes older readers drop the record
// during wire normalization, while current readers use LocalOnly to exclude it
// before every provider request.
func (a *Agent) RecordUnappliedSteer(text string) {
// before every provider request. itemID correlates the notice with the durable
// session inbox entry when one exists.
func (a *Agent) RecordUnappliedSteer(text string, itemID ...string) {
if a == nil || a.session == nil {
return
}
id := ""
if len(itemID) > 0 {
id = itemID[0]
}
a.session.Add(provider.Message{
Role: provider.RoleTool,
Content: a.withTurnPreferences(midTurnSteerMessage(text)),
@@ -1017,10 +1061,11 @@ func (a *Agent) RecordUnappliedSteer(text string) {
LocalOnly: true,
})
a.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Code: event.NoticeCodeUnappliedSteer,
Text: UnappliedSteerNotice(text),
Kind: event.Notice,
Level: event.LevelWarn,
Code: event.NoticeCodeUnappliedSteer,
Text: UnappliedSteerNotice(text),
ItemID: id,
})
}
+1
View File
@@ -600,6 +600,7 @@ func recoveryTrashSidecars(path string) []string {
path+".telemetry.json",
store.SessionCheckpointDir(path),
store.SessionJobsDir(path),
store.SessionInboxDir(path),
)
return artifacts
}
+6 -2
View File
@@ -279,9 +279,13 @@ func (a *Agent) runToolLoop(ctx context.Context, state *runLoopState) error {
// survives tab switches and history replay. The model sees it as
// guidance (with a prefix), not a new task. One cache miss per
// steer is unavoidable — the model must see the new instruction.
if text, ok := a.consumeSteer(); ok {
if text, itemID, ok := a.consumeSteer(); ok {
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(midTurnSteerMessage(text))})
a.sink.Emit(event.Event{Kind: event.Steer, Text: text})
a.sink.Emit(event.Event{Kind: event.Steer, Text: text, ItemID: itemID})
} else if itemID != "" {
// Loader failed after dequeue: durable entry stays for inspection
// (unapplied path marks uncertain + pause via the notice sink).
a.RecordUnappliedSteer("(body load failed)", itemID)
}
schemas := a.tools.Schemas()
prefixShape := a.capturePrefixShape(schemas)
+1
View File
@@ -2068,6 +2068,7 @@ func migrateSessionSidecars(oldPath, newPath, newID string) error {
{store.SessionRecoveryState(oldPath), store.SessionRecoveryState(newPath)},
{store.SessionCheckpointDir(oldPath), store.SessionCheckpointDir(newPath)},
{store.SessionJobsDir(oldPath), store.SessionJobsDir(newPath)},
{store.SessionInboxDir(oldPath), store.SessionInboxDir(newPath)},
} {
// A source name past the filesystem limit cannot exist; renaming it
// would just manufacture ENAMETOOLONG instead of a clean not-exist.
+145 -82
View File
@@ -18,6 +18,7 @@ import (
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/secrets"
"reasonix/internal/sessioninbox"
)
// GatewayConfig 是 BotGateway 的配置。
@@ -779,87 +780,127 @@ func (gw *BotGateway) handleMessage(ctx context.Context, binding AdapterBinding,
cleanup := gw.addPendingReaction(ctx, binding.Platform, binding.Adapter, msg)
queueMode := gw.queueMode(key, msg)
warnDeprecatedQueueDrop(gw.cfg.QueueDrop)
if gw.sessions.IsActive(key) {
switch queueMode {
case QueueModeSteer:
if gw.steerActiveSession(ctx, binding.Adapter, key, msg) {
gw.logger.Info("bot message steered into active turn", "session", key[:8])
if cleanup != nil {
cleanup()
// Busy session: durable inbox is the authority (not SessionManager.pending).
if IsSlashBypass(msg.Text) {
// Slash commands still acquire through the session lock below.
} else {
switch queueMode {
case QueueModeSteer:
if rec, ok := gw.steerActiveSessionDurable(ctx, binding.Adapter, key, msg); ok {
gw.logger.Info("bot message steered into active turn", "session", key[:8], "item", rec.ItemID)
if cleanup != nil {
cleanup()
}
_ = gw.sendText(ctx, binding.Adapter, msg, formatQueuedReceipt(rec)+"(已并入当前任务)")
return
}
_ = gw.sendText(ctx, binding.Adapter, msg, "已收到,会并入当前任务。")
case QueueModeInterrupt:
gw.cancelActiveSession(key)
runReactionCleanups(gw.takeReactionCleanups(key))
rec, err := gw.interruptActiveSessionDurable(ctx, binding.Adapter, key, msg)
gw.storeReactionCleanup(key, cleanup)
if err != nil {
gw.logger.Warn("bot interrupt enqueue failed", "session", key[:8], "err", err)
_ = gw.sendText(ctx, binding.Adapter, msg, "排队失败:"+err.Error())
return
}
gw.logger.Info("bot active turn interrupted; newest message durable-queued", "session", key[:8], "item", rec.ItemID)
_ = gw.sendText(ctx, binding.Adapter, msg, "已停止当前任务。"+formatQueuedReceipt(rec))
return
case QueueModeCollect:
if rec, err := gw.collectActiveSessionDurable(ctx, binding.Adapter, key, msg); err == nil {
gw.storeReactionCleanup(key, cleanup)
_ = gw.sendText(ctx, binding.Adapter, msg, formatQueuedReceipt(rec))
return
} else if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
if cleanup != nil {
cleanup()
}
_ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发,或使用 /queue pause 后清理。")
return
}
default: // followup
if rec, err := gw.followupActiveSessionDurable(ctx, binding.Adapter, key, msg); err == nil {
gw.storeReactionCleanup(key, cleanup)
_ = gw.sendText(ctx, binding.Adapter, msg, formatQueuedReceipt(rec))
return
} else if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
if cleanup != nil {
cleanup()
}
_ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发。")
return
}
}
case QueueModeInterrupt:
gw.cancelActiveSession(key)
runReactionCleanups(gw.takeReactionCleanups(key))
result := gw.sessions.ReplacePending(key, msg)
gw.storeReactionCleanup(key, cleanup)
gw.logger.Info("bot active turn interrupted; newest message queued", "session", key[:8], "pending", result.Pending)
_ = gw.sendText(ctx, binding.Adapter, msg, "已停止当前任务,稍后处理这条新消息。")
return
}
}
// session 并发控制
// session 并发控制 — only the active-turn lock remains here; bodies live in inbox.
result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{
Mode: queueMode,
Cap: gw.cfg.QueueCap,
Drop: gw.cfg.QueueDrop,
Mode: QueueModeFollowup, // never drop_old; capacity enforced by inbox
Cap: sessioninbox.DefaultMaxItems,
Drop: QueueDropNew,
})
if result.Rejected {
gw.logger.Warn("bot queue rejected message", "session", key[:8], "pending", result.Pending, "mode", result.Mode)
if cleanup != nil {
cleanup()
}
_ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发,或使用 /queue interrupt 中断当前任务。")
_ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发,或使用 /queue 管理队列。")
return
}
if result.Queued {
gw.logger.Debug("message queued", "session", key[:8], "mode", result.Mode, "pending", result.Pending, "dropped", result.Dropped)
gw.storeReactionCleanup(key, cleanup)
return
}
if !result.Acquired {
gw.logger.Debug("session busy without queue action", "session", key[:8])
gw.storeReactionCleanup(key, cleanup)
return
}
// Run the turn on its own goroutine so the dispatch loop stays free to read
// the next inbound message. A turn that hits interactive approval/ask blocks
// inside RunTurn waiting for ctrl.Approve/AnswerQuestion — and the ONLY path
// that calls those is handleSlashCommand on this same dispatch goroutine. Run
// it inline and the loop can never deliver the /approve (or card) reply that
// would unblock it: the session wedges until restart (#4701, #4863, #4402).
// Per-session serialization is still held by the session lock (active[key]),
// which the deferred Release inside runTurn clears.
gw.turnWG.Go(func() {
gw.runTurn(ctx, binding.Adapter, key, msg, cleanup)
})
gw.dispatchQueueResult(ctx, binding.Adapter, key, msg, cleanup, result)
}
func (gw *BotGateway) queueMode(key string, msg InboundMessage) string {
return gw.sessions.QueueMode(key, gw.cfg.QueueMode)
}
func (gw *BotGateway) steerActiveSession(ctx context.Context, adapter Adapter, key string, msg InboundMessage) bool {
func (gw *BotGateway) sessionAPI(key string) control.SessionAPI {
gw.mu.Lock()
state, ok := gw.controllers[key]
gw.mu.Unlock()
if !ok || state == nil || state.ctrl == nil {
return nil
}
if api, ok := state.ctrl.(control.SessionAPI); ok {
return api
}
return nil
}
func (gw *BotGateway) steerActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, bool) {
text := strings.TrimSpace(msg.Text)
if text == "" && len(msg.MediaURLs) == 0 && len(msg.Media) == 0 {
return false
return sessioninbox.InboxReceipt{}, false
}
gw.mu.Lock()
state, ok := gw.controllers[key]
gw.mu.Unlock()
if !ok || state.ctrl == nil {
return false
return sessioninbox.InboxReceipt{}, false
}
text = gw.inputTextWithMedia(ctx, adapter, msg, state)
msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
text = msg.Text
if strings.TrimSpace(text) == "" {
return false
return sessioninbox.InboxReceipt{}, false
}
controller, ok := state.ctrl.(interface{ TrySteer(string) bool })
return ok && controller.TrySteer(text)
msg.Text = text
api, ok := state.ctrl.(control.SessionAPI)
if !ok {
// Legacy fallback.
if steerer, ok := state.ctrl.(interface{ TrySteer(string) bool }); ok && steerer.TrySteer(text) {
return sessioninbox.InboxReceipt{Disposition: sessioninbox.DispositionSteerAccepted}, true
}
return sessioninbox.InboxReceipt{}, false
}
rec, err := enqueueViaInbox(api, msg, sessioninbox.IntentSteer)
if err != nil {
return sessioninbox.InboxReceipt{}, false
}
return rec, true
}
func (gw *BotGateway) cancelActiveSession(key string) {
@@ -1589,9 +1630,16 @@ func (gw *BotGateway) handleSlashCommand(ctx context.Context, adapter Adapter, k
_ = gw.sendText(ctx, adapter, msg, text)
case strings.HasPrefix(msg.Text, "/queue"):
if reply, handled, kick := gw.handleQueueInboxCommand(ctx, key, msg); handled {
_ = gw.sendText(ctx, adapter, msg, reply)
if kick {
gw.kickInbox(ctx, adapter, key, msg)
}
return
}
mode, clear, statusOnly, ok := parseQueueCommand(msg.Text)
if !ok {
_ = gw.sendText(ctx, adapter, msg, "用法: /queue steer|followup|collect|interrupt|status|default")
_ = gw.sendText(ctx, adapter, msg, "用法: /queue steer|followup|collect|interrupt|status|list|show|delete|move|pause|resume|retry|default")
return
}
if statusOnly {
@@ -1678,6 +1726,22 @@ func (gw *BotGateway) handleSlashCommand(ctx context.Context, adapter Adapter, k
}
}
func (gw *BotGateway) kickInbox(ctx context.Context, adapter Adapter, key string, fallback InboundMessage) {
if gw.sessions.IsActive(key) {
return
}
next := gw.nextInboxTurn(key, fallback)
if next == nil {
return
}
if !gw.sessions.TryAcquireIdle(key) {
return
}
gw.turnWG.Go(func() {
gw.runTurnItem(ctx, adapter, key, next.msg, next.itemID, nil)
})
}
func slashCommandVerb(text string) string {
parts := strings.Fields(strings.TrimSpace(text))
if len(parts) == 0 {
@@ -1894,11 +1958,18 @@ func parseQueueCommand(text string) (mode string, clear bool, statusOnly bool, o
}
func (gw *BotGateway) queueStatusText(key string, msg InboundMessage) string {
return fmt.Sprintf("当前队列模式:%s\n当前会话排队: %d\n全局上限: %d\n溢出策略: %s\n用法:/queue steer|followup|collect|interrupt|status|default",
inboxN := 0
paused := false
if api := gw.sessionAPI(key); api != nil {
snap := api.InboxSnapshot()
inboxN = len(snap.Items)
paused = snap.Paused
}
return fmt.Sprintf("当前队列模式:%s\n持久化 Inbox: %d%s\n全局上限: %d\n溢出策略: 拒绝新消息(queue_drop 已弃用)\n用法:/queue steer|followup|collect|interrupt|status|list|show|delete|move|pause|resume|retry|default",
queueModeLabel(gw.queueMode(key, msg)),
gw.sessions.PendingCount(key),
gw.cfg.QueueCap,
queueDropLabel(gw.cfg.QueueDrop),
inboxN,
map[bool]string{true: " (paused)", false: ""}[paused],
sessioninbox.DefaultMaxItems,
)
}
@@ -1915,17 +1986,6 @@ func queueModeLabel(mode string) string {
}
}
func queueDropLabel(drop string) string {
switch NormalizeQueueDrop(drop) {
case QueueDropOld:
return "丢弃最早消息"
case QueueDropNew:
return "拒绝新消息"
default:
return "压缩摘要"
}
}
func (gw *BotGateway) adapterHealthSummaryText() string {
snapshots := gw.AdapterHealth()
if len(snapshots) == 0 {
@@ -2067,21 +2127,12 @@ func toolApprovalModeLabel(mode string) string {
}
func (gw *BotGateway) runTurn(ctx context.Context, adapter Adapter, key string, msg InboundMessage, cleanup func()) {
gw.runTurnItem(ctx, adapter, key, msg, "", cleanup)
}
func (gw *BotGateway) runTurnItem(ctx context.Context, adapter Adapter, key string, msg InboundMessage, inboxItemID string, cleanup func()) {
gw.logger.Info("bot turn started", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
defer func() {
// 检查是否有等待队列中的消息
next := gw.sessions.Release(key)
if next != nil {
if cleanup != nil {
cleanup()
}
nextCleanup := makeReactionCleanup(gw.takeReactionCleanups(key))
gw.logger.Info("bot pending message released", "platform", next.Platform, "chat_type", next.ChatType, "chat", hashID(next.ChatID), "session", key[:8])
gw.runTurn(ctx, adapter, key, *next, nextCleanup)
return
}
gw.flushReactionCleanups(key, cleanup)
}()
defer gw.finishTurnItem(ctx, adapter, key, msg, cleanup)
// 获取或创建 Controller
state := gw.getOrCreateSession(ctx, key, msg)
@@ -2092,8 +2143,11 @@ func (gw *BotGateway) runTurn(ctx context.Context, adapter Adapter, key string,
gw.rememberSessionReady(msg, state.ctrl)
// 构建输入文本:群聊中在消息前加上发送者名,并把 IM 媒体保存为 @附件引用。
input := gw.inputTextWithMedia(ctx, adapter, msg, state)
if msg.ChatType == ChatGroup {
input := msg.Text
if inboxItemID == "" {
input = gw.inputTextWithMedia(ctx, adapter, msg, state)
}
if inboxItemID == "" && msg.ChatType == ChatGroup {
userName := strings.TrimSpace(msg.UserName)
if msg.ResolveUserName != nil {
if resolved := strings.TrimSpace(msg.ResolveUserName(ctx)); resolved != "" {
@@ -2161,7 +2215,16 @@ func (gw *BotGateway) runTurn(ctx context.Context, adapter Adapter, key string,
}
// 运行一轮对话
err := state.ctrl.RunTurn(turnCtx, input)
var err error
if inboxItemID == "" {
err = state.ctrl.RunTurn(turnCtx, input)
} else if api, ok := state.ctrl.(interface {
RunInboxTurn(context.Context, string) error
}); ok {
err = api.RunInboxTurn(turnCtx, inboxItemID)
} else {
err = fmt.Errorf("controller cannot run durable inbox item")
}
sink.Emit(event.Event{Kind: event.TurnDone, Err: err})
if err != nil {
gw.logger.Warn("turn error", "session", key[:8], "err", err)
+157
View File
@@ -0,0 +1,157 @@
package bot
import (
"context"
"fmt"
"strings"
"reasonix/internal/sessioninbox"
)
func (gw *BotGateway) dispatchQueueResult(ctx context.Context, adapter Adapter, key string, msg InboundMessage, cleanup func(), result QueueResult) {
if result.Queued {
// Unexpected with Cap=max and Drop=new while idle; persist as fallback.
if rec, err := gw.followupActiveSessionDurable(ctx, adapter, key, msg); err == nil {
gw.storeReactionCleanup(key, cleanup)
_ = gw.sendText(ctx, adapter, msg, formatQueuedReceipt(rec))
} else {
gw.storeReactionCleanup(key, cleanup)
}
return
}
if !result.Acquired {
gw.logger.Debug("session busy without queue action", "session", key[:8])
gw.storeReactionCleanup(key, cleanup)
return
}
// Keep the dispatch loop free to deliver approval/answer replies while the
// active turn blocks. The per-session lock still serializes all turns.
gw.turnWG.Go(func() { gw.runTurn(ctx, adapter, key, msg, cleanup) })
}
func (gw *BotGateway) finishTurnItem(ctx context.Context, adapter Adapter, key string, fallback InboundMessage, cleanup func()) {
// Legacy in-memory pending first (compat), then durable session inbox.
next := gw.sessions.Release(key)
nextInboxID := ""
if next == nil {
if queued := gw.nextInboxTurn(key, fallback); queued != nil {
next = &queued.msg
nextInboxID = queued.itemID
// Release made the session idle. If another inbound message wins the
// lock, leave this disk-backed item queued for that turn's drain.
if !gw.sessions.TryAcquireIdle(key) {
if cleanup != nil {
cleanup()
}
return
}
}
}
if next == nil {
gw.flushReactionCleanups(key, cleanup)
return
}
if cleanup != nil {
cleanup()
}
nextCleanup := makeReactionCleanup(gw.takeReactionCleanups(key))
gw.logger.Info("bot pending message released", "platform", next.Platform, "chat_type", next.ChatType, "chat", hashID(next.ChatID), "session", key[:8])
gw.runTurnItem(ctx, adapter, key, *next, nextInboxID, nextCleanup)
}
func (gw *BotGateway) followupActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
api := gw.sessionAPI(key)
if api == nil {
return sessioninbox.InboxReceipt{}, fmt.Errorf("no session controller")
}
gw.mu.Lock()
state := gw.controllers[key]
gw.mu.Unlock()
msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
return enqueueViaInbox(api, msg, sessioninbox.IntentFollowup)
}
func (gw *BotGateway) collectActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
api := gw.sessionAPI(key)
if api == nil {
return sessioninbox.InboxReceipt{}, fmt.Errorf("no session controller")
}
gw.mu.Lock()
state := gw.controllers[key]
gw.mu.Unlock()
msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
return collectAppend(api, msg, gw.sessions.Debounce())
}
func (gw *BotGateway) interruptActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
api := gw.sessionAPI(key)
if api == nil {
return sessioninbox.InboxReceipt{}, fmt.Errorf("no session controller")
}
gw.mu.Lock()
state := gw.controllers[key]
gw.mu.Unlock()
msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
return interruptEnqueue(api, msg)
}
func (gw *BotGateway) prepareDurableInboxMessage(ctx context.Context, adapter Adapter, msg InboundMessage, state *sessionState) InboundMessage {
msg.Text = gw.inputTextWithMedia(ctx, adapter, msg, state)
if msg.ChatType == ChatGroup {
userName := strings.TrimSpace(msg.UserName)
if msg.ResolveUserName != nil {
if resolved := strings.TrimSpace(msg.ResolveUserName(ctx)); resolved != "" {
userName = resolved
}
}
msg.Text = fmt.Sprintf("[%s] %s", userName, msg.Text)
msg.UserName = userName
}
msg.Media = nil
msg.MediaURLs = nil
msg.ResolveUserName = nil
msg.Raw = nil
return msg
}
type botInboxTurn struct {
itemID string
msg InboundMessage
}
// nextInboxTurn loads the next durable FIFO follow-up with its original routing
// metadata. RunInboxTurn performs the atomic queued -> running claim.
func (gw *BotGateway) nextInboxTurn(key string, fallback InboundMessage) *botInboxTurn {
api := gw.sessionAPI(key)
if api == nil {
return nil
}
snap := api.InboxSnapshot()
if snap.Paused {
return nil
}
for _, it := range snap.Items {
if it.State != sessioninbox.StateQueued {
continue
}
_, env, err := api.ReadInboxItem(it.ID)
if err != nil {
continue
}
msg := botMessageFromEnvelope(env, fallback)
if _, hasStoredRoute := env.Extra[botInboxMessageExtraKey]; !hasStoredRoute && it.Idempotency != "" {
msg.MessageID = it.Idempotency
}
return &botInboxTurn{itemID: it.ID, msg: msg}
}
return nil
}
// nextInboxMessage is retained for focused queue inspection tests.
func (gw *BotGateway) nextInboxMessage(key string) *InboundMessage {
next := gw.nextInboxTurn(key, InboundMessage{ChatID: key})
if next == nil {
return nil
}
return &next.msg
}
+11 -4
View File
@@ -1915,12 +1915,19 @@ func TestGatewayQueueInterruptCancelsAndKeepsNewestMessage(t *testing.T) {
if !ctrl.wasCanceled() {
t.Fatal("controller was not canceled")
}
next := gw.sessions.Release(key)
if next == nil || next.Text != "newest request" {
t.Fatalf("release = %#v, want newest request", next)
// Durable interrupt keeps the message in the session inbox (not SessionManager.pending).
if next := gw.sessions.Release(key); next != nil {
t.Fatalf("legacy pending should be empty after durable interrupt, got %#v", next)
}
if n := gw.nextInboxMessage(key); n == nil || n.Text != "newest request" {
// Controllers without SessionAPI cannot durable-queue; accept cancel-only.
if api, ok := any(ctrl).(control.SessionAPI); ok {
_ = api
t.Fatalf("durable inbox missing newest request")
}
}
sent := adapter.sentMessages()
if len(sent) != 1 || !strings.Contains(sent[0].Text, "稍后处理这条新消息") {
if len(sent) != 1 || (!strings.Contains(sent[0].Text, "稍后处理") && !strings.Contains(sent[0].Text, "已持久排队") && !strings.Contains(sent[0].Text, "排队失败")) {
t.Fatalf("sent = %#v, want interrupt acknowledgement", sent)
}
}
+363
View File
@@ -0,0 +1,363 @@
package bot
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/sessioninbox"
)
const botInboxMessageExtraKey = "reasonix.bot.inbound.v1"
type durableBotMessage struct {
Platform Platform `json:"platform"`
ConnectionID string `json:"connectionId,omitempty"`
Domain string `json:"domain,omitempty"`
ChatType ChatType `json:"chatType"`
ChatID string `json:"chatId"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
OperatorID string `json:"operatorId,omitempty"`
MessageID string `json:"messageId,omitempty"`
ThreadID string `json:"threadId,omitempty"`
}
func botInboxExtra(msg InboundMessage) map[string]string {
data, err := json.Marshal(durableBotMessage{
Platform: msg.Platform, ConnectionID: msg.ConnectionID, Domain: msg.Domain,
ChatType: msg.ChatType, ChatID: msg.ChatID, UserID: msg.UserID,
UserName: msg.UserName, OperatorID: msg.OperatorID, MessageID: msg.MessageID,
ThreadID: msg.ThreadID,
})
if err != nil {
return nil
}
return map[string]string{botInboxMessageExtraKey: string(data)}
}
func botMessageFromEnvelope(env sessioninbox.PromptEnvelope, fallback InboundMessage) InboundMessage {
msg := fallback
if raw := env.Extra[botInboxMessageExtraKey]; raw != "" {
var stored durableBotMessage
if json.Unmarshal([]byte(raw), &stored) == nil {
msg.Platform = stored.Platform
msg.ConnectionID = stored.ConnectionID
msg.Domain = stored.Domain
msg.ChatType = stored.ChatType
msg.ChatID = stored.ChatID
msg.UserID = stored.UserID
msg.UserName = stored.UserName
msg.OperatorID = stored.OperatorID
msg.MessageID = stored.MessageID
msg.ThreadID = stored.ThreadID
}
}
msg.Text = firstNonEmptyBotText(env.DisplayText, env.SubmitText, env.RawText)
msg.Media = nil
msg.MediaURLs = nil
msg.ResolveUserName = nil
msg.Raw = nil
return msg
}
func firstNonEmptyBotText(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// enqueueViaInbox durably queues an inbound message on the controller's
// session inbox. Platform message IDs are used as idempotency keys.
func enqueueViaInbox(ctrl control.SessionAPI, msg InboundMessage, intent sessioninbox.InboxIntent) (sessioninbox.InboxReceipt, error) {
if ctrl == nil {
return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
}
if ensurer, ok := ctrl.(interface{ EnsureSessionPath() }); ok {
ensurer.EnsureSessionPath()
}
text := strings.TrimSpace(msg.Text)
if text == "" {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrEmpty
}
idem := strings.TrimSpace(msg.MessageID)
req := control.InboxRequest{
Intent: intent,
Display: text,
Raw: text,
Submit: text,
Source: "bot",
Idempotency: idem,
Extra: botInboxExtra(msg),
}
if intent == sessioninbox.IntentSteer {
return ctrl.TryEnqueueAndSteer(req)
}
// Bot owns synchronous response rendering and drains the durable FIFO itself;
// detached Controller dispatch would lose the platform sink.
return ctrl.EnqueueInbox(req)
}
// collectAppend tries to append text into the last queued follow-up blob within
// the debounce window. Falls back to a new enqueue.
func collectAppend(ctrl control.SessionAPI, msg InboundMessage, debounce time.Duration) (sessioninbox.InboxReceipt, error) {
if ctrl == nil {
return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
}
snap := ctrl.InboxSnapshot()
// Find last queued follow-up.
var last *sessioninbox.InboxItemMeta
for i, it := range slices.Backward(snap.Items) {
if it.State == sessioninbox.StateQueued && it.Intent == sessioninbox.IntentFollowup {
last = &snap.Items[i]
break
}
}
text := strings.TrimSpace(msg.Text)
if last != nil && debounce > 0 && time.Since(last.UpdatedAt) < debounce {
if _, err := ctrl.AppendInboxItem(last.ID, text, strings.TrimSpace(msg.MessageID), botInboxExtra(msg)); err == nil {
return sessioninbox.InboxReceipt{
ItemID: last.ID,
Disposition: sessioninbox.DispositionQueuedFollowup,
Position: snap.Capacity.Items,
Paused: snap.Paused,
Capacity: snap.Capacity,
}, nil
}
}
return enqueueViaInbox(ctrl, msg, sessioninbox.IntentFollowup)
}
// interruptEnqueue cancels the current turn and moves a new item to the front.
func interruptEnqueue(ctrl control.SessionAPI, msg InboundMessage) (sessioninbox.InboxReceipt, error) {
if ctrl == nil {
return sessioninbox.InboxReceipt{}, fmt.Errorf("no controller")
}
ctrl.Cancel()
rec, err := enqueueViaInbox(ctrl, msg, sessioninbox.IntentFollowup)
if err != nil {
return rec, err
}
// Move to front (index 0) so it runs next; do not delete existing queue.
if err := ctrl.MoveInboxItem(rec.ItemID, 0); err != nil {
slog.Warn("bot: move interrupt item to front", "err", err)
}
return rec, nil
}
// formatQueuedReceipt is the user-visible durable queue confirmation.
func formatQueuedReceipt(rec sessioninbox.InboxReceipt) string {
return fmt.Sprintf("已持久排队 #%s", shortItemID(rec.ItemID))
}
func shortItemID(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}
// warnDeprecatedQueueDrop logs once when an old drop policy is still configured.
func warnDeprecatedQueueDrop(drop string) {
switch NormalizeQueueDrop(drop) {
case QueueDropOld, QueueDropSummarize:
slog.Warn("bot: queue_drop is deprecated; capacity rejections no longer drop old messages", "drop", drop)
}
}
// handleQueueInboxCommand extends /queue with durable inbox management.
// Returns handled=false for mode-switch forms of /queue.
func (gw *BotGateway) handleQueueInboxCommand(ctx context.Context, key string, msg InboundMessage) (string, bool, bool) {
_ = ctx
parts := strings.Fields(msg.Text)
if len(parts) < 2 {
return "", false, false
}
sub := strings.ToLower(parts[1])
if !isBotInboxCommand(sub) {
return "", false, false
}
api := gw.sessionAPI(key)
if api == nil {
return "当前没有可管理的会话队列。", true, false
}
// Group chats: only the same session initiator or admins may read bodies.
// Mode-level admin gate is enforced by requireCommandRole on sensitive ops.
switch sub {
case "list", "ls":
return formatBotInboxList(api), true, false
case "show":
return showBotInboxItem(api, parts), true, false
case "delete", "rm":
return deleteBotInboxItem(api, parts), true, false
case "move":
return moveBotInboxItem(api, parts), true, false
case "pause":
return setBotInboxPaused(api, true), true, false
case "resume":
reply := setBotInboxPaused(api, false)
return reply, true, reply == "inbox resumed"
case "retry":
reply := retryBotInboxItem(api, parts)
return reply, true, strings.HasPrefix(reply, "retry #")
case "refresh":
return refreshBotInboxItem(api, parts), true, false
}
return "", false, false
}
func formatBotInboxList(api control.SessionAPI) string {
snap := api.InboxSnapshot()
if len(snap.Items) == 0 {
return "inbox empty" + pausedSuffix(snap.Paused)
}
var b strings.Builder
fmt.Fprintf(&b, "inbox items=%d", len(snap.Items))
if snap.Paused {
b.WriteString(" paused")
}
b.WriteByte('\n')
limit := min(len(snap.Items), 15)
for i := range limit {
it := snap.Items[i]
fmt.Fprintf(&b, "%d. [%s/%s] %s #%s\n", i+1, it.Intent, it.State, it.Preview, shortItemID(it.ID))
}
return strings.TrimRight(b.String(), "\n")
}
func showBotInboxItem(api control.SessionAPI, parts []string) string {
if len(parts) < 3 {
return "用法: /queue show <n|id>"
}
id, err := resolveBotInboxRef(api, parts[2])
if err != nil {
return err.Error()
}
_, env, err := api.ReadInboxItem(id)
if err != nil {
return "show: " + err.Error()
}
return env.SubmitText
}
func deleteBotInboxItem(api control.SessionAPI, parts []string) string {
if len(parts) < 3 {
return "用法: /queue delete <n|id>"
}
id, err := resolveBotInboxRef(api, parts[2])
if err != nil {
return err.Error()
}
if err := api.DeleteInboxItem(id); err != nil {
return "delete: " + err.Error()
}
return "deleted #" + shortItemID(id)
}
func moveBotInboxItem(api control.SessionAPI, parts []string) string {
if len(parts) < 4 {
return "用法: /queue move <n|id> <to>"
}
id, err := resolveBotInboxRef(api, parts[2])
if err != nil {
return err.Error()
}
var to int
if _, err := fmt.Sscanf(parts[3], "%d", &to); err != nil {
return "move: bad index"
}
if err := api.MoveInboxItem(id, to-1); err != nil {
return "move: " + err.Error()
}
return "moved #" + shortItemID(id)
}
func setBotInboxPaused(api control.SessionAPI, paused bool) string {
setter, ok := any(api).(interface{ SetInboxPausedPassive(bool) error })
var err error
if ok {
err = setter.SetInboxPausedPassive(paused)
} else {
err = api.SetInboxPaused(paused)
}
if err != nil {
return err.Error()
}
if paused {
return "inbox paused"
}
return "inbox resumed"
}
func retryBotInboxItem(api control.SessionAPI, parts []string) string {
if len(parts) < 3 {
return "用法: /queue retry <n|id>"
}
id, err := resolveBotInboxRef(api, parts[2])
if err != nil {
return err.Error()
}
retrier, ok := any(api).(interface{ RetryInboxItemPassive(string) error })
var retryErr error
if ok {
retryErr = retrier.RetryInboxItemPassive(id)
} else {
retryErr = api.RetryInboxItem(id)
}
if retryErr != nil {
return retryErr.Error()
}
return "retry #" + shortItemID(id)
}
func refreshBotInboxItem(api control.SessionAPI, parts []string) string {
if len(parts) < 3 {
return "用法: /queue refresh <n|id>"
}
id, err := resolveBotInboxRef(api, parts[2])
if err != nil {
return err.Error()
}
if err := api.RefreshInboxReferences(id); err != nil {
return err.Error()
}
return "refs refreshed #" + shortItemID(id)
}
func isBotInboxCommand(sub string) bool {
switch sub {
case "list", "ls", "show", "delete", "rm", "move", "pause", "resume", "retry", "refresh":
return true
default:
return false
}
}
func pausedSuffix(paused bool) string {
if paused {
return " (paused)"
}
return ""
}
func resolveBotInboxRef(api control.SessionAPI, ref string) (string, error) {
snap := api.InboxSnapshot()
var n int
if _, err := fmt.Sscanf(ref, "%d", &n); err == nil && n >= 1 && n <= len(snap.Items) {
return snap.Items[n-1].ID, nil
}
for _, it := range snap.Items {
if it.ID == ref || strings.HasPrefix(it.ID, ref) {
return it.ID, nil
}
}
return "", fmt.Errorf("unknown inbox item %q", ref)
}
+52
View File
@@ -0,0 +1,52 @@
package bot
import (
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/sessioninbox"
)
func TestCollectAppendDeduplicatesPlatformRedelivery(t *testing.T) {
dir := t.TempDir()
ctrl := control.New(control.Options{
SessionPath: filepath.Join(dir, "s.jsonl"),
SessionDir: dir,
Sink: event.Discard,
})
first := InboundMessage{
Platform: PlatformFeishu, ChatType: ChatDM, ChatID: "chat-1",
UserID: "user-1", MessageID: "msg-1", Text: "first request",
}
rec, err := enqueueViaInbox(ctrl, first, sessioninbox.IntentFollowup)
if err != nil {
t.Fatal(err)
}
second := first
second.MessageID = "msg-2"
second.Text = "second request"
for range 2 {
got, err := collectAppend(ctrl, second, time.Minute)
if err != nil {
t.Fatal(err)
}
if got.ItemID != rec.ItemID {
t.Fatalf("collect item = %q, want %q", got.ItemID, rec.ItemID)
}
}
_, env, err := ctrl.ReadInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if strings.Count(env.SubmitText, "second request") != 1 {
t.Fatalf("redelivered collect body = %q", env.SubmitText)
}
recovered := botMessageFromEnvelope(env, InboundMessage{})
if recovered.Platform != second.Platform || recovered.ChatID != second.ChatID || recovered.MessageID != second.MessageID {
t.Fatalf("durable route metadata was not updated: %+v", recovered)
}
}
+21
View File
@@ -253,6 +253,19 @@ func (sm *SessionManager) TryAcquireWithQueue(key string, msg InboundMessage, op
return QueueResult{Acquired: true, Mode: mode}
}
// TryAcquireIdle acquires an idle session without enqueueing when another
// goroutine wins the race. Durable inbox items already live on disk, so adding
// a second in-memory copy on a failed claim would execute them twice.
func (sm *SessionManager) TryAcquireIdle(key string) bool {
sm.mu.Lock()
defer sm.mu.Unlock()
if sm.active[key] {
return false
}
sm.active[key] = true
return true
}
func (sm *SessionManager) ReplacePending(key string, msg InboundMessage) QueueResult {
sm.mu.Lock()
defer sm.mu.Unlock()
@@ -351,6 +364,14 @@ func (sm *SessionManager) IsActive(key string) bool {
return sm.active[key]
}
// Debounce returns the collect-mode merge window.
func (sm *SessionManager) Debounce() time.Duration {
if sm == nil {
return 1500 * time.Millisecond
}
return sm.debounce
}
// runIfIdle holds the per-gateway admission lock while fn switches runtime
// ownership for key. A normal message cannot become active between the idle
// check and the controller unlink/close sequence.
+14
View File
@@ -230,6 +230,20 @@ func TestSessionManagerRunIfIdleSerializesNewAdmission(t *testing.T) {
}
}
func TestSessionManagerTryAcquireIdleNeverQueuesDuplicate(t *testing.T) {
sm := NewSessionManager(time.Second)
key := "durable-session"
if !sm.TryAcquireIdle(key) {
t.Fatal("first idle acquisition failed")
}
if sm.TryAcquireIdle(key) {
t.Fatal("second idle acquisition unexpectedly succeeded")
}
if next := sm.Release(key); next != nil {
t.Fatalf("failed durable acquisition created an in-memory duplicate: %+v", next)
}
}
func TestHashID(t *testing.T) {
h1 := hashID("user_12345")
h2 := hashID("user_12345")
+170 -41
View File
@@ -38,6 +38,7 @@ import (
"reasonix/internal/provider"
"reasonix/internal/recovery"
"reasonix/internal/sandbox"
"reasonix/internal/sessioninbox"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
@@ -125,9 +126,10 @@ type chatTUI struct {
// should restore after a desktop-style YOLO toggle.
yoloRestoreToolApprovalMode string
// pendingInterject queues input typed while a turn runs; each TurnDone
// dequeues the front and submits it as the next turn.
pendingInterject []string
// inboxSelectedID is the currently highlighted durable inbox item while
// browsing the queue in tuiRunning. Empty means "not browsing". Full bodies
// are never cached here — only the selected ID and the snapshot metadata.
inboxSelectedID string
// queueEditCursor tracks which queued message the user is currently
// browsing/editing via ↑/↓ during tuiRunning. -1 means "not browsing".
queueEditCursor int
@@ -135,6 +137,9 @@ type chatTUI struct {
// presses ↑ to browse the queue, so it can be restored when the cursor
// moves past the end.
queueEditDraft string
// queueConfirmDelete, when true, the next 'd' confirms deletion of the
// selected inbox item.
queueConfirmDelete bool
// history is a resumed session's messages, committed to scrollback once on
// the first WindowSizeMsg so a reopened chat shows its prior transcript.
@@ -784,11 +789,12 @@ func (m *chatTUI) resetSubmittedInputRecall() {
m.submittedInputDraft = ""
}
// navigateQueue moves through the pending interject queue during tuiRunning.
// navigateQueue moves through the durable inbox during tuiRunning.
// delta < 0 means ↑ (older), delta > 0 means ↓ (newer). Returns true if the
// input was updated.
// input was updated. Bodies are loaded by ID only for the selected row.
func (m *chatTUI) navigateQueue(delta int) bool {
if len(m.pendingInterject) == 0 {
items := m.inboxPreviews()
if len(items) == 0 {
return false
}
cursor := m.queueEditCursor
@@ -798,7 +804,7 @@ func (m *chatTUI) navigateQueue(delta int) bool {
}
// First ↑: save the current draft and jump to the last queued item.
m.queueEditDraft = m.input.Value()
cursor = len(m.pendingInterject) - 1
cursor = len(items) - 1
} else {
cursor += delta
}
@@ -806,15 +812,22 @@ func (m *chatTUI) navigateQueue(delta int) bool {
if cursor < 0 {
cursor = 0
}
if cursor >= len(m.pendingInterject) {
if cursor >= len(items) {
// Past the end: restore the draft the user was composing.
m.queueEditCursor = -1
m.inboxSelectedID = ""
m.input.SetValue(m.queueEditDraft)
m.growInputToFit()
return true
}
m.queueEditCursor = cursor
m.input.SetValue(m.pendingInterject[cursor])
m.inboxSelectedID = items[cursor].ID
// Load body only for the selected item (edit path).
if _, env, err := m.ctrl.ReadInboxItem(items[cursor].ID); err == nil {
m.input.SetValue(env.SubmitText)
} else {
m.input.SetValue(items[cursor].Preview)
}
m.growInputToFit()
return true
}
@@ -825,31 +838,54 @@ func (m *chatTUI) navigateQueue(delta int) bool {
func (m *chatTUI) resetQueueNavigation() {
m.queueEditCursor = -1
m.queueEditDraft = ""
m.inboxSelectedID = ""
m.queueConfirmDelete = false
}
// renderQueueIndicator renders the pending-message queue as dim text to show
// above the input box when messages are queued during a running turn.
// renderQueueIndicator renders up to three bounded inbox previews above the
// input when instructions are queued. Full bodies are never materialised here.
func (m chatTUI) renderQueueIndicator() string {
if m.state != tuiRunning || len(m.pendingInterject) == 0 {
items := m.inboxPreviews()
if len(items) == 0 {
return ""
}
queueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // dim grey
highlightStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
var lines []string
for i, msg := range m.pendingInterject {
preview := msg
// Truncate long messages for the compact preview.
runes := []rune(preview)
if len(runes) > 50 {
preview = string(runes[:47]) + "…"
// Ordinary status: at most three rows; full list via /queue.
limit := min(len(items), 3)
for i := range limit {
it := items[i]
preview := it.Preview
if preview == "" {
preview = "(empty)"
}
// Already bounded by sessioninbox.PreviewText; cap display further.
if r := []rune(preview); len(r) > 50 {
preview = string(r[:47]) + "…"
}
cursor := " "
style := queueStyle
if m.queueEditCursor == i {
if m.queueEditCursor == i || m.inboxSelectedID == it.ID {
cursor = "▸"
style = highlightStyle
}
lines = append(lines, style.Render(fmt.Sprintf(" %s [%d] %s", cursor, i+1, preview)))
mark := ""
switch it.State {
case sessioninbox.StateUncertain:
mark = " ?"
case sessioninbox.StateBlocked:
mark = " !"
case sessioninbox.StateRunning, sessioninbox.StateSteerAccepted, sessioninbox.StateSteerConsumed:
mark = " …"
}
lines = append(lines, style.Render(fmt.Sprintf(" %s [%d]%s %s", cursor, it.Pos, mark, preview)))
}
if more := len(items) - limit; more > 0 {
lines = append(lines, queueStyle.Render(fmt.Sprintf(" … +%d more (/queue list)", more)))
}
if m.inboxSnap().Paused {
lines = append(lines, queueStyle.Render(" ⏸ inbox paused (space to resume)"))
}
return strings.Join(lines, "\n")
}
@@ -1431,6 +1467,43 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else if m.recallSubmittedInput(1) {
return m, nil
}
case "alt+up", "meta+up":
if m.handleQueueReorder(-1) {
return m, finalize(m, cmds)
}
case "alt+down", "meta+down":
if m.handleQueueReorder(1) {
return m, finalize(m, cmds)
}
case " ":
if m.inboxQueuedCount() > 0 && m.input.Value() == "" {
m.toggleInboxPaused()
return m, finalize(m, cmds)
}
case "r":
if m.queueEditCursor >= 0 && m.inboxSelectedID != "" {
if err := m.ctrl.RetryInboxItem(m.inboxSelectedID); err != nil {
m.notice("retry: " + err.Error())
} else {
m.notice("retry queued #" + shortID(m.inboxSelectedID))
}
return m, finalize(m, cmds)
}
case "d":
if m.queueEditCursor >= 0 && m.inboxSelectedID != "" {
if !m.queueConfirmDelete {
m.queueConfirmDelete = true
m.notice("press d again to delete #" + shortID(m.inboxSelectedID))
return m, finalize(m, cmds)
}
if err := m.ctrl.DeleteInboxItem(m.inboxSelectedID); err != nil {
m.notice("delete: " + err.Error())
} else {
m.notice("deleted #" + shortID(m.inboxSelectedID))
}
m.resetQueueNavigation()
return m, finalize(m, cmds)
}
case "enter":
// Don't reset queue navigation — the Enter handler below needs
// queueEditCursor to decide whether to save an edit or enqueue.
@@ -1442,6 +1515,8 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Enter handler to save the edit in-place. (#4877)
if m.queueEditCursor < 0 {
m.resetQueueNavigation()
} else {
m.queueConfirmDelete = false
}
}
if imagePasteShortcut(msg.String(), runtime.GOOS) {
@@ -1615,6 +1690,40 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+b":
m.toggleShellOutput()
return m, finalize(m, cmds)
case "ctrl+enter":
// Durable mid-turn steer (terminals without modified Enter use /steer).
if m.state == tuiRunning {
line := strings.TrimSpace(m.input.Value())
if line == "" {
return m, nil
}
// Local /queue always, even while running.
if handled, msg := m.handleQueueSlash(line); handled {
m.notice(msg)
m.input.Reset()
m.pastedBlocks = nil
return m, finalize(m, cmds)
}
body := m.expandPastedBlocks(line)
rec, err := m.enqueueSteer(body, body)
if err != nil {
m.notice("steer: " + err.Error())
// Keep composer text on durable failure.
return m, finalize(m, cmds)
}
switch rec.Disposition {
case sessioninbox.DispositionSteerAccepted:
m.notice(fmt.Sprintf("steer accepted #%s", shortID(rec.ItemID)))
case sessioninbox.DispositionQueuedFollowup:
m.notice(fmt.Sprintf("steer rejected — durable follow-up #%s", shortID(rec.ItemID)))
default:
m.notice(fmt.Sprintf("queued #%s", shortID(rec.ItemID)))
}
m.input.Reset()
m.pastedBlocks = nil
m.resetQueueNavigation()
return m, finalize(m, cmds)
}
case "enter":
if m.state == tuiRunning {
line := strings.TrimSpace(m.input.Value())
@@ -1623,17 +1732,32 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.markFollowTail()
return m, nil
}
if m.queueEditCursor >= 0 && m.queueEditCursor < len(m.pendingInterject) {
// Save the edited text back to the queue slot.
m.pendingInterject[m.queueEditCursor] = m.expandPastedBlocks(line)
// /queue and /steer are local commands even mid-turn.
if handled, msg := m.handleQueueSlash(line); handled {
m.notice(msg)
m.input.Reset()
m.pastedBlocks = nil
return m, finalize(m, cmds)
}
body := m.expandPastedBlocks(line)
items := m.inboxPreviews()
if m.queueEditCursor >= 0 && m.queueEditCursor < len(items) {
id := items[m.queueEditCursor].ID
if _, err := m.ctrl.UpdateInboxItem(id, body, body, body); err != nil {
m.notice("queue update: " + err.Error())
return m, finalize(m, cmds)
}
m.notice(fmt.Sprintf("queue [%d] updated", m.queueEditCursor+1))
m.queueEditCursor = -1
m.queueEditDraft = ""
m.resetQueueNavigation()
} else {
m.pendingInterject = append(m.pendingInterject, m.expandPastedBlocks(line))
m.notice("feedback queued — will send when the current turn finishes")
m.queueEditCursor = -1
m.queueEditDraft = ""
rec, err := m.enqueueFollowup(body, body)
if err != nil {
m.notice("queue: " + err.Error())
// Keep composer text on durable failure / capacity.
return m, finalize(m, cmds)
}
m.notice(fmt.Sprintf("durable follow-up queued #%s — will run when idle", shortID(rec.ItemID)))
m.resetQueueNavigation()
}
m.input.Reset()
m.pastedBlocks = nil
@@ -1652,6 +1776,13 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
if line == "exit" || line == "quit" || line == ":q" {
return m, shutdownNow
}
// /queue and /steer are local even when idle (never model-prompted).
if handled, msg := m.handleQueueSlash(line); handled {
m.notice(msg)
m.input.Reset()
m.pastedBlocks = nil
return m, finalize(m, cmds)
}
m.rememberSubmittedInput(line)
// "# <note>" quick-adds a memory line locally, no model turn. The
@@ -1770,16 +1901,11 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c := m.runStatusline(); c != nil {
cmds = append(cmds, c)
}
if len(m.pendingInterject) > 0 {
interject := m.pendingInterject[0]
m.pendingInterject = m.pendingInterject[1:]
// Reset queue navigation — the indices shifted.
m.queueEditCursor = -1
m.queueEditDraft = ""
cmds = append(cmds, m.startTurn(interject, interject, interject))
}
// Durable inbox dispatch is owned by the controller after TurnDone.
// Reset local queue navigation when the snapshot changes.
m.resetQueueNavigation()
// A /reload typed while the turn ran fires now that the TUI may be
// idle; the drain re-checks busy state (an interject above or a
// idle; the drain re-checks busy state (an inbox admission above or a
// background job keeps it queued).
if c := m.drainQueuedRuntimeReload(); c != nil {
cmds = append(cmds, c)
@@ -3284,12 +3410,15 @@ func (m chatTUI) runningWorkingLine(cancelRequested, styled bool) string {
if m.turnTokens > 0 {
working += " · ↓" + shortTokens(m.turnTokens)
}
if n := len(m.pendingInterject); n > 0 {
if n := m.inboxQueuedCount(); n > 0 {
var queued string
if n == 1 {
queued = " · ✎ feedback queued"
queued = " · ✎ 1 in inbox"
} else {
queued = fmt.Sprintf(" · ✎ %d queued", n)
queued = fmt.Sprintf(" · ✎ %d in inbox", n)
}
if m.inboxSnap().Paused {
queued += " (paused)"
}
if styled {
working += dim(queued)
+15
View File
@@ -0,0 +1,15 @@
package cli
// toggleInboxPaused handles the queue-only space shortcut outside the main
// Update state machine so inbox UX does not add another branch to that already
// large input dispatcher.
func (m *chatTUI) toggleInboxPaused() {
snap := m.inboxSnap()
paused := !snap.Paused
_ = m.ctrl.SetInboxPaused(paused)
if paused {
m.notice("inbox paused")
return
}
m.notice("inbox resumed")
}
@@ -0,0 +1,29 @@
package cli
import (
"path/filepath"
"testing"
tea "charm.land/bubbletea/v2"
"reasonix/internal/control"
"reasonix/internal/event"
)
func TestManualNewlineDuringRunningTurnDoesNotSteerOrClearDraft(t *testing.T) {
dir := t.TempDir()
ctrl := control.New(control.Options{SessionPath: filepath.Join(dir, "session.jsonl"), SessionDir: dir})
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40)
m.state = tuiRunning
m.input.SetValue("first line")
m0, _ := m.Update(tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl})
m = m0.(chatTUI)
if got := m.input.Value(); got != "first line\n" {
t.Fatalf("input after running Ctrl+J = %q, want newline-preserved draft", got)
}
if items := ctrl.InboxSnapshot().Items; len(items) != 0 {
t.Fatalf("running Ctrl+J unexpectedly enqueued guidance: %+v", items)
}
}
+64 -42
View File
@@ -352,17 +352,21 @@ func TestStatusLineWrapAccounting(t *testing.T) {
m.state = tuiRunning
m.elapsed = 5
m.turnTokens = 100
// Push an interject so the working line is longer.
m.pendingInterject = []string{"feedback"}
m.statusLineCount = m.computeStatusLineCount(m.width)
runCount := m.statusLineCount
// Push a durable inbox item so the working line is longer.
m2 := newInboxTestChatTUI(t)
m2.state = tuiRunning
m2.elapsed = 5
m2.turnTokens = 100
m2.seedInbox("feedback")
m2.width = m.width
m2.statusLineCount = m2.computeStatusLineCount(m2.width)
runCount := m2.statusLineCount
if runCount <= idleCount {
t.Fatalf("statusLineCount when running (%d) should be > idle (%d)", runCount, idleCount)
}
// Reset and test that a custom statusline command is also counted.
m.state = tuiIdle
m.pendingInterject = nil
m.statuslineCmd = "custom"
m.statuslineOut = "model: claude-3 · ctx: 45% · tokens: 128K · cache: 87% · rate: 1.2s · jobs: 3 running · balance: ¥152.30"
m0, _ = m.Update(tea.WindowSizeMsg{Width: 35, Height: 12})
@@ -2676,9 +2680,9 @@ func TestSubmittedInputRecallWithArrowKeys(t *testing.T) {
}
func TestQueueNavigationWithArrowKeys(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"queued one", "queued two", "queued three"}
m.seedInbox("queued one", "queued two", "queued three")
m.input.SetValue("my draft")
up := tea.KeyPressMsg{Code: tea.KeyUp}
@@ -2720,9 +2724,9 @@ func TestQueueNavigationWithArrowKeys(t *testing.T) {
}
func TestQueueNavigationClampAtStart(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"only item"}
m.seedInbox("only item")
m.input.SetValue("draft")
up := tea.KeyPressMsg{Code: tea.KeyUp}
@@ -2744,7 +2748,7 @@ func TestQueueNavigationClampAtStart(t *testing.T) {
}
func TestQueueNavigationNoOpWhenEmpty(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.input.SetValue("hello")
@@ -2757,9 +2761,9 @@ func TestQueueNavigationNoOpWhenEmpty(t *testing.T) {
}
func TestQueueEditSavesOnEnter(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"original one", "original two"}
m.seedInbox("original one", "original two")
up := tea.KeyPressMsg{Code: tea.KeyUp}
model, _ := m.Update(up)
@@ -2774,11 +2778,12 @@ func TestQueueEditSavesOnEnter(t *testing.T) {
model, _ = m.Update(enter)
m = model.(chatTUI)
if m.pendingInterject[1] != "edited two" {
t.Fatalf("queue[1] should be %q, got %q", "edited two", m.pendingInterject[1])
bodies := m.inboxBodies()
if bodies[1] != "edited two" {
t.Fatalf("queue[1] should be %q, got %q", "edited two", bodies[1])
}
if m.pendingInterject[0] != "original one" {
t.Fatalf("queue[0] should be unchanged, got %q", m.pendingInterject[0])
if bodies[0] != "original one" {
t.Fatalf("queue[0] should be unchanged, got %q", bodies[0])
}
if m.queueEditCursor != -1 {
t.Fatalf("cursor should reset after enter, got %d", m.queueEditCursor)
@@ -2786,32 +2791,35 @@ func TestQueueEditSavesOnEnter(t *testing.T) {
}
func TestQueueNewMessageOnEnterDuringRunning(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"existing"}
m.seedInbox("existing")
m.input.SetValue("new message")
enter := tea.KeyPressMsg{Code: tea.KeyEnter}
model, _ := m.Update(enter)
m = model.(chatTUI)
if len(m.pendingInterject) != 2 {
t.Fatalf("queue should have 2 items, got %d", len(m.pendingInterject))
bodies := m.inboxBodies()
if len(bodies) != 2 {
t.Fatalf("queue should have 2 items, got %d", len(bodies))
}
if m.pendingInterject[1] != "new message" {
t.Fatalf("queue[1] should be %q, got %q", "new message", m.pendingInterject[1])
if bodies[1] != "new message" {
t.Fatalf("queue[1] should be %q, got %q", "new message", bodies[1])
}
}
func TestQueuedFoldedPasteExpandsBeforeInterjectSend(t *testing.T) {
runner := &recordingTurnRunner{}
events := make(chan event.Event, 8)
dir := t.TempDir()
ctrl := control.New(control.Options{
Runner: runner,
Sink: event.FuncSink(func(e event.Event) { events <- e }),
SessionDir: t.TempDir(),
SessionDir: dir,
Label: "test",
})
ctrl.EnsureSessionPath()
m := newTestChatTUI()
m.ctrl = ctrl
m.eventCh = make(chan event.Event, 8)
@@ -2829,10 +2837,11 @@ func TestQueuedFoldedPasteExpandsBeforeInterjectSend(t *testing.T) {
model, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
m = model.(chatTUI)
if len(m.pendingInterject) != 1 {
t.Fatalf("queue should have 1 item, got %d", len(m.pendingInterject))
bodies := m.inboxBodies()
if len(bodies) != 1 {
t.Fatalf("queue should have 1 item, got %d", len(bodies))
}
queued := m.pendingInterject[0]
queued := bodies[0]
if queued == display {
t.Fatalf("queued interject kept the folded placeholder: %q", queued)
}
@@ -2846,10 +2855,18 @@ func TestQueuedFoldedPasteExpandsBeforeInterjectSend(t *testing.T) {
}
}
// Resume inbox so controller can dispatch after TurnDone.
_ = m.ctrl.SetInboxPaused(false)
model, _ = m.Update(agentEventMsg(event.Event{Kind: event.TurnDone}))
m = model.(chatTUI)
// Controller dispatches asynchronously via maybeDispatch; wait briefly.
waitForCLIEvent(t, events, event.TurnDone)
// Admission may start a turn; wait for runner input.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && len(runner.inputs) == 0 {
time.Sleep(10 * time.Millisecond)
}
if len(runner.inputs) != 1 {
t.Fatalf("runner should receive queued interject, inputs=%q", runner.inputs)
}
@@ -2863,9 +2880,9 @@ func TestQueuedFoldedPasteExpandsBeforeInterjectSend(t *testing.T) {
}
func TestQueueNavigationResetOnNonUpDownKey(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"queued"}
m.seedInbox("queued")
up := tea.KeyPressMsg{Code: tea.KeyUp}
model, _ := m.Update(up)
@@ -2885,9 +2902,9 @@ func TestQueueNavigationResetOnNonUpDownKey(t *testing.T) {
}
func TestQueueEditTypingDoesNotResetCursor(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"first", "second"}
m.seedInbox("first", "second")
// Navigate up to select the last item.
up := tea.KeyPressMsg{Code: tea.KeyUp}
@@ -2909,9 +2926,9 @@ func TestQueueEditTypingDoesNotResetCursor(t *testing.T) {
}
func TestQueueEditReplaceOnEnter(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"hello"}
m.seedInbox("hello")
// Navigate up to select the item.
up := tea.KeyPressMsg{Code: tea.KeyUp}
@@ -2928,11 +2945,12 @@ func TestQueueEditReplaceOnEnter(t *testing.T) {
model, _ = m.Update(enter)
m = model.(chatTUI)
if len(m.pendingInterject) != 1 {
t.Fatalf("queue should still have 1 item, got %d", len(m.pendingInterject))
bodies := m.inboxBodies()
if len(bodies) != 1 {
t.Fatalf("queue should still have 1 item, got %d", len(bodies))
}
if m.pendingInterject[0] != "world" {
t.Fatalf("queue[0] should be %q, got %q", "world", m.pendingInterject[0])
if bodies[0] != "world" {
t.Fatalf("queue[0] should be %q, got %q", "world", bodies[0])
}
if m.queueEditCursor != -1 {
t.Fatalf("cursor should reset after enter, got %d", m.queueEditCursor)
@@ -2940,9 +2958,9 @@ func TestQueueEditReplaceOnEnter(t *testing.T) {
}
func TestQueueIndicatorRendering(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.pendingInterject = []string{"first msg", "second msg"}
m.seedInbox("first msg", "second msg")
qi := m.renderQueueIndicator()
if qi == "" {
@@ -2964,12 +2982,16 @@ func TestQueueIndicatorRendering(t *testing.T) {
}
func TestQueueIndicatorHiddenWhenIdle(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiIdle
m.pendingInterject = []string{"queued"}
// Idle sessions with a recovered/paused inbox still show the shelf so the
// user can inspect it; empty inboxes stay hidden.
if qi := m.renderQueueIndicator(); qi != "" {
t.Fatalf("queue indicator should be empty when idle, got %q", qi)
t.Fatalf("queue indicator should be empty when inbox empty, got %q", qi)
}
m.seedInbox("queued")
if qi := m.renderQueueIndicator(); qi == "" {
t.Fatal("queue indicator should show durable items even when idle")
}
}
+305
View File
@@ -0,0 +1,305 @@
package cli
import (
"fmt"
"strings"
"reasonix/internal/control"
"reasonix/internal/sessioninbox"
)
// inboxPreview is a bounded UI row for the composer shelf (never full body).
type inboxPreview struct {
ID string
Preview string
State sessioninbox.InboxState
Intent sessioninbox.InboxIntent
Pos int
}
func (m *chatTUI) inboxSnap() sessioninbox.InboxSnapshot {
if m == nil || m.ctrl == nil {
return sessioninbox.InboxSnapshot{}
}
return m.ctrl.InboxSnapshot()
}
func (m *chatTUI) inboxPreviews() []inboxPreview {
snap := m.inboxSnap()
out := make([]inboxPreview, 0, len(snap.Items))
for i, it := range snap.Items {
out = append(out, inboxPreview{
ID: it.ID,
Preview: it.Preview,
State: it.State,
Intent: it.Intent,
Pos: i + 1,
})
}
return out
}
func (m *chatTUI) inboxQueuedCount() int {
return len(m.inboxSnap().Items)
}
// enqueueFollowup persists a follow-up. Only clears the composer on success.
func (m *chatTUI) enqueueFollowup(display, submit string) (sessioninbox.InboxReceipt, error) {
if m.ctrl == nil {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrClosed
}
if ensurer, ok := m.ctrl.(interface{ EnsureSessionPath() }); ok {
ensurer.EnsureSessionPath()
}
return m.ctrl.EnqueueInbox(control.InboxRequest{
Intent: sessioninbox.IntentFollowup,
Display: display,
Raw: submit,
Submit: submit,
Source: "cli",
})
}
// enqueueSteer persists then attempts mid-turn steer.
func (m *chatTUI) enqueueSteer(display, submit string) (sessioninbox.InboxReceipt, error) {
if m.ctrl == nil {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrClosed
}
if ensurer, ok := m.ctrl.(interface{ EnsureSessionPath() }); ok {
ensurer.EnsureSessionPath()
}
return m.ctrl.TryEnqueueAndSteer(control.InboxRequest{
Intent: sessioninbox.IntentSteer,
Display: display,
Raw: submit,
Submit: submit,
Source: "cli",
})
}
// seedInbox is a test helper to push durable queue rows.
func (m *chatTUI) seedInbox(texts ...string) {
for _, text := range texts {
_, _ = m.enqueueFollowup(text, text)
}
}
// inboxBodies returns full submit texts in queue order (tests only).
func (m *chatTUI) inboxBodies() []string {
snap := m.inboxSnap()
out := make([]string, 0, len(snap.Items))
for _, it := range snap.Items {
_, env, err := m.ctrl.ReadInboxItem(it.ID)
if err != nil {
out = append(out, it.Preview)
continue
}
out = append(out, env.SubmitText)
}
return out
}
// handleQueueSlash runs /queue and /steer as local commands even while running.
func (m *chatTUI) handleQueueSlash(line string) (handled bool, notice string) {
fields := strings.Fields(line)
if len(fields) == 0 {
return false, ""
}
cmd := strings.ToLower(fields[0])
args := fields[1:]
switch cmd {
case "/steer":
text := strings.TrimSpace(strings.TrimPrefix(line, fields[0]))
if text == "" {
return true, "usage: /steer <guidance>"
}
body := m.expandPastedBlocks(text)
rec, err := m.enqueueSteer(body, body)
if err != nil {
return true, "steer: " + err.Error()
}
switch rec.Disposition {
case sessioninbox.DispositionSteerAccepted:
return true, fmt.Sprintf("steer accepted #%s", shortID(rec.ItemID))
case sessioninbox.DispositionQueuedFollowup:
return true, fmt.Sprintf("steer rejected — queued as follow-up #%s", shortID(rec.ItemID))
default:
return true, fmt.Sprintf("queued #%s (%s)", shortID(rec.ItemID), rec.Disposition)
}
case "/queue":
return true, m.runQueueCommand(args)
default:
return false, ""
}
}
func (m *chatTUI) runQueueCommand(args []string) string {
if len(args) == 0 {
args = []string{"list"}
}
sub := strings.ToLower(args[0])
rest := args[1:]
switch sub {
case "list", "ls", "status":
return m.renderQueueList()
case "show":
id, err := m.resolveQueueRef(rest)
if err != nil {
return err.Error()
}
_, env, err := m.ctrl.ReadInboxItem(id)
if err != nil {
return "show: " + err.Error()
}
return env.SubmitText
case "edit":
return m.editQueueItem(rest)
case "delete", "rm", "del":
id, err := m.resolveQueueRef(rest)
if err != nil {
return err.Error()
}
if err := m.ctrl.DeleteInboxItem(id); err != nil {
return "delete: " + err.Error()
}
return "deleted #" + shortID(id)
case "move":
return m.moveQueueItem(rest)
case "pause":
if err := m.ctrl.SetInboxPaused(true); err != nil {
return "pause: " + err.Error()
}
return "inbox paused"
case "resume":
if err := m.ctrl.SetInboxPaused(false); err != nil {
return "resume: " + err.Error()
}
return "inbox resumed"
case "retry":
id, err := m.resolveQueueRef(rest)
if err != nil {
return err.Error()
}
if err := m.ctrl.RetryInboxItem(id); err != nil {
return "retry: " + err.Error()
}
return "retry queued #" + shortID(id)
case "refresh":
id, err := m.resolveQueueRef(rest)
if err != nil {
return err.Error()
}
if err := m.ctrl.RefreshInboxReferences(id); err != nil {
return "refresh: " + err.Error()
}
return "refs refreshed #" + shortID(id)
default:
return "usage: /queue list|show|edit|delete|move|pause|resume|retry|refresh"
}
}
func (m *chatTUI) renderQueueList() string {
snap := m.inboxSnap()
if len(snap.Items) == 0 {
status := "inbox empty"
if snap.Paused {
status += " (paused)"
}
return status
}
var b strings.Builder
fmt.Fprintf(&b, "inbox rev=%d items=%d", snap.Revision, len(snap.Items))
if snap.Paused {
b.WriteString(" paused")
}
if snap.Recovered {
fmt.Fprintf(&b, " recovered=%d", snap.RecoveredN)
}
b.WriteByte('\n')
limit := min(len(snap.Items), 20)
for i := range limit {
it := snap.Items[i]
fmt.Fprintf(&b, " %d. [%s/%s] %s #%s\n", i+1, it.Intent, it.State, it.Preview, shortID(it.ID))
}
if len(snap.Items) > limit {
fmt.Fprintf(&b, " … and %d more (use /queue show <n>)\n", len(snap.Items)-limit)
}
return strings.TrimRight(b.String(), "\n")
}
func (m *chatTUI) editQueueItem(args []string) string {
if len(args) < 2 {
return "usage: /queue edit <n|id> <text>"
}
id, err := m.resolveQueueRef(args[:1])
if err != nil {
return err.Error()
}
text := strings.Join(args[1:], " ")
if _, err := m.ctrl.UpdateInboxItem(id, text, text, text); err != nil {
return "edit: " + err.Error()
}
return "updated #" + shortID(id)
}
func (m *chatTUI) moveQueueItem(args []string) string {
if len(args) < 2 {
return "usage: /queue move <n|id> <to-index>"
}
id, err := m.resolveQueueRef(args[:1])
if err != nil {
return err.Error()
}
var to int
if _, err := fmt.Sscanf(args[1], "%d", &to); err != nil {
return "move: bad index"
}
if err := m.ctrl.MoveInboxItem(id, to-1); err != nil {
return "move: " + err.Error()
}
return "moved #" + shortID(id)
}
func (m *chatTUI) resolveQueueRef(args []string) (string, error) {
if len(args) == 0 {
return "", fmt.Errorf("missing item ref (index or id)")
}
ref := args[0]
snap := m.inboxSnap()
// Numeric 1-based index.
var n int
if _, err := fmt.Sscanf(ref, "%d", &n); err == nil && n >= 1 && n <= len(snap.Items) {
return snap.Items[n-1].ID, nil
}
// Full or short id prefix.
for _, it := range snap.Items {
if it.ID == ref || strings.HasPrefix(it.ID, ref) {
return it.ID, nil
}
}
return "", fmt.Errorf("unknown inbox item %q", ref)
}
func shortID(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}
// handleQueueReorder moves the selected item by delta (-1 up, +1 down).
func (m *chatTUI) handleQueueReorder(delta int) bool {
if m.queueEditCursor < 0 || m.inboxSelectedID == "" || m.ctrl == nil {
return false
}
to := m.queueEditCursor + delta
if to < 0 {
return false
}
if err := m.ctrl.MoveInboxItem(m.inboxSelectedID, to); err != nil {
m.notice("move: " + err.Error())
return true
}
m.queueEditCursor = to
return true
}
+25 -9
View File
@@ -10,7 +10,7 @@ import (
)
func TestInterjectQueuesWhileRunningWithoutOverwrite(t *testing.T) {
m := newTestChatTUI()
m := newInboxTestChatTUI(t)
m.state = tuiRunning
m.input.SetValue("first")
@@ -25,26 +25,42 @@ func TestInterjectQueuesWhileRunningWithoutOverwrite(t *testing.T) {
m0, _ = m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
m = m0.(chatTUI)
if want := []string{"first", "second"}; len(m.pendingInterject) != len(want) ||
m.pendingInterject[0] != want[0] || m.pendingInterject[1] != want[1] {
t.Fatalf("pendingInterject = %v, want %v (second must not overwrite first; empty must not queue)", m.pendingInterject, want)
got := m.inboxBodies()
if want := []string{"first", "second"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("inbox = %v, want %v (second must not overwrite first; empty must not queue)", got, want)
}
if m.state != tuiRunning {
t.Fatalf("queuing input must not change state; got %v", m.state)
}
}
func TestInterjectDequeuesFrontOnTurnDone(t *testing.T) {
func TestInterjectLeavesQueueOnTurnDoneForControllerDispatch(t *testing.T) {
r := &blockingTurnRunner{started: make(chan struct{})}
ctrl := control.New(control.Options{Runner: r, Sink: event.Discard, SessionDir: t.TempDir(), Label: "test"})
dir := t.TempDir()
ctrl := control.New(control.Options{Runner: r, Sink: event.Discard, SessionDir: dir, Label: "test"})
ctrl.EnsureSessionPath()
m := newChatTUI(ctrl, "", make(chan event.Event, 8), 80)
m.state = tuiRunning
m.pendingInterject = []string{"first", "second"}
m.seedInbox("first", "second")
// CLI no longer dequeues on TurnDone; durable items stay until the controller
// admits and acks them. Pause so dispatch does not immediately consume.
_ = m.ctrl.SetInboxPaused(true)
m0, _ := m.update(agentEventMsg(event.Event{Kind: event.TurnDone}))
m = m0.(chatTUI)
if len(m.pendingInterject) != 1 || m.pendingInterject[0] != "second" {
t.Fatalf("TurnDone should dequeue only the front; got %v", m.pendingInterject)
got := m.inboxBodies()
if len(got) != 2 || got[0] != "first" || got[1] != "second" {
t.Fatalf("TurnDone must not drop durable inbox items; got %v", got)
}
}
func newInboxTestChatTUI(t *testing.T) chatTUI {
t.Helper()
dir := t.TempDir()
ctrl := control.New(control.Options{SessionDir: dir, Label: "test", Sink: event.Discard})
ctrl.EnsureSessionPath()
m := newTestChatTUI()
m.ctrl = ctrl
return m
}
+16 -3
View File
@@ -22,7 +22,7 @@ const (
// runGuarded runs body under a fresh context, guarding concurrent turns.
// Finishing-window arrivals park instead of dropping (see admissionResult).
func (c *Controller) runGuarded(body func(ctx context.Context) error) admissionResult {
return c.admitGuardedTurn(body, false)
return c.admitGuardedTurn(body, false, true, nil)
}
// runGuardedOrPark admits like runGuarded but parks the body while another
@@ -31,10 +31,16 @@ func (c *Controller) runGuarded(body func(ctx context.Context) error) admissionR
// the FIFO drain in finishGuardedTurn delivers them the moment the current
// turn finishes.
func (c *Controller) runGuardedOrPark(body func(ctx context.Context) error) admissionResult {
return c.admitGuardedTurn(body, true)
return c.admitGuardedTurn(body, true, true, nil)
}
func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, parkWhileRunning bool) admissionResult {
// runGuardedInbox admits a durable item without parking it in volatile memory.
// onStart runs after admission is reserved and before its goroutine can finish.
func (c *Controller) runGuardedInbox(body func(ctx context.Context) error, onStart func()) admissionResult {
return c.admitGuardedTurn(body, false, false, onStart)
}
func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, parkWhileRunning, parkWhileFinishing bool, onStart func()) admissionResult {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
@@ -60,6 +66,10 @@ func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, park
return turnDroppedRunning
}
if c.finishing {
if !parkWhileFinishing {
c.mu.Unlock()
return turnDroppedRunning
}
c.parkedTurns = append(c.parkedTurns, body)
c.mu.Unlock()
return turnParked
@@ -69,6 +79,9 @@ func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, park
c.running = true
c.canceling = false
c.mu.Unlock()
if onStart != nil {
onStart()
}
c.spawnGuardedTurn(ctx, cancel, body)
return turnStarted
}
+118 -64
View File
@@ -52,6 +52,7 @@ import (
"reasonix/internal/provider"
"reasonix/internal/recovery"
"reasonix/internal/sandbox"
"reasonix/internal/sessioninbox"
"reasonix/internal/sessiontemp"
"reasonix/internal/shellrun"
"reasonix/internal/skill"
@@ -303,6 +304,10 @@ type Controller struct {
turn int
displayRecorder func(content, display string)
// inbox is the durable session-level instruction queue. Disk I/O never
// runs under c.mu; the store owns its own lock.
inbox inboxState
}
type approvalReply struct {
@@ -637,6 +642,14 @@ func New(opts Options) *Controller {
// Checkpoints: bind a store to the session and route writer pre-edits into it.
c.rebindCheckpoints(opts.SessionPath)
c.setActiveJobSession(opts.SessionPath)
c.rebindInbox()
// Observe Steer / unapplied-steer for durable inbox state transitions.
// Must wrap both the controller sink and the executor sink: agent.Steer
// emits on the executor path, TurnDone on the controller path.
c.sink = &inboxEventSink{inner: c.sink, c: c}
if c.executor != nil {
c.executor.SetSink(c.sink)
}
cmdsInit := opts.Commands
c.commands.Store(&cmdsInit)
if c.executor != nil {
@@ -700,13 +713,26 @@ func (c *Controller) ReplaceExtensions(d *dispatch.Dispatcher) {
func (c *Controller) installExtensionsLocked(d *dispatch.Dispatcher) {
c.extensions = d
if existing, ok := c.sink.(*frontendEventSink); ok {
existing.setDispatcher(d)
} else {
c.sink = newFrontendEventSink(c.sink, d)
// Keep the inbox observer as the outermost sink so Steer/unapplied events
// always update durable state, while still installing/updating the
// frontendEventSink wrapper underneath for extension rulings.
switch sink := c.sink.(type) {
case *inboxEventSink:
if existing, ok := sink.inner.(*frontendEventSink); ok {
existing.setDispatcher(d)
} else {
sink.inner = newFrontendEventSink(sink.inner, d)
}
case *frontendEventSink:
sink.setDispatcher(d)
// Ensure inbox observer stays outer.
c.sink = &inboxEventSink{inner: sink, c: c}
default:
c.sink = &inboxEventSink{inner: newFrontendEventSink(c.sink, d), c: c}
}
if c.executor != nil {
c.executor.SetExtensions(d)
c.executor.SetSink(c.sink)
}
}
@@ -913,13 +939,16 @@ func (c *Controller) finishGuardedTurn(err error, completion *guardedTurnComplet
defer func() {
c.mu.Lock()
c.finishing = false
if c.closed || len(c.parkedTurns) == 0 {
// A closed controller must not start a parked turn against freed
// resources; close() also cleared the queue, this guards the
// close-raced-with-delivery ordering.
if c.closed {
c.mu.Unlock()
return
}
if len(c.parkedTurns) == 0 {
c.mu.Unlock()
// No parked compatibility body: admit the next durable inbox item.
c.maybeDispatchInbox()
return
}
next := c.parkedTurns[0]
c.parkedTurns = c.parkedTurns[1:]
ctx, cancel := context.WithCancel(extension.ContextWithRuntimeOwner(context.Background(), c.runtimeOwner))
@@ -929,11 +958,32 @@ func (c *Controller) finishGuardedTurn(err error, completion *guardedTurnComplet
c.mu.Unlock()
c.spawnGuardedTurn(ctx, cancel, next)
}()
done := event.Event{Kind: event.TurnDone, Err: err, Cancelled: cancelRequested, Outcome: turnOutcome(err), CheckpointTurn: c.validatedCheckpointTurn(completion), Receipt: c.executor.CompletionReceipt()}
c.inbox.mu.Lock()
// Prefer a single representative id for the wire event (first active).
// Full multi-item ack happens in onInboxTurnDone via activeItemIDs.
activeInboxID := ""
for id := range c.inbox.activeItemIDs {
activeInboxID = id
break
}
c.inbox.mu.Unlock()
done := event.Event{
Kind: event.TurnDone,
Err: err,
Cancelled: cancelRequested,
Outcome: turnOutcome(err),
CheckpointTurn: c.validatedCheckpointTurn(completion),
Receipt: c.executor.CompletionReceipt(),
ItemID: activeInboxID,
}
var readinessErr *agent.FinalReadinessError
if errors.As(err, &readinessErr) {
done.Readiness = &event.FinalReadiness{Attempts: readinessErr.Attempts, Missing: append([]string(nil), readinessErr.Missing...)}
}
// Ack active durable items before exposing TurnDone. Frontends commonly
// refresh the inbox from that event and must not observe already-consumed
// steers in the completed turn. Dispatch still waits for finishing to clear.
c.onInboxTurnDone()
c.sink.Emit(done)
}
@@ -1010,40 +1060,9 @@ func (c *Controller) runTurn(ctx context.Context, input string) error {
// composition, checkpoints, hooks, and plan approval. It is for transports that
// need a blocking request/response boundary, such as ACP session/prompt.
func (c *Controller) RunTurn(ctx context.Context, input string) error {
ctx, cancel := context.WithCancel(extension.ContextWithRuntimeOwner(ctx, c.RuntimeOwner()))
c.mu.Lock()
// finishing is part of the gate: TurnDone delivery for the previous turn
// is still fanning out, and starting a synchronous turn inside that
// window recreates the completion/transport crosstalk the window exists
// to prevent (Running() already reports true here). closed seals a torn-
// down controller. Synchronous callers get an error rather than parking:
// they hold a request/response boundary open and already handle busy.
if c.running || c.finishing || c.rotating || c.closed {
c.mu.Unlock()
cancel()
return ErrTurnRunning
}
if c.rejectDrainingGenerationLocked() {
c.mu.Unlock()
cancel()
c.emitDrainingNotice()
return ErrRuntimeDraining
}
c.cancel = cancel
c.running = true
c.canceling = false
c.mu.Unlock()
defer event.RecordTurnCompletion(c.sink)
defer func() {
c.mu.Lock()
c.running = false
c.cancel = nil
c.canceling = false
c.mu.Unlock()
cancel()
}()
return c.runTurn(ctx, input)
return c.runSynchronousTurn(ctx, nil, func(runCtx context.Context) error {
return c.runTurn(runCtx, input)
})
}
func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) error {
@@ -1205,6 +1224,22 @@ func (c *Controller) submitInvocations(input, display string, requests []Invocat
c.SubmitDisplay(display, input)
return
}
prepared, err := c.prepareInvocationTurn(input, requests)
if err != nil {
c.notice(err.Error())
return
}
c.runGuarded(func(ctx context.Context) error {
return c.runPreparedInvocationTurn(ctx, prepared, input, input, display, nil)
})
}
type preparedInvocationTurn struct {
composed string
subagents []skill.Skill
}
func (c *Controller) prepareInvocationTurn(input string, requests []InvocationRequest) (preparedInvocationTurn, error) {
ordered := append([]InvocationRequest(nil), requests...)
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Offset < ordered[j].Offset })
inline := make([]skill.Skill, 0, len(ordered))
@@ -1212,16 +1247,14 @@ func (c *Controller) submitInvocations(input, display string, requests []Invocat
for _, request := range ordered {
sk, _, ok := c.resolveSkillInvocation("/" + strings.TrimSpace(request.Name))
if !ok {
c.notice("unknown invocation: /" + strings.TrimSpace(request.Name))
return
return preparedInvocationTurn{}, fmt.Errorf("unknown invocation: /%s", strings.TrimSpace(request.Name))
}
kind := "skill"
if sk.RunAs == skill.RunSubagent {
kind = "subagent"
}
if request.Kind != kind {
c.notice(fmt.Sprintf("invocation /%s is %s, not %s", sk.SlashName(), kind, request.Kind))
return
if strings.TrimSpace(request.Kind) != "" && request.Kind != kind {
return preparedInvocationTurn{}, fmt.Errorf("invocation /%s is %s, not %s", sk.SlashName(), kind, request.Kind)
}
if sk.RunAs == skill.RunSubagent {
subagents = append(subagents, sk)
@@ -1238,24 +1271,36 @@ func (c *Controller) submitInvocations(input, display string, requests []Invocat
parts = append(parts, input)
}
composed := strings.Join(parts, "\n\n")
if len(subagents) == 0 {
c.runGuarded(func(ctx context.Context) error {
return c.runGoalLoopWithRawDisplay(ctx, composed, input, display)
})
return
}
if strings.TrimSpace(input) == "" {
c.notice("subagent invocation requires a task")
return
}
c.runGuarded(func(ctx context.Context) error {
planMode := c.PlanMode()
runner := c.skillRunner
if runner == nil {
return fmt.Errorf("subagent skill runner is unavailable")
if len(subagents) > 0 {
return preparedInvocationTurn{}, fmt.Errorf("subagent invocation requires a task")
}
return newTurnOrchestrator(c).runSubagentSkillTurnsGoalLoop(ctx, subagents, composed, input, display, runner, planMode)
})
}
return preparedInvocationTurn{composed: composed, subagents: subagents}, nil
}
func (c *Controller) runPreparedInvocationTurn(
ctx context.Context,
prepared preparedInvocationTurn,
input, raw, display string,
frozenImages []string,
) error {
if len(prepared.subagents) == 0 {
return c.runGoalLoopWithFrozenImagesRawDisplay(ctx, prepared.composed, raw, display, frozenImages)
}
runner := c.skillRunner
if runner == nil {
return fmt.Errorf("subagent skill runner is unavailable")
}
return newTurnOrchestrator(c).runSubagentSkillTurnsGoalLoop(
ctx,
prepared.subagents,
prepared.composed,
input,
display,
runner,
c.PlanMode(),
)
}
// SubmitEditedDisplay is SubmitDisplay for an inline-edited prompt. The model
@@ -2864,6 +2909,9 @@ func (c *Controller) NewSession() error {
c.resetRecoveryForNewSession(freshPath)
c.rotateSessionTemp()
c.snapshotMu.Unlock()
// Old session keeps its inbox (paused); the fresh session starts empty.
c.pauseInboxOnRotate()
c.rebindInbox()
// A new session starts with no active goal: without this, a running goal's
// text kept injecting into the fresh session's first turns. The old
// session's goal-state sidecar was persisted before the rotation and stays
@@ -2949,6 +2997,7 @@ func (c *Controller) ClearSession() error {
c.resetRecoveryForNewSession(freshPath)
c.rotateSessionTemp()
c.snapshotMu.Unlock()
c.rebindInbox()
// Same contract as NewSession: the fresh session starts with no active goal.
c.ClearGoal()
c.mu.Lock()
@@ -3005,6 +3054,9 @@ func removeSessionArtifacts(path string) error {
return err
}
}
if err := sessioninbox.RemoveDir(path); err != nil && !os.IsNotExist(err) {
return err
}
if dir := ckptDir(path); dir != "" {
if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
return err
@@ -3445,6 +3497,7 @@ func (c *Controller) Resume(s *agent.Session, path string) {
c.rotateSessionTemp()
}
c.snapshotMu.Unlock()
c.rebindInbox()
c.recoverCheckpointTransactions()
c.recoverInterruptedTurn(path)
c.maybeColdResumePrune(path)
@@ -4611,6 +4664,7 @@ func (c *Controller) setSessionPath(p string, fresh bool) {
c.loadRecoveryState(p)
}
c.snapshotMu.Unlock()
c.rebindInbox()
if !fresh {
c.recoverCheckpointTransactions()
}
+15 -2
View File
@@ -204,7 +204,7 @@ func TestInputReceiveBlock(t *testing.T) {
func TestInputReceiveNilDispatcherUntouched(t *testing.T) {
runner := &fakeTurnRunner{}
c := New(Options{Runner: runner})
if _, wrapped := c.sink.(*frontendEventSink); wrapped {
if sinkHasFrontendWrapper(c.sink) {
t.Fatal("sink wrapped without a dispatcher — the nil fast path must stay unwrapped")
}
if err := runTestTurn(c, "plain"); err != nil {
@@ -247,7 +247,8 @@ func TestSetExtensionsInstallsDispatcher(t *testing.T) {
t.Fatal("SetExtensions(nil) wrapped the sink")
}
c.SetExtensions(d)
if _, wrapped := c.sink.(*frontendEventSink); !wrapped {
// Durable inbox observation sits outside the frontend wrapper.
if !sinkHasFrontendWrapper(c.sink) {
t.Fatal("SetExtensions did not wrap the sink")
}
// The first install wins; a later SetExtensions is ignored.
@@ -546,3 +547,15 @@ func controlSystemMessage(msgs []provider.Message) string {
}
return ""
}
func sinkHasFrontendWrapper(s event.Sink) bool {
switch t := s.(type) {
case *frontendEventSink:
return true
case *inboxEventSink:
_, ok := t.inner.(*frontendEventSink)
return ok
default:
return false
}
}
+795
View File
@@ -0,0 +1,795 @@
package control
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"strings"
"sync"
"reasonix/internal/event"
"reasonix/internal/sessioninbox"
)
// TurnAdmission is the exported classification of TrySubmitInboxItem /
// TrySteerInboxItem results.
type TurnAdmission string
const (
AdmissionStarted TurnAdmission = "started"
AdmissionSteerAccepted TurnAdmission = "steer_accepted"
AdmissionQueuedFollowup TurnAdmission = "queued_followup"
AdmissionRejectedBusy TurnAdmission = "rejected_busy"
AdmissionRejectedRotating TurnAdmission = "rejected_rotating"
AdmissionRejectedClosed TurnAdmission = "rejected_closed"
AdmissionRejectedCapacity TurnAdmission = "rejected_capacity"
)
// InboxRequest is the frontend-facing enqueue payload.
type InboxRequest struct {
Intent sessioninbox.InboxIntent
Display string
Raw string
Submit string
Format string
Source string
Idempotency string
Invocations []InvocationRequest
Extra map[string]string
// FreezeRefs lists workspace-relative paths to freeze at enqueue time.
FreezeRefs []string
}
// Inbox port on SessionAPI.
type Inbox interface {
EnqueueInbox(req InboxRequest) (sessioninbox.InboxReceipt, error)
InboxSnapshot() sessioninbox.InboxSnapshot
ReadInboxItem(id string) (sessioninbox.InboxItemMeta, sessioninbox.PromptEnvelope, error)
UpdateInboxItem(id string, display, raw, submit string) (sessioninbox.InboxItemMeta, error)
AppendInboxItem(id, text, idempotency string, extra map[string]string) (sessioninbox.InboxItemMeta, error)
DeleteInboxItem(id string) error
CancelWithInboxItems(ids []string, source string) error
MoveInboxItem(id string, toIndex int) error
SetInboxPaused(paused bool) error
RetryInboxItem(id string) error
RefreshInboxReferences(id string) error
TrySubmitInboxItem(id string) (sessioninbox.InboxReceipt, error)
RunInboxTurn(ctx context.Context, id string) error
TrySteerInboxItem(id string) (sessioninbox.InboxReceipt, error)
TryEnqueueAndSteer(req InboxRequest) (sessioninbox.InboxReceipt, error)
TryEnqueueFollowup(req InboxRequest) (sessioninbox.InboxReceipt, error)
}
// Compile-time port satisfaction.
var _ Inbox = (*Controller)(nil)
// inboxState is controller-owned inbox wiring (disk store + active items).
type inboxState struct {
mu sync.Mutex
store *sessioninbox.Store
// activeItemIDs includes the running follow-up and every accepted steer.
// TurnDone durable-acks the set so multi-steer rounds leave no orphans.
activeItemIDs map[string]struct{}
dispatching bool
// beforePreparedAdmission is a deterministic test hook for the gap between
// durable preparation and Controller admission. Production leaves it nil.
beforePreparedAdmission func()
}
func (s *inboxState) trackActive(id string) {
if s == nil || id == "" {
return
}
if s.activeItemIDs == nil {
s.activeItemIDs = make(map[string]struct{})
}
s.activeItemIDs[id] = struct{}{}
}
func (s *inboxState) untrackActive(id string) {
if s == nil || s.activeItemIDs == nil || id == "" {
return
}
delete(s.activeItemIDs, id)
}
func (s *inboxState) takeActive() []string {
if s == nil || len(s.activeItemIDs) == 0 {
return nil
}
out := make([]string, 0, len(s.activeItemIDs))
for id := range s.activeItemIDs {
out = append(out, id)
}
s.activeItemIDs = nil
return out
}
func (s *inboxState) clearActive() {
if s == nil {
return
}
s.activeItemIDs = nil
}
func (c *Controller) ensureInbox() (*sessioninbox.Store, error) {
path := c.SessionPath()
if path == "" {
return nil, fmt.Errorf("inbox requires a persisted session path")
}
c.inbox.mu.Lock()
defer c.inbox.mu.Unlock()
if c.inbox.store != nil && c.inbox.store.SessionPath() == path {
return c.inbox.store, nil
}
if c.inbox.store != nil {
c.inbox.store.Close()
c.inbox.store = nil
}
st, err := sessioninbox.Open(path, sessioninbox.Limits{})
if err != nil {
return nil, err
}
c.inbox.store = st
snap := st.Snapshot()
if snap.Recovered && snap.RecoveredN > 0 {
c.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Code: "inbox_recovered",
Text: fmt.Sprintf("Recovered %d pending instruction(s). Inbox is paused — review with /queue before resuming.", snap.RecoveredN),
})
sessioninbox.NoteRecovered(snap.RecoveredN)
}
return st, nil
}
// rebindInbox opens the inbox for the current session path. Safe across
// NewSession/Resume/SetSessionPath; does not copy items on fork.
func (c *Controller) rebindInbox() {
path := c.SessionPath()
c.inbox.mu.Lock()
defer c.inbox.mu.Unlock()
if c.inbox.store != nil {
if path != "" && c.inbox.store.SessionPath() == path {
return
}
// Pause the old session's queue so it is not auto-run if reopened.
_ = c.inbox.store.SetPaused(true)
c.inbox.store.Close()
c.inbox.store = nil
c.inbox.clearActive()
}
if path == "" {
return
}
st, err := sessioninbox.Open(path, sessioninbox.Limits{})
if err != nil {
slog.Warn("controller: open session inbox", "err", err, "path", path)
return
}
c.inbox.store = st
snap := st.Snapshot()
if snap.Recovered && snap.RecoveredN > 0 {
// Emit after unlock via deferred sink call would race; emit here.
go func(n int) {
c.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Code: "inbox_recovered",
Text: fmt.Sprintf("Recovered %d pending instruction(s). Inbox is paused — review with /queue before resuming.", n),
})
}(snap.RecoveredN)
sessioninbox.NoteRecovered(snap.RecoveredN)
}
}
func (c *Controller) pauseInboxOnRotate() {
c.inbox.mu.Lock()
st := c.inbox.store
c.inbox.mu.Unlock()
if st != nil {
_ = st.SetPaused(true)
}
}
// EnqueueInbox durably queues an instruction. Only returns a receipt after
// blob+manifest commit. Does not auto-start a turn (call TrySubmit / dispatcher).
func (c *Controller) EnqueueInbox(req InboxRequest) (sessioninbox.InboxReceipt, error) {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxReceipt{}, err
}
submit := strings.TrimSpace(firstNonEmptyStr(req.Submit, req.Raw))
if submit == "" && len(req.Invocations) == 0 {
submit = strings.TrimSpace(req.Display)
}
if submit == "" && len(req.Invocations) == 0 {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrEmpty
}
display := firstNonEmptyStr(req.Display, submit)
raw := firstNonEmptyStr(req.Raw, submit)
env := sessioninbox.PromptEnvelope{
DisplayText: display,
RawText: raw,
SubmitText: submit,
Format: req.Format,
Source: req.Source,
Idempotency: req.Idempotency,
ExplicitRefs: append([]string(nil), req.FreezeRefs...),
Invocations: sessionInboxInvocations(req.Invocations),
Extra: maps.Clone(req.Extra),
}
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), submit, req.FreezeRefs)
intent := req.Intent
if intent != sessioninbox.IntentSteer {
intent = sessioninbox.IntentFollowup
}
rec, err := st.Enqueue(sessioninbox.EnqueueRequest{
Intent: intent,
Envelope: env,
Source: req.Source,
Idempotency: req.Idempotency,
SessionID: c.parentSessionID(),
})
if err != nil {
if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
sessioninbox.NoteCapacityReject()
} else {
sessioninbox.NoteTxFail()
}
return sessioninbox.InboxReceipt{}, err
}
if !rec.Idempotent && len(env.ReferenceErrors) > 0 {
reason := strings.Join(env.ReferenceErrors, "; ")
if stateErr := st.SetState(rec.ItemID, sessioninbox.StateBlocked, reason); stateErr != nil {
return sessioninbox.InboxReceipt{}, stateErr
}
if pauseErr := st.SetPaused(true); pauseErr != nil {
return sessioninbox.InboxReceipt{}, pauseErr
}
rec.Paused = true
}
sessioninbox.NoteEnqueue(int64(len(env.SubmitText)))
return rec, nil
}
func (c *Controller) InboxSnapshot() sessioninbox.InboxSnapshot {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxSnapshot{}
}
return st.Snapshot()
}
func (c *Controller) ReadInboxItem(id string) (sessioninbox.InboxItemMeta, sessioninbox.PromptEnvelope, error) {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxItemMeta{}, sessioninbox.PromptEnvelope{}, err
}
return st.ReadItem(id)
}
func (c *Controller) UpdateInboxItem(id, display, raw, submit string) (sessioninbox.InboxItemMeta, error) {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxItemMeta{}, err
}
submit = strings.TrimSpace(firstNonEmptyStr(submit, raw, display))
display = firstNonEmptyStr(display, submit)
raw = firstNonEmptyStr(raw, submit)
_, previous, err := st.ReadItem(id)
if err != nil {
return sessioninbox.InboxItemMeta{}, err
}
env := sessioninbox.PromptEnvelope{
DisplayText: display,
RawText: raw,
SubmitText: submit,
Format: previous.Format,
Source: previous.Source,
ExplicitRefs: append([]string(nil), previous.ExplicitRefs...),
Invocation: previous.Invocation,
Invocations: append([]sessioninbox.StructuredInvocation(nil), previous.Invocations...),
Attachments: append([]string(nil), previous.Attachments...),
Extra: maps.Clone(previous.Extra),
}
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), submit, env.ExplicitRefs)
updated, err := st.UpdateItem(id, env)
if err != nil {
return sessioninbox.InboxItemMeta{}, err
}
if len(env.ReferenceErrors) > 0 {
reason := strings.Join(env.ReferenceErrors, "; ")
if err := st.SetState(id, sessioninbox.StateBlocked, reason); err != nil {
return sessioninbox.InboxItemMeta{}, err
}
_ = st.SetPaused(true)
updated.State = sessioninbox.StateBlocked
updated.BlockReason = reason
}
return updated, nil
}
// AppendInboxItem atomically merges collect-mode text and binds the inbound
// platform message ID as an idempotency alias for the existing durable item.
func (c *Controller) AppendInboxItem(id, text, idempotency string, extra map[string]string) (sessioninbox.InboxItemMeta, error) {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxItemMeta{}, err
}
_, previous, err := st.ReadItem(id)
if err != nil {
return sessioninbox.InboxItemMeta{}, err
}
text = strings.TrimSpace(text)
if text == "" {
return sessioninbox.InboxItemMeta{}, sessioninbox.ErrEmpty
}
merged := strings.TrimSpace(previous.SubmitText)
if merged != "" {
merged += "\n" + text
} else {
merged = text
}
env := previous
env.DisplayText = merged
env.RawText = merged
env.SubmitText = merged
if len(extra) > 0 {
env.Extra = maps.Clone(extra)
}
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), merged, env.ExplicitRefs)
aliasEnv := sessioninbox.PromptEnvelope{
DisplayText: text,
RawText: text,
SubmitText: text,
Source: previous.Source,
Extra: maps.Clone(extra),
}
updated, err := st.UpdateItemWithIdempotency(id, env, idempotency, aliasEnv)
if err != nil {
return sessioninbox.InboxItemMeta{}, err
}
if len(env.ReferenceErrors) > 0 {
reason := strings.Join(env.ReferenceErrors, "; ")
if err := st.SetState(id, sessioninbox.StateBlocked, reason); err != nil {
return sessioninbox.InboxItemMeta{}, err
}
_ = st.SetPaused(true)
updated.State = sessioninbox.StateBlocked
updated.BlockReason = reason
}
return updated, nil
}
func (c *Controller) DeleteInboxItem(id string) error {
st, err := c.ensureInbox()
if err != nil {
return err
}
return st.DeleteItem(id)
}
// CancelWithInboxItems stops the active turn and discards only the durable
// pending items explicitly owned by the cancelling frontend. Admission is
// paused around the batch deletion so TurnDone cannot race a cancelled item
// into a new provider turn. Unrelated inbox items remain intact.
func (c *Controller) CancelWithInboxItems(ids []string, source string) error {
st, err := c.ensureInbox()
if err != nil {
c.Cancel()
return err
}
wasPaused := st.Snapshot().Paused
if err := st.SetPaused(true); err != nil {
c.Cancel()
return err
}
if err := st.DiscardPendingItemsOwned(ids, strings.TrimSpace(source)); err != nil {
// Keep the inbox paused for inspection if an item already crossed the
// admission boundary. Cancellation still stops that in-flight turn.
c.Cancel()
return err
}
c.Cancel()
if !wasPaused {
if err := st.SetPaused(false); err != nil {
return err
}
}
return nil
}
func (c *Controller) MoveInboxItem(id string, toIndex int) error {
st, err := c.ensureInbox()
if err != nil {
return err
}
return st.MoveItem(id, toIndex)
}
func (c *Controller) SetInboxPaused(paused bool) error {
return c.setInboxPaused(paused, true)
}
// SetInboxPausedPassive changes pause state without starting a background turn.
// Blocking transports such as Bot own their render sink and drain explicitly.
func (c *Controller) SetInboxPausedPassive(paused bool) error {
return c.setInboxPaused(paused, false)
}
func (c *Controller) setInboxPaused(paused, dispatch bool) error {
st, err := c.ensureInbox()
if err != nil {
return err
}
if err := st.SetPaused(paused); err != nil {
return err
}
if paused {
sessioninbox.NotePaused()
} else if dispatch {
// On resume, try to dispatch if idle.
c.maybeDispatchInbox()
}
return nil
}
func (c *Controller) RetryInboxItem(id string) error {
return c.retryInboxItem(id, true)
}
// RetryInboxItemPassive requeues an item without detached background dispatch.
func (c *Controller) RetryInboxItemPassive(id string) error {
return c.retryInboxItem(id, false)
}
func (c *Controller) retryInboxItem(id string, dispatch bool) error {
st, err := c.ensureInbox()
if err != nil {
return err
}
if err := st.RetryItem(id); err != nil {
return err
}
if dispatch {
c.maybeDispatchInbox()
}
return nil
}
func (c *Controller) RefreshInboxReferences(id string) error {
st, err := c.ensureInbox()
if err != nil {
return err
}
meta, env, err := st.ReadItem(id)
if err != nil {
return err
}
_ = meta
env.Refs = nil
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), env.SubmitText, env.ExplicitRefs)
_, err = st.UpdateItem(id, env)
if err == nil && len(env.ReferenceErrors) > 0 {
reason := strings.Join(env.ReferenceErrors, "; ")
err = st.SetState(id, sessioninbox.StateBlocked, reason)
_ = st.SetPaused(true)
}
return err
}
// TrySteerInboxItem persists intent=steer (if needed) and attempts mid-turn
// admission. Rejected steers stay queued as follow-up.
//
// The agent loader only captures the item ID and re-reads the blob on consume
// so large steer bodies do not accumulate in the agent heap.
func (c *Controller) TrySteerInboxItem(id string) (sessioninbox.InboxReceipt, error) {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxReceipt{}, err
}
meta, env, err := st.ReadItem(id)
if err != nil {
return sessioninbox.InboxReceipt{}, err
}
if meta.State != sessioninbox.StateQueued && meta.State != sessioninbox.StateSteerAccepted {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrInvalidState
}
if meta.State == sessioninbox.StateSteerAccepted {
return sessioninbox.InboxReceipt{
ItemID: id,
Disposition: sessioninbox.DispositionSteerAccepted,
Paused: st.Snapshot().Paused,
Capacity: st.Snapshot().Capacity,
Idempotent: true,
}, nil
}
snapshot := st.Snapshot()
if snapshot.Paused {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrPaused
}
cap := snapshot.Capacity
c.mu.Lock()
rotating := c.rotating
closed := c.closed
c.mu.Unlock()
if closed {
return sessioninbox.InboxReceipt{ItemID: id, Disposition: sessioninbox.DispositionRejectedClosed, Capacity: cap}, nil
}
if rotating {
return sessioninbox.InboxReceipt{ItemID: id, Disposition: sessioninbox.DispositionRejectedRotating, Capacity: cap}, nil
}
// Capture only the store pointer + item id. Load body from disk at consume.
storeRef := st
itemID := id
loader := func() (string, error) {
_, env, err := storeRef.ReadItem(itemID)
if err != nil {
return "", err
}
text := strings.TrimSpace(env.SubmitText)
if text == "" {
text = strings.TrimSpace(env.DisplayText)
}
if text == "" {
return "", fmt.Errorf("inbox item %s has empty body", itemID)
}
materialized, images, block, materializeErr := applyInboxReferences(env)
if materializeErr != nil {
return "", materializeErr
}
if block != "" {
return "", fmt.Errorf("frozen reference unavailable: %s", block)
}
if len(images) > 0 {
return "", fmt.Errorf("image guidance requires a follow-up turn")
}
return firstNonEmptyStr(materialized, text), nil
}
// Persist the admission boundary before exposing the loader to the agent.
// Holding c.mu for the short in-memory enqueue serializes active tracking
// with finishGuardedTurn, so TurnDone cannot overtake an accepted steer.
if len(env.FrozenImages) == 0 {
if err := st.SetState(id, sessioninbox.StateSteerAccepted, ""); err != nil {
return sessioninbox.InboxReceipt{}, err
}
}
c.mu.Lock()
accepted := !c.closed && !c.rotating && c.running && c.executor != nil && len(env.FrozenImages) == 0 && c.executor.SteerItem(id, loader)
if accepted {
c.inbox.mu.Lock()
c.inbox.trackActive(id)
c.inbox.mu.Unlock()
}
c.mu.Unlock()
if accepted {
sessioninbox.NoteSteerAccepted()
return sessioninbox.InboxReceipt{
ItemID: id,
Disposition: sessioninbox.DispositionSteerAccepted,
Paused: st.Snapshot().Paused,
Capacity: cap,
}, nil
}
// Rejected: keep as follow-up.
if len(env.FrozenImages) == 0 {
if err := st.SetState(id, sessioninbox.StateQueued, ""); err != nil {
_ = st.ForcePause(true, 1)
return sessioninbox.InboxReceipt{}, err
}
}
if err := st.ConvertIntent(id, sessioninbox.IntentFollowup); err != nil {
return sessioninbox.InboxReceipt{}, err
}
sessioninbox.NoteSteerRejected()
return sessioninbox.InboxReceipt{
ItemID: id,
Disposition: sessioninbox.DispositionQueuedFollowup,
Paused: st.Snapshot().Paused,
Capacity: cap,
}, nil
}
// TrySubmitInboxItem admits a queued item as a new turn when the session is idle.
func (c *Controller) TrySubmitInboxItem(id string) (sessioninbox.InboxReceipt, error) {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxReceipt{}, err
}
meta, env, err := st.ReadItem(id)
if err != nil {
return sessioninbox.InboxReceipt{}, err
}
if meta.State != sessioninbox.StateQueued {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrInvalidState
}
if st.Snapshot().Paused {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrPaused
}
run, block, materializeErr := c.prepareInboxRun(env)
if materializeErr != nil {
return sessioninbox.InboxReceipt{}, materializeErr
}
if block != "" {
_ = st.SetState(id, sessioninbox.StateBlocked, block)
_ = st.SetPaused(true)
return sessioninbox.InboxReceipt{}, fmt.Errorf("%w: %s", sessioninbox.ErrInvalidState, block)
}
// Persist the in-flight state before admission. Active tracking is installed
// only after Controller admission is reserved and before the turn can finish.
if err := st.ClaimItem(id); err != nil {
return sessioninbox.InboxReceipt{}, err
}
c.inbox.mu.Lock()
beforeAdmission := c.inbox.beforePreparedAdmission
c.inbox.mu.Unlock()
if beforeAdmission != nil {
beforeAdmission()
}
// Start the classified envelope directly. Submit would parse @tokens again
// and mix live workspace bytes with the enqueue-time snapshot.
result := c.submitPreparedInboxTurn(id, run)
if result != turnStarted {
if err := st.SetState(id, sessioninbox.StateQueued, ""); err != nil {
_ = st.ForcePause(true, 1)
return sessioninbox.InboxReceipt{}, err
}
return c.receiptForAdmissionResult(id, st, result), nil
}
return sessioninbox.InboxReceipt{
ItemID: id,
Disposition: sessioninbox.DispositionStarted,
Capacity: st.Snapshot().Capacity,
}, nil
}
func (c *Controller) receiptForAdmissionResult(id string, st *sessioninbox.Store, result admissionResult) sessioninbox.InboxReceipt {
disposition := sessioninbox.DispositionRejectedBusy
switch result {
case turnDroppedClosed:
disposition = sessioninbox.DispositionRejectedClosed
case turnDroppedRotating:
disposition = sessioninbox.DispositionRejectedRotating
}
return sessioninbox.InboxReceipt{ItemID: id, Disposition: disposition, Capacity: st.Snapshot().Capacity}
}
// onInboxTurnDone acknowledges durable completion of every active inbox item
// (running follow-up + all steers accepted this turn). Dispatch of the next
// item is deferred until the finishing window closes so admission is not
// rejected as busy.
func (c *Controller) onInboxTurnDone() {
c.inbox.mu.Lock()
ids := c.inbox.takeActive()
st := c.inbox.store
c.inbox.mu.Unlock()
if st == nil || len(ids) == 0 {
return
}
// Transcript snapshot is the durable receipt boundary for the whole set.
if err := c.SnapshotActivity(); err != nil {
slog.Warn("controller: inbox turn snapshot", "err", err)
for _, id := range ids {
_ = st.SetState(id, sessioninbox.StateUncertain, "turn completed but transcript snapshot failed")
}
_ = st.SetPaused(true)
sessioninbox.NoteUncertain()
return
}
ackFailed := false
for _, id := range ids {
if err := st.AckDequeue(id); err != nil {
if errors.Is(err, sessioninbox.ErrNotFound) {
continue
}
slog.Warn("controller: inbox ack dequeue", "err", err, "id", id)
_ = st.SetState(id, sessioninbox.StateUncertain, "turn completed but inbox acknowledgement failed")
ackFailed = true
}
}
if ackFailed {
_ = st.SetPaused(true)
sessioninbox.NoteUncertain()
}
}
// onInboxUnappliedSteer keeps accepted-but-unapplied steers for inspection.
func (c *Controller) onInboxUnappliedSteer(itemID string) {
if itemID == "" {
return
}
st, err := c.ensureInbox()
if err != nil {
return
}
_ = st.SetState(itemID, sessioninbox.StateUncertain, "steer accepted but unapplied before turn exit")
_ = st.SetPaused(true)
c.inbox.mu.Lock()
c.inbox.untrackActive(itemID)
c.inbox.mu.Unlock()
sessioninbox.NoteUncertain()
}
// onInboxSteerConsumed marks steer_accepted → steer_consumed.
func (c *Controller) onInboxSteerConsumed(itemID string) {
if itemID == "" {
return
}
st, err := c.ensureInbox()
if err != nil {
return
}
_ = st.SetState(itemID, sessioninbox.StateSteerConsumed, "")
}
// maybeDispatchInbox admits the next FIFO item when idle, not paused, and no
// approval/ask UI is open.
func (c *Controller) maybeDispatchInbox() {
c.inbox.mu.Lock()
if c.inbox.dispatching {
c.inbox.mu.Unlock()
return
}
c.inbox.dispatching = true
c.inbox.mu.Unlock()
defer func() {
c.inbox.mu.Lock()
c.inbox.dispatching = false
c.inbox.mu.Unlock()
}()
if c.PendingPrompt() {
return
}
c.mu.Lock()
busy := c.running || c.finishing || c.rotating || c.closed
c.mu.Unlock()
if busy {
return
}
st, err := c.ensureInbox()
if err != nil {
return
}
meta, ok := st.NextQueued()
if !ok {
return
}
_, _ = c.TrySubmitInboxItem(meta.ID)
}
// TryEnqueueAndSteer is a convenience for frontends: durable steer then TrySteer.
func (c *Controller) TryEnqueueAndSteer(req InboxRequest) (sessioninbox.InboxReceipt, error) {
req.Intent = sessioninbox.IntentSteer
rec, err := c.EnqueueInbox(req)
if err != nil {
return rec, err
}
return c.TrySteerInboxItem(rec.ItemID)
}
// TryEnqueueFollowup durably queues a follow-up and may dispatch if idle.
func (c *Controller) TryEnqueueFollowup(req InboxRequest) (sessioninbox.InboxReceipt, error) {
req.Intent = sessioninbox.IntentFollowup
rec, err := c.EnqueueInbox(req)
if err != nil {
return rec, err
}
if !c.Running() {
c.maybeDispatchInbox()
}
return rec, nil
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+47
View File
@@ -0,0 +1,47 @@
package control
import (
"context"
"strings"
"reasonix/internal/sessioninbox"
)
func (c *Controller) freezeInboxReferences(ctx context.Context, submit string, explicit []string) (string, []string, []string) {
var line strings.Builder
line.WriteString(submit)
for _, path := range explicit {
path = strings.TrimSpace(path)
if path != "" {
line.WriteString(" @")
line.WriteString(EscapeRefPath(path))
}
}
input := line.String()
if !c.HasRefs(input) {
return "", nil, nil
}
block, errs := c.ResolveRefs(ctx, input)
images := c.resolveInputImageCandidates(input)
return block, images, errs
}
func applyInboxReferences(env sessioninbox.PromptEnvelope) (submit string, images []string, blockReason string, err error) {
if len(env.ReferenceErrors) > 0 {
return "", nil, strings.Join(env.ReferenceErrors, "; "), nil
}
submit = env.SubmitText
images = append([]string(nil), env.FrozenImages...)
if env.FrozenRefBlock != "" {
submit = "Referenced context:\n\n" + env.FrozenRefBlock + "\n\n" + submit
return submit, images, "", nil
}
if len(env.Refs) == 0 {
return submit, images, "", nil
}
legacyBlock, bodies, materializeErr := sessioninbox.MaterializeRefs(context.Background(), "", env.Refs)
if materializeErr != nil || legacyBlock != "" {
return "", nil, legacyBlock, materializeErr
}
return sessioninbox.ApplyFrozenRefs(submit, bodies), images, "", nil
}
+99
View File
@@ -0,0 +1,99 @@
package control
import (
"context"
"fmt"
"strings"
"reasonix/internal/sessioninbox"
)
// RunInboxTurn synchronously claims and executes one durable item. Bot and ACP
// use this path so their blocking response sink remains attached through every
// queued follow-up while Controller still owns durable state and ack semantics.
func (c *Controller) RunInboxTurn(ctx context.Context, id string) error {
st, err := c.ensureInbox()
if err != nil {
return err
}
meta, env, err := st.ReadItem(id)
if err != nil {
return err
}
if meta.State != sessioninbox.StateQueued {
return sessioninbox.ErrInvalidState
}
run, block, err := c.prepareInboxRun(env)
if err != nil {
return err
}
if block != "" {
_ = st.SetState(id, sessioninbox.StateBlocked, block)
_ = st.SetPaused(true)
return fmt.Errorf("%w: %s", sessioninbox.ErrInvalidState, block)
}
return c.runSynchronousTurn(ctx, func() error {
if err := st.ClaimItem(id); err != nil {
return err
}
c.inbox.mu.Lock()
c.inbox.trackActive(id)
c.inbox.mu.Unlock()
return nil
}, run)
}
func (c *Controller) prepareInboxRun(env sessioninbox.PromptEnvelope) (func(context.Context) error, string, error) {
submit, frozenImages, block, err := applyInboxReferences(env)
if err != nil || block != "" {
return nil, block, err
}
display := firstNonEmptyStr(env.DisplayText, submit)
raw := firstNonEmptyStr(env.RawText, submit)
requests := controlInvocationsFromInbox(env)
if len(requests) == 0 {
return func(ctx context.Context) error {
return c.runGoalLoopWithFrozenImagesRawDisplay(c.withTurnFormat(ctx, strings.TrimSpace(env.Format)), submit, raw, display, frozenImages)
}, "", nil
}
prepared, err := c.prepareInvocationTurn(submit, requests)
if err != nil {
return nil, err.Error(), nil
}
return func(ctx context.Context) error {
return c.runPreparedInvocationTurn(c.withTurnFormat(ctx, strings.TrimSpace(env.Format)), prepared, submit, raw, display, frozenImages)
}, "", nil
}
// submitPreparedInboxTurn starts an already-classified inbox envelope without
// interpreting slash commands, shell shortcuts, or @references a second time.
func (c *Controller) submitPreparedInboxTurn(itemID string, run func(context.Context) error) admissionResult {
return c.runGuardedInbox(run, func() {
c.inbox.mu.Lock()
c.inbox.trackActive(itemID)
c.inbox.mu.Unlock()
})
}
func sessionInboxInvocations(requests []InvocationRequest) []sessioninbox.StructuredInvocation {
if len(requests) == 0 {
return nil
}
out := make([]sessioninbox.StructuredInvocation, 0, len(requests))
for _, request := range requests {
out = append(out, sessioninbox.StructuredInvocation{Name: request.Name, Kind: request.Kind, Offset: request.Offset})
}
return out
}
func controlInvocationsFromInbox(env sessioninbox.PromptEnvelope) []InvocationRequest {
stored := env.Invocations
if len(stored) == 0 && env.Invocation != nil {
stored = []sessioninbox.StructuredInvocation{*env.Invocation}
}
out := make([]InvocationRequest, 0, len(stored))
for _, invocation := range stored {
out = append(out, InvocationRequest{Name: invocation.Name, Kind: invocation.Kind, Offset: invocation.Offset})
}
return out
}
+109
View File
@@ -0,0 +1,109 @@
package control
import (
"reasonix/internal/event"
"reasonix/internal/evidence"
)
// inboxEventSink observes Steer and unapplied-steer events so durable inbox
// state tracks consumption without frontends matching on text.
type inboxEventSink struct {
inner event.Sink
c *Controller
}
func (s *inboxEventSink) Emit(e event.Event) {
if s == nil {
return
}
if s.inner != nil {
s.inner.Emit(e)
}
if s.c == nil {
return
}
switch e.Kind {
case event.Steer:
if e.ItemID != "" {
s.c.onInboxSteerConsumed(e.ItemID)
}
case event.Notice:
if e.Code == event.NoticeCodeUnappliedSteer && e.ItemID != "" {
s.c.onInboxUnappliedSteer(e.ItemID)
}
}
}
// Forward optional sink capabilities so wrapping does not strip accounting.
func (s *inboxEventSink) RecordTurnCompletion() {
if s == nil {
return
}
event.RecordTurnCompletion(s.inner)
}
func (s *inboxEventSink) RecordReadinessAudit(a evidence.ReadinessAudit) {
if s == nil {
return
}
event.RecordReadinessAudit(s.inner, a)
}
func (s *inboxEventSink) RecordContractShadow(a event.ContractShadowAudit) {
if s == nil {
return
}
if rs, ok := s.inner.(interface {
RecordContractShadow(event.ContractShadowAudit)
}); ok {
rs.RecordContractShadow(a)
}
}
func (s *inboxEventSink) RecordCompletionReport(a event.CompletionReportAudit) {
if s == nil {
return
}
if rs, ok := s.inner.(interface {
RecordCompletionReport(event.CompletionReportAudit)
}); ok {
rs.RecordCompletionReport(a)
}
}
func (s *inboxEventSink) RecordOutcomeProgress(sample evidence.OutcomeSample) {
if s == nil {
return
}
if rs, ok := s.inner.(interface{ RecordOutcomeProgress(evidence.OutcomeSample) }); ok {
rs.RecordOutcomeProgress(sample)
}
}
func (s *inboxEventSink) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
if s == nil {
return
}
if rs, ok := s.inner.(interface {
RecordDelegationAdmission(event.DelegationAdmissionAudit)
}); ok {
rs.RecordDelegationAdmission(a)
}
}
func (s *inboxEventSink) RecordMemoryRecall(a event.MemoryRecallAudit) {
if s == nil {
return
}
if rs, ok := s.inner.(interface{ RecordMemoryRecall(event.MemoryRecallAudit) }); ok {
rs.RecordMemoryRecall(a)
}
}
func (s *inboxEventSink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
if s == nil {
return
}
event.RecordProtocolRecovery(s.inner, a)
}
+628
View File
@@ -0,0 +1,628 @@
package control
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/memory"
"reasonix/internal/provider"
"reasonix/internal/sessioninbox"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
func TestEnqueueInboxDurableAndSnapshot(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
if err := os.WriteFile(session, []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
c := New(Options{SessionPath: session, SessionDir: dir, Sink: event.Discard})
rec, err := c.EnqueueInbox(InboxRequest{
Intent: sessioninbox.IntentFollowup,
Display: "hello durable",
Submit: "hello durable",
Source: "test",
})
if err != nil {
t.Fatal(err)
}
if rec.ItemID == "" {
t.Fatal("empty item id")
}
snap := c.InboxSnapshot()
if len(snap.Items) != 1 || snap.Items[0].Preview == "" {
t.Fatalf("snapshot = %+v", snap)
}
// Body only via ReadInboxItem.
_, env, err := c.ReadInboxItem(rec.ItemID)
if err != nil || env.SubmitText != "hello durable" {
t.Fatalf("read = %+v err=%v", env, err)
}
}
func TestTrySteerRejectedBecomesFollowup(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
c := New(Options{SessionPath: session, SessionDir: dir, Sink: event.Discard})
rec, err := c.EnqueueInbox(InboxRequest{
Intent: sessioninbox.IntentSteer,
Submit: "mid-turn please",
})
if err != nil {
t.Fatal(err)
}
// No running turn → reject, keep as follow-up.
got, err := c.TrySteerInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if got.Disposition != sessioninbox.DispositionQueuedFollowup {
t.Fatalf("disposition = %s, want queued_followup", got.Disposition)
}
meta, _, err := c.ReadInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if meta.State != sessioninbox.StateQueued || meta.Intent != sessioninbox.IntentFollowup {
t.Fatalf("meta = %+v", meta)
}
}
func TestIdempotentEnqueue(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
c := New(Options{SessionPath: session, SessionDir: dir, Sink: event.Discard})
a, err := c.EnqueueInbox(InboxRequest{Submit: "x", Idempotency: "k1"})
if err != nil {
t.Fatal(err)
}
b, err := c.EnqueueInbox(InboxRequest{Submit: "x", Idempotency: "k1"})
if err != nil {
t.Fatal(err)
}
if a.ItemID != b.ItemID || !b.Idempotent {
t.Fatalf("a=%+v b=%+v", a, b)
}
}
func TestIdempotentEnqueueDoesNotReclassifyExistingItem(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
c := New(Options{
SessionPath: filepath.Join(dir, "s.jsonl"),
SessionDir: dir,
WorkspaceRoot: workspace,
Sink: event.Discard,
})
first, err := c.EnqueueInbox(InboxRequest{Submit: "original", Idempotency: "same"})
if err != nil {
t.Fatal(err)
}
second, err := c.EnqueueInbox(InboxRequest{Submit: "original", Idempotency: "same"})
if err != nil {
t.Fatal(err)
}
if first.ItemID != second.ItemID || !second.Idempotent {
t.Fatalf("first=%+v second=%+v", first, second)
}
snapshot := c.InboxSnapshot()
if snapshot.Paused || len(snapshot.Items) != 1 || snapshot.Items[0].State != sessioninbox.StateQueued {
t.Fatalf("idempotent replay reclassified original item: %+v", snapshot)
}
}
func TestIdempotentEnqueueRejectsDifferentInput(t *testing.T) {
dir := t.TempDir()
c := New(Options{
SessionPath: filepath.Join(dir, "s.jsonl"),
SessionDir: dir,
Sink: event.Discard,
})
if _, err := c.EnqueueInbox(InboxRequest{Submit: "original", Idempotency: "same"}); err != nil {
t.Fatal(err)
}
if _, err := c.EnqueueInbox(InboxRequest{Submit: "replacement", Idempotency: "same"}); !errors.Is(err, sessioninbox.ErrIdempotencyConflict) {
t.Fatalf("conflicting replay error = %v, want ErrIdempotencyConflict", err)
}
}
type inboxSteerProvider struct {
started chan struct{}
release chan struct{}
requests []provider.Request
}
func (p *inboxSteerProvider) Name() string { return "inbox-steer" }
func (p *inboxSteerProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.requests = append(p.requests, req)
ch := make(chan provider.Chunk, 2)
if len(p.requests) == 1 {
close(p.started)
go func() {
defer close(ch)
select {
case <-p.release:
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ready"}
ch <- provider.Chunk{Type: provider.ChunkDone}
case <-ctx.Done():
}
}()
return ch, nil
}
ch <- provider.Chunk{Type: provider.ChunkText, Text: "applied"}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func TestThirtySteersApplyAndAckExactlyOnce(t *testing.T) {
dir := t.TempDir()
prov := &inboxSteerProvider{started: make(chan struct{}), release: make(chan struct{})}
sess := agent.NewSession("sys")
exec := agent.New(prov, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
sink, done, _ := collectSink()
c := New(Options{
Runner: exec,
Executor: exec,
Sink: sink,
SessionDir: dir,
SessionPath: filepath.Join(dir, "s.jsonl"),
})
defer c.autosaveWG.Wait()
c.Submit("initial turn")
select {
case <-prov.started:
case <-time.After(time.Second):
t.Fatal("initial provider turn did not start")
}
const steerCount = 30
for i := range steerCount {
body := fmt.Sprintf("durable-steer-%02d", i)
rec, err := c.EnqueueInbox(InboxRequest{Intent: sessioninbox.IntentSteer, Submit: body})
if err != nil {
t.Fatal(err)
}
got, err := c.TrySteerInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if got.Disposition != sessioninbox.DispositionSteerAccepted {
t.Fatalf("steer %d disposition = %q", i, got.Disposition)
}
}
close(prov.release)
waitForDone(t, done)
if items := c.InboxSnapshot().Items; len(items) != 0 {
t.Fatalf("accepted steers were not all acknowledged: %+v", items)
}
if got := len(prov.requests); got != steerCount+1 {
t.Fatalf("provider requests = %d, want %d", got, steerCount+1)
}
messages := sess.Snapshot()
for i := range steerCount {
body := fmt.Sprintf("durable-steer-%02d", i)
count := 0
for _, message := range messages {
count += strings.Count(message.Content, body)
}
if count != 1 {
t.Fatalf("%q appears %d times in transcript, want exactly once", body, count)
}
}
}
func TestMultiSteerActiveSetAcksAll(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
c := New(Options{SessionPath: session, SessionDir: dir, Sink: event.Discard})
st, err := c.ensureInbox()
if err != nil {
t.Fatal(err)
}
var ids []string
for i := range 3 {
rec, err := c.EnqueueInbox(InboxRequest{Submit: "body-" + string(rune('a'+i))})
if err != nil {
t.Fatal(err)
}
ids = append(ids, rec.ItemID)
_ = st.SetState(rec.ItemID, sessioninbox.StateSteerConsumed, "")
}
c.inbox.mu.Lock()
c.inbox.clearActive()
for _, id := range ids {
c.inbox.trackActive(id)
}
c.inbox.mu.Unlock()
c.onInboxTurnDone()
if n := len(c.InboxSnapshot().Items); n != 0 {
t.Fatalf("want all 3 steers acked/dequeued, still have %d items", n)
}
}
func TestSubmitInboxUsesFrozenReferenceWithoutLiveReresolve(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
refPath := filepath.Join(workspace, "note.txt")
if err := os.WriteFile(refPath, []byte("enqueue-time-body"), 0o600); err != nil {
t.Fatal(err)
}
sessionPath := filepath.Join(dir, "s.jsonl")
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
sink, done, _ := collectSink()
c := New(Options{
Runner: appendingRunner{session: sess},
Executor: exec,
Sink: sink,
SessionDir: dir,
SessionPath: sessionPath,
WorkspaceRoot: workspace,
})
defer c.autosaveWG.Wait()
rec, err := c.EnqueueInbox(InboxRequest{Submit: "review @note.txt"})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(refPath, []byte("live-body-after-enqueue"), 0o600); err != nil {
t.Fatal(err)
}
got, err := c.TrySubmitInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if got.Disposition != sessioninbox.DispositionStarted {
t.Fatalf("disposition = %q, want started", got.Disposition)
}
waitForDone(t, done)
messages := sess.Snapshot()
if len(messages) < 2 {
t.Fatalf("messages = %+v", messages)
}
input := messages[len(messages)-1].Content
if !strings.Contains(input, "enqueue-time-body") {
t.Fatalf("prepared inbox turn omitted frozen body: %q", input)
}
if strings.Contains(input, "live-body-after-enqueue") {
t.Fatalf("prepared inbox turn re-resolved live reference: %q", input)
}
if strings.Count(input, "enqueue-time-body") != 1 {
t.Fatalf("frozen body injected more than once: %q", input)
}
}
func TestInboxFreezesTypedDirectoryAndPathInstructions(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
service := filepath.Join(workspace, "service")
if err := os.MkdirAll(service, 0o755); err != nil {
t.Fatal(err)
}
for path, body := range map[string]string{
filepath.Join(workspace, "AGENTS.md"): "ROOT RULE",
filepath.Join(service, "AGENTS.md"): "SERVICE RULE",
filepath.Join(service, "old.go"): "package service",
} {
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
sessionPath := filepath.Join(dir, "s.jsonl")
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
sink, done, _ := collectSink()
c := New(Options{
Runner: appendingRunner{session: sess},
Executor: exec,
Sink: sink,
SessionDir: dir,
SessionPath: sessionPath,
WorkspaceRoot: workspace,
Memory: memory.Load(memory.Options{CWD: workspace}),
})
defer c.autosaveWG.Wait()
rec, err := c.EnqueueInbox(InboxRequest{Submit: "review @service"})
if err != nil {
t.Fatal(err)
}
_, env, err := c.ReadInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"<dir ", "old.go", "<path-instructions", "SERVICE RULE"} {
if !strings.Contains(env.FrozenRefBlock, want) {
t.Fatalf("frozen typed context missing %q:\n%s", want, env.FrozenRefBlock)
}
}
if err := os.WriteFile(filepath.Join(service, "new.go"), []byte("package changed"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := c.TrySubmitInboxItem(rec.ItemID); err != nil {
t.Fatal(err)
}
waitForDone(t, done)
input := sess.Snapshot()[len(sess.Snapshot())-1].Content
if !strings.Contains(input, "old.go") || strings.Contains(input, "new.go") {
t.Fatalf("directory reference was re-resolved live: %q", input)
}
}
func TestInboxUsesFrozenImageBytesAfterWorkspaceChanges(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
writeVisionTestConfig(t, workspace)
imagePath := filepath.Join(workspace, "diagram.png")
if err := os.WriteFile(imagePath, mustBase64(t, tinyPNG), 0o644); err != nil {
t.Fatal(err)
}
prov := &recordingProvider{streams: [][]provider.Chunk{{
{Type: provider.ChunkText, Text: "done"},
{Type: provider.ChunkDone},
}}}
sess := agent.NewSession("sys")
exec := agent.New(prov, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
sink, done, _ := collectSink()
c := New(Options{
Runner: exec,
Executor: exec,
Sink: sink,
SessionDir: dir,
SessionPath: filepath.Join(dir, "s.jsonl"),
WorkspaceRoot: workspace,
ModelRef: "custom/vision-pro",
})
defer c.autosaveWG.Wait()
rec, err := c.EnqueueInbox(InboxRequest{Submit: "inspect @diagram.png"})
if err != nil {
t.Fatal(err)
}
_, env, err := c.ReadInboxItem(rec.ItemID)
if err != nil || len(env.FrozenImages) != 1 {
t.Fatalf("frozen image envelope = %+v err=%v", env, err)
}
frozen := env.FrozenImages[0]
if err := os.WriteFile(imagePath, []byte("changed after enqueue"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := c.TrySubmitInboxItem(rec.ItemID); err != nil {
t.Fatal(err)
}
waitForDone(t, done)
if len(prov.requests) != 1 {
t.Fatalf("provider requests = %d, want 1", len(prov.requests))
}
messages := prov.requests[0].Messages
if len(messages) == 0 || len(messages[len(messages)-1].Images) != 1 || messages[len(messages)-1].Images[0] != frozen {
t.Fatalf("provider did not receive the enqueue-time image snapshot: %+v", messages)
}
}
func TestTrySubmitInboxAdmissionRaceRestoresQueuedItem(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
c := New(Options{SessionPath: session, SessionDir: dir, Sink: event.Discard})
rec, err := c.EnqueueInbox(InboxRequest{Submit: "must remain durable"})
if err != nil {
t.Fatal(err)
}
competingStarted := make(chan struct{})
releaseCompeting := make(chan struct{})
c.inbox.mu.Lock()
c.inbox.beforePreparedAdmission = func() {
if result := c.runGuarded(func(context.Context) error {
close(competingStarted)
<-releaseCompeting
return nil
}); result != turnStarted {
t.Errorf("competing admission = %v, want turnStarted", result)
}
select {
case <-competingStarted:
case <-time.After(time.Second):
t.Error("competing turn did not start")
}
}
c.inbox.mu.Unlock()
receipt, err := c.TrySubmitInboxItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if receipt.Disposition != sessioninbox.DispositionRejectedBusy {
t.Fatalf("race disposition = %q, want rejected_busy", receipt.Disposition)
}
meta, _, err := c.ReadInboxItem(rec.ItemID)
if err != nil || meta.State != sessioninbox.StateQueued {
t.Fatalf("raced item = %+v err=%v, want durable queued", meta, err)
}
if err := c.SetInboxPaused(true); err != nil {
t.Fatal(err)
}
close(releaseCompeting)
c.autosaveWG.Wait()
}
func TestCancelWithInboxItemsDiscardsOnlyOwnedPendingItems(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
c := New(Options{SessionPath: session, SessionDir: dir, Sink: event.Discard})
owned, err := c.EnqueueInbox(InboxRequest{Submit: "owned by composer", Source: "desktop"})
if err != nil {
t.Fatal(err)
}
unrelated, err := c.EnqueueInbox(InboxRequest{Submit: "owned by bot", Source: "bot"})
if err != nil {
t.Fatal(err)
}
if err := c.CancelWithInboxItems([]string{owned.ItemID, unrelated.ItemID}, "desktop"); err != nil {
t.Fatal(err)
}
snap := c.InboxSnapshot()
if snap.Paused {
t.Fatal("successful scoped cancel left inbox paused")
}
if len(snap.Items) != 1 || snap.Items[0].ID != unrelated.ItemID {
t.Fatalf("scoped cancel left items = %+v", snap.Items)
}
}
func TestRunTurnAcknowledgesAcceptedDurableItems(t *testing.T) {
dir := t.TempDir()
runner := &fakeTurnRunner{}
c := New(Options{
Runner: runner,
SessionPath: filepath.Join(dir, "s.jsonl"),
SessionDir: dir,
Sink: event.Discard,
})
rec, err := c.EnqueueInbox(InboxRequest{Submit: "accepted steer", Idempotency: "steer-1"})
if err != nil {
t.Fatal(err)
}
st, err := c.ensureInbox()
if err != nil {
t.Fatal(err)
}
if err := st.SetState(rec.ItemID, sessioninbox.StateSteerConsumed, ""); err != nil {
t.Fatal(err)
}
c.inbox.mu.Lock()
c.inbox.trackActive(rec.ItemID)
c.inbox.mu.Unlock()
if err := c.RunTurn(context.Background(), "foreground"); err != nil {
t.Fatal(err)
}
if got := c.InboxSnapshot().Items; len(got) != 0 {
t.Fatalf("synchronous completion left accepted item queued: %+v", got)
}
if len(runner.inputs) != 1 || runner.inputs[0] != "foreground" {
t.Fatalf("runner inputs = %q", runner.inputs)
}
}
func TestRunInboxTurnClaimsAndAcknowledgesFIFOItems(t *testing.T) {
dir := t.TempDir()
runner := &fakeTurnRunner{}
c := New(Options{
Runner: runner,
SessionPath: filepath.Join(dir, "s.jsonl"),
SessionDir: dir,
Sink: event.Discard,
})
var ids []string
for _, input := range []string{"first", "second"} {
rec, err := c.EnqueueInbox(InboxRequest{Submit: input, Idempotency: "msg-" + input})
if err != nil {
t.Fatal(err)
}
ids = append(ids, rec.ItemID)
}
for _, id := range ids {
if err := c.RunInboxTurn(context.Background(), id); err != nil {
t.Fatal(err)
}
}
if got := runner.inputs; len(got) != 2 || got[0] != "first" || got[1] != "second" {
t.Fatalf("durable FIFO inputs = %q", got)
}
if got := c.InboxSnapshot().Items; len(got) != 0 {
t.Fatalf("completed FIFO items remain queued: %+v", got)
}
}
func TestStructuredInboxInvocationSurvivesReopenAndRunsSkill(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "s.jsonl")
skills := []skill.Skill{{
Name: "init", Body: "INITIALIZE_FROM_DURABLE_INBOX", RunAs: skill.RunInline, Scope: skill.ScopeGlobal,
}}
first := New(Options{SessionPath: path, SessionDir: dir, Skills: skills, Sink: event.Discard})
rec, err := first.EnqueueInbox(InboxRequest{
Display: "/init",
Idempotency: "desktop-submit-1",
Invocations: []InvocationRequest{{Name: "init", Kind: "skill", Offset: 0}},
})
if err != nil {
t.Fatal(err)
}
first.inbox.mu.Lock()
first.inbox.store.Close()
first.inbox.store = nil
first.inbox.mu.Unlock()
runner := &fakeTurnRunner{}
reopened := New(Options{
Runner: runner, SessionPath: path, SessionDir: dir, Skills: skills, Sink: event.Discard,
})
if err := reopened.RunInboxTurn(context.Background(), rec.ItemID); err != nil {
t.Fatal(err)
}
if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "INITIALIZE_FROM_DURABLE_INBOX") {
t.Fatalf("reopened structured turn lost skill semantics: %q", runner.inputs)
}
if strings.Contains(runner.inputs[0], "/init") {
t.Fatalf("structured turn degraded to slash text: %q", runner.inputs[0])
}
if got := reopened.InboxSnapshot().Items; len(got) != 0 {
t.Fatalf("structured item was not acknowledged: %+v", got)
}
}
func TestLegacySingularInboxInvocationInfersSkillKind(t *testing.T) {
dir := t.TempDir()
runner := &fakeTurnRunner{}
c := New(Options{
Runner: runner, SessionPath: filepath.Join(dir, "s.jsonl"), SessionDir: dir, Sink: event.Discard,
Skills: []skill.Skill{{Name: "legacy", Body: "LEGACY_SKILL_BODY", RunAs: skill.RunInline, Scope: skill.ScopeGlobal}},
})
st, err := c.ensureInbox()
if err != nil {
t.Fatal(err)
}
rec, err := st.Enqueue(sessioninbox.EnqueueRequest{Envelope: sessioninbox.PromptEnvelope{
DisplayText: "/legacy",
Invocation: &sessioninbox.StructuredInvocation{Name: "legacy"},
}})
if err != nil {
t.Fatal(err)
}
if err := c.RunInboxTurn(context.Background(), rec.ItemID); err != nil {
t.Fatal(err)
}
if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "LEGACY_SKILL_BODY") {
t.Fatalf("legacy structured input = %q", runner.inputs)
}
}
+2
View File
@@ -255,6 +255,7 @@ type SessionAPI interface {
SessionPersistence
Input
Settings
Inbox
}
// Compile-time proof that the concrete controller satisfies each sub-port and
@@ -272,5 +273,6 @@ var (
_ SessionPersistence = (*Controller)(nil)
_ Input = (*Controller)(nil)
_ Settings = (*Controller)(nil)
_ Inbox = (*Controller)(nil)
_ SessionAPI = (*Controller)(nil)
)
+59
View File
@@ -0,0 +1,59 @@
package control
import (
"context"
"reasonix/internal/event"
"reasonix/internal/extension"
)
// runSynchronousTurn owns the blocking transport lifecycle. Durable steer
// acknowledgement is intentionally shared with the asynchronous completion
// path, while follow-up dispatch remains owned by the synchronous frontend so
// its response sink stays bound for every queued turn.
func (c *Controller) runSynchronousTurn(
ctx context.Context,
onAdmitted func() error,
run func(context.Context) error,
) error {
ctx, cancel := context.WithCancel(extension.ContextWithRuntimeOwner(ctx, c.RuntimeOwner()))
c.mu.Lock()
// Finishing is part of the gate: TurnDone is still fanning out. Closed
// seals a torn-down controller. Blocking callers get an error rather than
// parking because they already own and enforce the request boundary.
if c.running || c.finishing || c.rotating || c.closed {
c.mu.Unlock()
cancel()
return ErrTurnRunning
}
if c.rejectDrainingGenerationLocked() {
c.mu.Unlock()
cancel()
c.emitDrainingNotice()
return ErrRuntimeDraining
}
c.cancel = cancel
c.running = true
c.canceling = false
c.mu.Unlock()
finish := func() {
c.mu.Lock()
c.running = false
c.cancel = nil
c.canceling = false
c.mu.Unlock()
cancel()
}
if onAdmitted != nil {
if err := onAdmitted(); err != nil {
finish()
return err
}
}
defer event.RecordTurnCompletion(c.sink)
defer func() {
finish()
c.onInboxTurnDone()
}()
return run(ctx)
}
+4
View File
@@ -54,6 +54,10 @@ func (c *Controller) runGoalLoopWithImageRefsRawDisplay(ctx context.Context, inp
return newTurnOrchestrator(c).runGoalLoopWithImageRefsRawDisplay(ctx, input, raw, imageRefs, display)
}
func (c *Controller) runGoalLoopWithFrozenImagesRawDisplay(ctx context.Context, input, raw, display string, images []string) error {
return newTurnOrchestrator(c).runGoalLoopWithFrozenImagesRawDisplay(ctx, input, raw, display, images)
}
func (c *Controller) runEditedGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display, original string) error {
return newTurnOrchestrator(c).runEditedGoalLoopWithImageRefsRawDisplay(ctx, input, raw, imageRefs, display, original)
}
+19 -1
View File
@@ -375,8 +375,26 @@ func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input,
}
func (o *turnOrchestrator) runGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display string) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
turn := o.c.prepareOrchestratedTurnImages(orchestratedTurn{input: input, raw: raw, imageRefs: imageRefs, display: display})
return o.runGoalLoopWithPreparedTurn(ctx, turn)
}
func (o *turnOrchestrator) runGoalLoopWithFrozenImagesRawDisplay(ctx context.Context, input, raw, display string, images []string) error {
turn := orchestratedTurn{
input: input,
raw: raw,
display: display,
imageCandidates: append([]string(nil), images...),
imagesResolved: true,
}
if o.c.imageInputEnabled() {
turn.userImages = append([]string(nil), images...)
}
return o.runGoalLoopWithPreparedTurn(ctx, turn)
}
func (o *turnOrchestrator) runGoalLoopWithPreparedTurn(ctx context.Context, turn orchestratedTurn) error {
expectedContinuationEpoch := o.c.goals.continuationToken()
ctx = agent.WithSubagentImageCandidates(ctx, turn.imageCandidates)
err := o.runOrchestratedTurn(ctx, turn)
if err != nil {
+4 -1
View File
@@ -561,7 +561,10 @@ type Event struct {
RetryMax int // Retrying: total attempts before giving up
RetryScope RetryScope // Retrying: optional "headers" | "stream"; empty for older emitters
StreamAttempt StreamAttemptInfo // StreamAttempt lifecycle
Workspace *WorkspaceChangedPayload // WorkspaceChanged (host-local)
// ItemID correlates Steer / unapplied-steer / TurnDone with a durable
// session-inbox entry. Empty for legacy callers that still use text only.
ItemID string
Workspace *WorkspaceChangedPayload // WorkspaceChanged (host-local)
}
type WorkspaceWatchState string
+5 -2
View File
@@ -37,7 +37,10 @@ type Event struct {
RetryMax int `json:"retryMax,omitempty"`
RetryScope string `json:"retryScope,omitempty"` // "headers" | "stream"; omit for older clients
StreamAttempt *StreamAttempt `json:"streamAttempt,omitempty"`
Workspace *WorkspaceChanged `json:"workspace,omitempty"`
// ItemID correlates Steer / TurnDone / unapplied-steer with a durable
// session-inbox entry. Empty for legacy text-only guidance.
ItemID string `json:"itemId,omitempty"`
Workspace *WorkspaceChanged `json:"workspace,omitempty"`
}
type WorkspaceChanged struct {
@@ -73,7 +76,7 @@ type StreamAttempt struct {
// ToWire converts a typed runtime event into the shared frontend JSON contract.
func ToWire(e event.Event) Event {
w := Event{Kind: kindNames[e.Kind], Text: e.Text, Detail: e.Detail, Reasoning: e.Reasoning}
w := Event{Kind: kindNames[e.Kind], Text: e.Text, Detail: e.Detail, Reasoning: e.Reasoning, ItemID: e.ItemID}
if len(e.MemoryCitations) > 0 {
w.MemoryCitations = ToWireMemoryCitations(e.MemoryCitations)
}
+182
View File
@@ -0,0 +1,182 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
"strings"
"reasonix/internal/control"
"reasonix/internal/sessioninbox"
)
func (s *Server) registerInboxRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /inbox", s.inboxList)
mux.HandleFunc("POST /inbox/items", s.inboxEnqueue)
mux.HandleFunc("GET /inbox/items/{id}", s.inboxGet)
mux.HandleFunc("PATCH /inbox/items/{id}", s.inboxUpdate)
mux.HandleFunc("DELETE /inbox/items/{id}", s.inboxDelete)
mux.HandleFunc("POST /inbox/move", s.inboxMove)
mux.HandleFunc("POST /inbox/pause", s.inboxPause)
mux.HandleFunc("POST /inbox/resume", s.inboxResume)
mux.HandleFunc("POST /inbox/items/{id}/retry", s.inboxRetry)
mux.HandleFunc("POST /inbox/items/{id}/refresh", s.inboxRefresh)
}
func (s *Server) inboxAPI() control.SessionAPI {
return s.ctl()
}
func writeInboxError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, sessioninbox.ErrItemTooLarge):
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge) // 413
case errors.Is(err, sessioninbox.ErrCapacityItems), errors.Is(err, sessioninbox.ErrCapacityBytes),
errors.Is(err, sessioninbox.ErrInvalidState), errors.Is(err, sessioninbox.ErrPaused),
errors.Is(err, sessioninbox.ErrNotFound):
http.Error(w, err.Error(), http.StatusConflict) // 409
case errors.Is(err, sessioninbox.ErrEmpty):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) inboxList(w http.ResponseWriter, r *http.Request) {
_ = r
snap := s.inboxAPI().InboxSnapshot()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(snap)
}
func (s *Server) inboxEnqueue(w http.ResponseWriter, r *http.Request) {
var body struct {
Input string `json:"input"`
Intent string `json:"intent"`
IdempotencyKey string `json:"idempotencyKey"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Input) == "" {
http.Error(w, "missing input", http.StatusBadRequest)
return
}
intent := sessioninbox.IntentFollowup
if strings.EqualFold(body.Intent, "steer") {
intent = sessioninbox.IntentSteer
}
api := s.inboxAPI()
if ensurer, ok := any(api).(interface{ EnsureSessionPath() }); ok {
ensurer.EnsureSessionPath()
}
req := control.InboxRequest{
Intent: intent,
Display: body.Input,
Raw: body.Input,
Submit: body.Input,
Source: "http",
Idempotency: body.IdempotencyKey,
}
var rec sessioninbox.InboxReceipt
var err error
if intent == sessioninbox.IntentSteer {
rec, err = api.TryEnqueueAndSteer(req)
} else {
rec, err = api.TryEnqueueFollowup(req)
}
if err != nil {
writeInboxError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(rec)
}
func (s *Server) inboxGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
meta, env, err := s.inboxAPI().ReadInboxItem(id)
if err != nil {
writeInboxError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"meta": meta, "envelope": env})
}
func (s *Server) inboxUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Input string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Input) == "" {
http.Error(w, "missing input", http.StatusBadRequest)
return
}
meta, err := s.inboxAPI().UpdateInboxItem(id, body.Input, body.Input, body.Input)
if err != nil {
writeInboxError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meta)
}
func (s *Server) inboxDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.inboxAPI().DeleteInboxItem(id); err != nil {
writeInboxError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) inboxMove(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string `json:"id"`
ToIndex int `json:"toIndex"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
if err := s.inboxAPI().MoveInboxItem(body.ID, body.ToIndex); err != nil {
writeInboxError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) inboxPause(w http.ResponseWriter, r *http.Request) {
_ = r
if err := s.inboxAPI().SetInboxPaused(true); err != nil {
writeInboxError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) inboxResume(w http.ResponseWriter, r *http.Request) {
_ = r
if err := s.inboxAPI().SetInboxPaused(false); err != nil {
writeInboxError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) inboxRetry(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.inboxAPI().RetryInboxItem(id); err != nil {
writeInboxError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) inboxRefresh(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.inboxAPI().RefreshInboxReferences(id); err != nil {
writeInboxError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+21 -5
View File
@@ -497,6 +497,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /history", s.history)
mux.HandleFunc("GET /context", s.context)
mux.HandleFunc("POST /submit", s.submit)
s.registerInboxRoutes(mux)
mux.HandleFunc("POST /cancel", s.cancel)
mux.HandleFunc("POST /approve", s.approve)
mux.HandleFunc("POST /plan", s.plan)
@@ -751,7 +752,24 @@ func (s *Server) submit(w http.ResponseWriter, r *http.Request) {
// published replacement. This closes the check/build/swap race where a
// request could otherwise start on cur after reload's initial busy check.
s.bindMu.Lock()
s.ctl().SubmitHTTPFormat(body.Input, body.Format)
ctrl := s.ctl()
// Fix false 202 while a turn is active: SubmitHTTPFormat silently drops
// concurrent input. Clients must use POST /inbox/items for durable follow-up.
if ctrl.Running() {
s.bindMu.Unlock()
http.Error(w, "session is busy; use POST /inbox/items for durable follow-up", http.StatusConflict)
return
}
ctrl.SubmitHTTPFormat(body.Input, body.Format)
// After synchronous admission, a successful start sets Running. A silent
// drop (rotating/closed) leaves Running false — return 409 instead of 202.
// Finishing-window park also leaves Running false briefly; prefer 202 only
// when Running or a pending prompt is observed, else durable-queue guidance.
if !ctrl.Running() && !ctrl.RuntimeStatus().PendingPrompt {
s.bindMu.Unlock()
http.Error(w, "input was not admitted; session is rotating, closed, or finishing — use POST /inbox/items", http.StatusConflict)
return
}
s.bindMu.Unlock()
w.WriteHeader(http.StatusAccepted)
}
@@ -1154,10 +1172,8 @@ func (s *Server) resume(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session is pending cleanup", http.StatusBadRequest)
return
}
// Session-path-changing critical sequence: two interleaved resumes would
// leave the controller on one session and the lease on another; serialize
// with /new, /fork, and switchModel. Taken after body/path validation so a
// slow client cannot hold the binding lock while uploading.
// Serialize with /new, /fork, and switchModel so the controller and lease
// cannot land on different sessions. Validate first to avoid slow holders.
s.bindMu.Lock()
defer s.bindMu.Unlock()
// Snapshot the current session before switching away — while this process
@@ -0,0 +1,79 @@
package sessioninbox
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestAdmittedItemsRejectUserCRUD(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
rec, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "running"}})
if err := s.SetState(rec.ItemID, StateRunning, ""); err != nil {
t.Fatal(err)
}
if _, err := s.UpdateItem(rec.ItemID, PromptEnvelope{SubmitText: "changed"}); !errors.Is(err, ErrInvalidState) {
t.Fatalf("update running item error = %v, want ErrInvalidState", err)
}
if err := s.MoveItem(rec.ItemID, 0); !errors.Is(err, ErrInvalidState) {
t.Fatalf("move running item error = %v, want ErrInvalidState", err)
}
if err := s.DeleteItem(rec.ItemID); !errors.Is(err, ErrInvalidState) {
t.Fatalf("delete running item error = %v, want ErrInvalidState", err)
}
if err := s.RetryItem(rec.ItemID); !errors.Is(err, ErrInvalidState) {
t.Fatalf("retry running item error = %v, want ErrInvalidState", err)
}
if err := s.AckDequeue(rec.ItemID); err != nil {
t.Fatalf("durable completion could not ack running item: %v", err)
}
}
func BenchmarkThirtyOneMiBItemsRetainedHeap(b *testing.B) {
body := strings.Repeat("x", 1<<20)
var retainedTotal uint64
for range b.N {
b.StopTimer()
session := filepath.Join(b.TempDir(), "s.jsonl")
s, err := Open(session, Limits{})
if err != nil {
b.Fatal(err)
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
b.StartTimer()
for range 30 {
if _, err := s.Enqueue(EnqueueRequest{
Intent: IntentFollowup,
Envelope: PromptEnvelope{
DisplayText: "one MiB body",
SubmitText: body,
},
}); err != nil {
b.Fatal(err)
}
}
b.StopTimer()
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
if after.HeapAlloc > before.HeapAlloc {
retainedTotal += after.HeapAlloc - before.HeapAlloc
}
s.Close()
}
b.ReportMetric(float64(retainedTotal)/float64(b.N), "B/retained-op")
}
+431
View File
@@ -0,0 +1,431 @@
package sessioninbox
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/fileutil"
"reasonix/internal/store"
)
func (s *Store) mutableLocked() error {
if s.closed {
return ErrClosed
}
if s.readonly {
return ErrSchemaReadonly
}
return nil
}
func (s *Store) blobPath(blobName string) (string, error) {
if !validBlobStem(blobName) {
return "", fmt.Errorf("sessioninbox: invalid blob name")
}
base := filepath.Join(s.dir, blobsDirName)
path := filepath.Join(base, blobName+blobSuffix)
rel, err := filepath.Rel(base, path)
if err != nil || rel == "." || !filepath.IsLocal(rel) {
return "", fmt.Errorf("sessioninbox: blob path escapes inbox")
}
return path, nil
}
// blobNameFor returns the on-disk blob stem for a meta entry.
func blobNameFor(meta InboxItemMeta) string {
if name := strings.TrimSpace(meta.BlobName); name != "" {
return name
}
return meta.ID
}
func (s *Store) writeBlobLocked(blobName string, data []byte) error {
if err := ensurePrivateDir(s.dir); err != nil {
return err
}
if err := ensurePrivateDir(filepath.Join(s.dir, blobsDirName)); err != nil {
return fmt.Errorf("sessioninbox: blobs dir: %w", err)
}
path, err := s.blobPath(blobName)
if err != nil {
return err
}
fileutil.Crash("inbox-blob-write", path)
if err := fileutil.AtomicWriteFileStrict(path, data, 0o600); err != nil {
return fmt.Errorf("sessioninbox: write blob: %w", err)
}
fileutil.Crash("inbox-blob-rename", path)
return nil
}
func (s *Store) readBlobLocked(blobName, wantChecksum string) (PromptEnvelope, error) {
if err := validatePrivateDir(filepath.Join(s.dir, blobsDirName)); err != nil {
return PromptEnvelope{}, fmt.Errorf("sessioninbox: blobs dir: %w", err)
}
path, err := s.blobPath(blobName)
if err != nil {
return PromptEnvelope{}, err
}
data, err := readRegularFile(path, s.limits.MaxItemBytes)
if err != nil {
return PromptEnvelope{}, fmt.Errorf("sessioninbox: read blob: %w", err)
}
got := sha256Hex(data)
if wantChecksum != "" && got != wantChecksum {
return PromptEnvelope{}, fmt.Errorf("sessioninbox: blob checksum mismatch")
}
var env PromptEnvelope
if err := json.Unmarshal(data, &env); err != nil {
return PromptEnvelope{}, fmt.Errorf("sessioninbox: decode blob: %w", err)
}
return env, nil
}
func ensurePrivateDir(path string) error {
if err := os.MkdirAll(path, 0o700); err != nil {
return err
}
return validatePrivateDir(path)
}
func validatePrivateDir(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("refusing non-directory or symlink")
}
return nil
}
func readRegularFile(path string, maxBytes int64) ([]byte, error) {
before, err := os.Lstat(path)
if err != nil {
return nil, err
}
if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() {
return nil, fmt.Errorf("refusing non-regular file")
}
if maxBytes > 0 && before.Size() > maxBytes {
return nil, fmt.Errorf("file exceeds %d bytes", maxBytes)
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
opened, err := f.Stat()
if err != nil {
return nil, err
}
if !opened.Mode().IsRegular() || !os.SameFile(before, opened) {
return nil, fmt.Errorf("file changed while opening")
}
reader := io.Reader(f)
if maxBytes > 0 {
reader = io.LimitReader(f, maxBytes+1)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if maxBytes > 0 && int64(len(data)) > maxBytes {
return nil, fmt.Errorf("file exceeds %d bytes", maxBytes)
}
return data, nil
}
func (s *Store) commitManifestLocked(next *manifest) error {
if next == nil {
return fmt.Errorf("sessioninbox: nil manifest")
}
if err := validateManifest(next, false); err != nil {
return fmt.Errorf("sessioninbox: invalid manifest: %w", err)
}
if err := ensurePrivateDir(s.dir); err != nil {
return fmt.Errorf("sessioninbox: mkdir: %w", err)
}
next.SchemaVersion = SchemaVersion
next.RunID = s.runID
next.Revision++
next.UpdatedAt = time.Now().UTC()
data, err := json.MarshalIndent(next, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
path := filepath.Join(s.dir, manifestName)
fileutil.Crash("inbox-manifest-write", path)
if err := fileutil.AtomicWriteFileStrict(path, data, 0o600); err != nil {
return fmt.Errorf("sessioninbox: write manifest: %w", err)
}
fileutil.Crash("inbox-manifest-commit", path)
// Best-effort directory fsync for durability of the rename.
if d, err := os.Open(s.dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
s.man = next
return nil
}
func (s *Store) quarantineFileLocked(path, tag string) error {
qdir := filepath.Join(s.dir, quarantineName)
if err := ensurePrivateDir(qdir); err != nil {
return err
}
base := filepath.Base(path) + "." + tag + "." + fmt.Sprintf("%d", time.Now().UnixNano())
return os.Rename(path, filepath.Join(qdir, base))
}
func (s *Store) gcOrphansLocked() {
bdir := filepath.Join(s.dir, blobsDirName)
if err := validatePrivateDir(bdir); err != nil {
return
}
entries, err := os.ReadDir(bdir)
if err != nil {
return
}
live := make(map[string]struct{}, len(s.man.Items))
for _, it := range s.man.Items {
live[blobNameFor(it)] = struct{}{}
}
qdir := filepath.Join(s.dir, quarantineName)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, blobSuffix) {
_ = s.quarantineUnknownLocked(filepath.Join(bdir, name))
continue
}
stem := strings.TrimSuffix(name, blobSuffix)
if _, ok := live[stem]; ok {
continue
}
// Orphan blob → quarantine (do not delete silently: crash recovery).
_ = ensurePrivateDir(qdir)
_ = os.Rename(filepath.Join(bdir, name), filepath.Join(qdir, name+"."+fmt.Sprintf("%d", time.Now().UnixNano())))
}
}
// salvageOrphanBlobsLocked rebuilds uncertain meta rows from blob files after a
// corrupt-manifest quarantine. Bodies stay on disk; the user reviews before resume.
func (s *Store) salvageOrphanBlobsLocked() []InboxItemMeta {
bdir := filepath.Join(s.dir, blobsDirName)
if err := validatePrivateDir(bdir); err != nil {
return nil
}
entries, err := os.ReadDir(bdir)
if err != nil {
return nil
}
now := time.Now().UTC()
var out []InboxItemMeta
for _, e := range entries {
if e.IsDir() || e.Type()&os.ModeSymlink != 0 || !strings.HasSuffix(e.Name(), blobSuffix) {
continue
}
stem := strings.TrimSuffix(e.Name(), blobSuffix)
if !validBlobStem(stem) {
continue
}
data, err := readRegularFile(filepath.Join(bdir, e.Name()), s.limits.MaxItemBytes)
if err != nil {
continue
}
var env PromptEnvelope
if err := json.Unmarshal(data, &env); err != nil {
continue
}
preview := PreviewText(firstNonEmpty(env.DisplayText, env.SubmitText, env.RawText), DefaultPreviewRunes)
if preview == "" {
preview = "(salvaged body)"
}
// The corrupt manifest no longer provides a revision-to-item mapping.
// Use the complete blob stem as a collision-free recovered item ID.
itemID := stem
out = append(out, InboxItemMeta{
ID: itemID,
Intent: IntentFollowup,
State: StateUncertain,
BlobName: stem,
CreatedAt: now,
UpdatedAt: now,
Preview: preview,
ByteSize: int64(len(data)),
Checksum: sha256Hex(data),
RunID: s.runID,
BlockReason: "salvaged after corrupt manifest",
})
}
return out
}
func (s *Store) quarantineUnknownLocked(path string) error {
qdir := filepath.Join(s.dir, quarantineName)
if err := ensurePrivateDir(qdir); err != nil {
return err
}
return os.Rename(path, filepath.Join(qdir, filepath.Base(path)+"."+fmt.Sprintf("%d", time.Now().UnixNano())))
}
func (s *Store) notifyLocked(snap InboxSnapshot) {
listeners := append([]func(InboxSnapshot){}, s.listeners...)
// Unlock is held; notify asynchronously so listeners can re-enter.
go func() {
for _, fn := range listeners {
fn(snap)
}
}()
}
func encodeEnvelope(env PromptEnvelope) (data []byte, checksum string, size int64, err error) {
data, err = json.Marshal(env)
if err != nil {
return nil, "", 0, err
}
return data, sha256Hex(data), int64(len(data)), nil
}
// idempotencyRequestHash fingerprints stable client intent. Enqueue-time
// reference materialization is deliberately excluded so a network retry does
// not conflict merely because the referenced workspace changed meanwhile.
func idempotencyRequestHash(env PromptEnvelope) (string, error) {
type stableInvocation struct {
Name string `json:"name,omitempty"`
Args map[string]string `json:"args,omitempty"`
Display string `json:"display,omitempty"`
}
storedInvocations := append([]StructuredInvocation(nil), env.Invocations...)
if len(storedInvocations) == 0 && env.Invocation != nil {
storedInvocations = []StructuredInvocation{*env.Invocation}
}
sort.SliceStable(storedInvocations, func(i, j int) bool {
return storedInvocations[i].Offset < storedInvocations[j].Offset
})
invocations := make([]stableInvocation, 0, len(storedInvocations))
for _, invocation := range storedInvocations {
invocations = append(invocations, stableInvocation{
Name: invocation.Name, Args: invocation.Args, Display: invocation.Display,
})
}
stable := struct {
DisplayText string `json:"displayText"`
RawText string `json:"rawText"`
SubmitText string `json:"submitText"`
Invocations []stableInvocation `json:"invocations,omitempty"`
Format string `json:"format,omitempty"`
Attachments []string `json:"attachments,omitempty"`
ExplicitRefs []string `json:"explicitRefs,omitempty"`
Source string `json:"source,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}{
DisplayText: env.DisplayText,
RawText: env.RawText,
SubmitText: env.SubmitText,
Invocations: invocations,
Format: env.Format,
Attachments: env.Attachments,
ExplicitRefs: env.ExplicitRefs,
Source: env.Source,
Extra: env.Extra,
}
data, err := json.Marshal(stable)
if err != nil {
return "", err
}
return sha256Hex(data), nil
}
func normalizeEnvelope(env PromptEnvelope) PromptEnvelope {
env.DisplayText = strings.TrimSpace(env.DisplayText)
env.RawText = strings.TrimSpace(env.RawText)
env.SubmitText = strings.TrimSpace(env.SubmitText)
env.Format = strings.TrimSpace(env.Format)
env.Idempotency = strings.TrimSpace(env.Idempotency)
env.Source = strings.TrimSpace(env.Source)
return env
}
func completeEnqueueEnvelope(env PromptEnvelope) PromptEnvelope {
env = normalizeEnvelope(env)
if env.Invocation != nil || len(env.Invocations) > 0 {
return env
}
if env.SubmitText == "" {
env.SubmitText = firstNonEmpty(env.RawText, env.DisplayText)
}
if env.DisplayText == "" {
env.DisplayText = env.SubmitText
}
if env.RawText == "" {
env.RawText = env.SubmitText
}
return env
}
func refSummaries(refs []RefSnapshot) []RefSummary {
if len(refs) == 0 {
return nil
}
out := make([]RefSummary, 0, len(refs))
for _, r := range refs {
out = append(out, RefSummary{
Kind: r.Kind,
Path: firstNonEmpty(r.DisplayPath, r.Path),
Commit: r.Commit,
Bytes: int64(len(r.Content)),
Preview: PreviewText(string(r.Content), 40),
})
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func agentBranchID(sessionPath string) string {
base := filepath.Base(sessionPath)
return strings.TrimSuffix(base, ".jsonl")
}
// RemoveDir deletes the entire inbox directory (clear/delete session).
func RemoveDir(sessionPath string) error {
dir := store.SessionInboxDir(sessionPath)
if dir == "" {
return nil
}
if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// MigrateDir renames the inbox directory with a session path change.
func MigrateDir(oldPath, newPath string) error {
oldDir := store.SessionInboxDir(oldPath)
newDir := store.SessionInboxDir(newPath)
if oldDir == "" || newDir == "" {
return nil
}
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+6
View File
@@ -0,0 +1,6 @@
// Package sessioninbox is the durable session-level instruction queue.
//
// Each session owns a <stem>.inbox/ directory with a revisioned manifest
// (metadata only) and per-item PromptEnvelope blobs. Frontends admit work
// through control.Controller; this package never enters provider prompts.
package sessioninbox
@@ -0,0 +1,69 @@
package sessioninbox
import "time"
func (s *Store) idempotentReceiptLocked(key, requestHash string) (InboxReceipt, bool, error) {
if key == "" {
return InboxReceipt{}, false, nil
}
if id, ok := s.man.Idempotency[key]; ok {
if item, found := s.man.item(id); found {
if previous := s.man.IdempotencyHashes[key]; previous != "" && previous != requestHash {
return InboxReceipt{}, false, ErrIdempotencyConflict
}
return InboxReceipt{
ItemID: item.ID, Disposition: DispositionIdempotentHit,
Position: s.man.indexOf(item.ID) + 1, Paused: s.man.Paused,
Capacity: s.snapshotLocked().Capacity, Idempotent: true,
}, true, nil
}
}
receipt, ok := s.man.Receipts[key]
if !ok || time.Since(receipt.CompletedAt) > idempotencyReceiptTTL {
return InboxReceipt{}, false, nil
}
if receipt.RequestHash != requestHash {
return InboxReceipt{}, false, ErrIdempotencyConflict
}
return InboxReceipt{
ItemID: receipt.ItemID, Disposition: DispositionIdempotentHit,
Paused: s.man.Paused, Capacity: s.snapshotLocked().Capacity, Idempotent: true,
}, true, nil
}
func (s *Store) idempotentAliasReplayLocked(key, requestHash, itemID string) (bool, error) {
if key == "" {
return false, nil
}
if existingID, ok := s.man.Idempotency[key]; ok {
if existingHash := s.man.IdempotencyHashes[key]; existingHash != "" && existingHash != requestHash {
return false, ErrIdempotencyConflict
}
if existingID != itemID {
return false, ErrIdempotencyConflict
}
return true, nil
}
receipt, ok := s.man.Receipts[key]
if !ok || time.Since(receipt.CompletedAt) > idempotencyReceiptTTL {
return false, nil
}
if receipt.RequestHash != requestHash || receipt.ItemID != itemID {
return false, ErrIdempotencyConflict
}
return true, nil
}
func bindIdempotency(m *manifest, key, itemID, requestHash string) {
if key == "" {
return
}
if m.Idempotency == nil {
m.Idempotency = map[string]string{}
}
if m.IdempotencyHashes == nil {
m.IdempotencyHashes = map[string]string{}
}
m.Idempotency[key] = itemID
m.IdempotencyHashes[key] = requestHash
}
+87
View File
@@ -0,0 +1,87 @@
package sessioninbox
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"unicode/utf8"
)
var processRunID = sync.OnceValue(func() string {
return newRandomID()
})
// ProcessRunID returns a process-stable run identifier used to detect
// cross-process crash recovery without mistaking tab switches for crashes.
func ProcessRunID() string {
return processRunID()
}
func newRandomID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failure is exceptional; fall back to a hashed time-ish value.
sum := sha256.Sum256(fmt.Appendf(nil, "fallback-%p", &b))
return hex.EncodeToString(sum[:16])
}
// UUID-like layout without external deps: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
h := hex.EncodeToString(b[:])
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// PreviewText returns a bounded single-line preview without materializing the
// whole body as []rune when unnecessary.
func PreviewText(text string, maxRunes int) string {
if maxRunes <= 0 {
maxRunes = DefaultPreviewRunes
}
text = strings.TrimSpace(text)
if text == "" {
return ""
}
// Collapse internal whitespace for the shelf preview.
var b strings.Builder
b.Grow(min(len(text), maxRunes*4))
prevSpace := false
count := 0
for i := 0; i < len(text); {
r, size := utf8.DecodeRuneInString(text[i:])
i += size
if r == utf8.RuneError && size == 1 {
continue
}
if r == '\n' || r == '\r' || r == '\t' || r == ' ' {
if prevSpace || count == 0 {
continue
}
b.WriteByte(' ')
prevSpace = true
count++
if count >= maxRunes {
break
}
continue
}
prevSpace = false
b.WriteRune(r)
count++
if count >= maxRunes {
// Peek if more content remains.
if i < len(text) {
b.WriteString("…")
}
break
}
}
return strings.TrimSpace(b.String())
}
+280
View File
@@ -0,0 +1,280 @@
package sessioninbox
import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
"strings"
"time"
)
const (
maxIdempotencyReceipts = 512
idempotencyReceiptTTL = 7 * 24 * time.Hour
maxIdempotencyKeyBytes = 1024
)
type idempotencyReceipt struct {
ItemID string `json:"itemId"`
RequestHash string `json:"requestHash"`
Disposition Disposition `json:"disposition"`
CompletedAt time.Time `json:"completedAt"`
}
// manifest is the on-disk revisioned metadata file (no bodies).
type manifest struct {
SchemaVersion int `json:"schemaVersion"`
Revision int64 `json:"revision"`
RunID string `json:"runId,omitempty"`
Paused bool `json:"paused"`
Recovered bool `json:"recovered"`
RecoveredN int `json:"recoveredCount,omitempty"`
Items []InboxItemMeta `json:"items"`
Idempotency map[string]string `json:"idempotency,omitempty"` // key -> itemID
// IdempotencyHashes fingerprints the original client request, excluding
// enqueue-time reference materialization. It covers both live items and
// aliases created by collect mode.
IdempotencyHashes map[string]string `json:"idempotencyHashes,omitempty"`
Receipts map[string]idempotencyReceipt `json:"receipts,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
func emptyManifest(runID string) *manifest {
return &manifest{
SchemaVersion: SchemaVersion,
RunID: runID,
Items: []InboxItemMeta{},
Idempotency: map[string]string{},
IdempotencyHashes: map[string]string{},
Receipts: map[string]idempotencyReceipt{},
UpdatedAt: time.Now().UTC(),
}
}
func (m *manifest) clone() *manifest {
if m == nil {
return emptyManifest("")
}
out := *m
out.Items = append([]InboxItemMeta(nil), m.Items...)
if m.Idempotency != nil {
out.Idempotency = make(map[string]string, len(m.Idempotency))
maps.Copy(out.Idempotency, m.Idempotency)
} else {
out.Idempotency = map[string]string{}
}
if m.IdempotencyHashes != nil {
out.IdempotencyHashes = make(map[string]string, len(m.IdempotencyHashes))
maps.Copy(out.IdempotencyHashes, m.IdempotencyHashes)
} else {
out.IdempotencyHashes = map[string]string{}
}
if m.Receipts != nil {
out.Receipts = make(map[string]idempotencyReceipt, len(m.Receipts))
maps.Copy(out.Receipts, m.Receipts)
} else {
out.Receipts = map[string]idempotencyReceipt{}
}
return &out
}
func (m *manifest) totalBytes() int64 {
var n int64
for _, it := range m.Items {
n += it.ByteSize
}
return n
}
func (m *manifest) indexOf(id string) int {
for i, it := range m.Items {
if it.ID == id {
return i
}
}
return -1
}
func (m *manifest) item(id string) (InboxItemMeta, bool) {
i := m.indexOf(id)
if i < 0 {
return InboxItemMeta{}, false
}
return m.Items[i], true
}
func (m *manifest) removeItem(id string) (InboxItemMeta, bool) {
i := m.indexOf(id)
if i < 0 {
return InboxItemMeta{}, false
}
it := m.Items[i]
m.Items = append(m.Items[:i], m.Items[i+1:]...)
for key, itemID := range m.Idempotency {
if itemID == id {
delete(m.Idempotency, key)
delete(m.IdempotencyHashes, key)
}
}
return it, true
}
func (m *manifest) idempotencyKeysFor(id string) []string {
keys := make([]string, 0, 1)
for key, itemID := range m.Idempotency {
if itemID == id {
keys = append(keys, key)
}
}
return keys
}
func (m *manifest) rememberReceipt(keys []string, itemID string, disposition Disposition, now time.Time) {
if m.Receipts == nil {
m.Receipts = map[string]idempotencyReceipt{}
}
for _, key := range keys {
if strings.TrimSpace(key) == "" {
continue
}
m.Receipts[key] = idempotencyReceipt{
ItemID: itemID,
RequestHash: m.IdempotencyHashes[key],
Disposition: disposition,
CompletedAt: now,
}
}
m.pruneReceipts(now)
}
func (m *manifest) pruneReceipts(now time.Time) {
for key, receipt := range m.Receipts {
if receipt.CompletedAt.IsZero() || now.Sub(receipt.CompletedAt) > idempotencyReceiptTTL {
delete(m.Receipts, key)
}
}
for len(m.Receipts) > maxIdempotencyReceipts {
oldestKey := ""
var oldest time.Time
for key, receipt := range m.Receipts {
if oldestKey == "" || receipt.CompletedAt.Before(oldest) {
oldestKey = key
oldest = receipt.CompletedAt
}
}
delete(m.Receipts, oldestKey)
}
}
func decodeManifest(data []byte) (*manifest, error) {
var m manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
if m.Items == nil {
m.Items = []InboxItemMeta{}
}
if m.Idempotency == nil {
m.Idempotency = map[string]string{}
}
if m.IdempotencyHashes == nil {
m.IdempotencyHashes = map[string]string{}
}
if m.Receipts == nil {
m.Receipts = map[string]idempotencyReceipt{}
}
if err := validateManifest(&m, m.SchemaVersion > SchemaVersion); err != nil {
return nil, err
}
return &m, nil
}
func validateManifest(m *manifest, allowUnknownEnums bool) error {
if m == nil {
return fmt.Errorf("nil manifest")
}
if m.SchemaVersion < 0 || m.Revision < 0 {
return fmt.Errorf("invalid manifest version or revision")
}
ids := make(map[string]struct{}, len(m.Items))
for _, item := range m.Items {
if !validBlobStem(item.ID) || !validBlobStem(blobNameFor(item)) {
return fmt.Errorf("invalid inbox item path")
}
if _, exists := ids[item.ID]; exists {
return fmt.Errorf("duplicate inbox item id")
}
ids[item.ID] = struct{}{}
if item.ByteSize < 0 {
return fmt.Errorf("negative inbox item size")
}
if !allowUnknownEnums && (!validInboxIntent(item.Intent) || !validInboxState(item.State)) {
return fmt.Errorf("invalid inbox item intent or state")
}
if item.Checksum != "" && !validSHA256(item.Checksum) {
return fmt.Errorf("invalid inbox item checksum")
}
}
if allowUnknownEnums {
return nil
}
for key, id := range m.Idempotency {
if !validIdempotencyKey(key) {
return fmt.Errorf("invalid idempotency key")
}
if _, exists := ids[id]; !exists {
return fmt.Errorf("idempotency key references missing item")
}
hash := m.IdempotencyHashes[key]
if hash == "" {
if m.SchemaVersion >= 2 {
return fmt.Errorf("missing idempotency request hash")
}
} else if !validSHA256(hash) {
return fmt.Errorf("invalid idempotency request hash")
}
}
for key, receipt := range m.Receipts {
if !validIdempotencyKey(key) || !validBlobStem(receipt.ItemID) || !validSHA256(receipt.RequestHash) {
return fmt.Errorf("invalid idempotency receipt")
}
}
return nil
}
func validBlobStem(name string) bool {
if name == "" || strings.TrimSpace(name) != name || len(name) > 200 || name == "." || name == ".." || !filepath.IsLocal(name) || filepath.Base(name) != name {
return false
}
return !strings.ContainsAny(name, "/\\\x00")
}
func validIdempotencyKey(key string) bool {
return key != "" && strings.TrimSpace(key) == key && len(key) <= maxIdempotencyKeyBytes && !strings.ContainsRune(key, '\x00')
}
func validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, r := range value {
if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') {
return false
}
}
return true
}
func validInboxIntent(intent InboxIntent) bool {
return intent == IntentFollowup || intent == IntentSteer
}
func validInboxState(state InboxState) bool {
switch state {
case StateQueued, StateSteerAccepted, StateSteerConsumed, StateRunning, StateBlocked, StateUncertain:
return true
default:
return false
}
}
+67
View File
@@ -0,0 +1,67 @@
package sessioninbox
import "sync/atomic"
// Lightweight process-local counters (no message bodies). Telemetry sinks may
// scrape these; zero values mean the feature was unused.
var (
metricEnqueue atomic.Int64
metricEnqueueBytes atomic.Int64
metricRecovered atomic.Int64
metricPaused atomic.Int64
metricSteerAccepted atomic.Int64
metricSteerRejected atomic.Int64
metricCapacityRej atomic.Int64
metricUncertain atomic.Int64
metricTxFail atomic.Int64
)
// MetricsSnapshot is a body-free counters view for diagnostics.
type MetricsSnapshot struct {
Enqueue int64 `json:"enqueue"`
EnqueueBytes int64 `json:"enqueueBytes"`
Recovered int64 `json:"recovered"`
Paused int64 `json:"paused"`
SteerAccepted int64 `json:"steerAccepted"`
SteerRejected int64 `json:"steerRejected"`
CapacityReject int64 `json:"capacityReject"`
Uncertain int64 `json:"uncertain"`
TxFail int64 `json:"txFail"`
}
// Metrics returns current process-local inbox counters.
func Metrics() MetricsSnapshot {
return MetricsSnapshot{
Enqueue: metricEnqueue.Load(),
EnqueueBytes: metricEnqueueBytes.Load(),
Recovered: metricRecovered.Load(),
Paused: metricPaused.Load(),
SteerAccepted: metricSteerAccepted.Load(),
SteerRejected: metricSteerRejected.Load(),
CapacityReject: metricCapacityRej.Load(),
Uncertain: metricUncertain.Load(),
TxFail: metricTxFail.Load(),
}
}
// NoteEnqueue increments durable-enqueue counters (body length only, no text).
func NoteEnqueue(bytes int64) {
metricEnqueue.Add(1)
if bytes > 0 {
metricEnqueueBytes.Add(bytes)
}
}
// NoteRecovered records crash-recovery item counts.
func NoteRecovered(n int) {
if n > 0 {
metricRecovered.Add(int64(n))
}
}
func NotePaused() { metricPaused.Add(1) }
func NoteSteerAccepted() { metricSteerAccepted.Add(1) }
func NoteSteerRejected() { metricSteerRejected.Add(1) }
func NoteCapacityReject() { metricCapacityRej.Add(1) }
func NoteUncertain() { metricUncertain.Add(1) }
func NoteTxFail() { metricTxFail.Add(1) }
+449
View File
@@ -0,0 +1,449 @@
package sessioninbox
import (
"strings"
"time"
)
// DeleteItem removes metadata first, then the blob (crash may leave orphan).
func (s *Store) DeleteItem(id string) error {
if s == nil {
return ErrClosed
}
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
meta, ok := s.man.item(id)
if !ok {
return ErrNotFound
}
if !isPendingState(meta.State) {
return ErrInvalidState
}
next := s.man.clone()
keys := next.idempotencyKeysFor(id)
next.rememberReceipt(keys, id, Disposition("deleted"), time.Now().UTC())
removed, _ := next.removeItem(id)
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.removeBlobLocked(blobNameFor(removed))
s.notifyLocked(s.snapshotLocked())
return nil
}
// DiscardPendingItems removes the named, not-yet-admitted items in one
// manifest transaction. Missing IDs are treated as already consumed so a
// frontend may safely cancel from a slightly stale metadata snapshot. Items
// that have crossed the admission boundary are never deleted here.
func (s *Store) DiscardPendingItems(ids []string) error {
return s.DiscardPendingItemsOwned(ids, "")
}
// DiscardPendingItemsOwned atomically removes pending IDs belonging to source.
// Foreign-source IDs are ignored so one frontend cannot cancel another.
func (s *Store) DiscardPendingItemsOwned(ids []string, source string) error {
if s == nil {
return ErrClosed
}
wanted := make(map[string]struct{}, len(ids))
for _, id := range ids {
if id = strings.TrimSpace(id); id != "" {
wanted[id] = struct{}{}
}
}
if len(wanted) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
for _, item := range s.man.Items {
if _, ok := wanted[item.ID]; !ok {
continue
}
if source != "" && item.Source != source {
continue
}
switch item.State {
case StateQueued, StateBlocked, StateUncertain:
default:
return ErrInvalidState
}
}
next := s.man.clone()
removed := make([]InboxItemMeta, 0, len(wanted))
kept := next.Items[:0]
for _, item := range next.Items {
_, selected := wanted[item.ID]
owned := source == "" || item.Source == source
if selected && owned {
removed = append(removed, item)
continue
}
kept = append(kept, item)
}
if len(removed) == 0 {
return nil
}
next.Items = kept
now := time.Now().UTC()
for _, item := range removed {
keys := next.idempotencyKeysFor(item.ID)
next.rememberReceipt(keys, item.ID, Disposition("discarded"), now)
for _, key := range keys {
delete(next.Idempotency, key)
delete(next.IdempotencyHashes, key)
}
}
if err := s.commitManifestLocked(next); err != nil {
return err
}
for _, item := range removed {
s.removeBlobLocked(blobNameFor(item))
}
s.notifyLocked(s.snapshotLocked())
return nil
}
// MoveItem reorders the queue. toIndex is 0-based; values past the end append.
func (s *Store) MoveItem(id string, toIndex int) error {
if s == nil {
return ErrClosed
}
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
next := s.man.clone()
from := next.indexOf(id)
if from < 0 {
return ErrNotFound
}
if !isPendingState(next.Items[from].State) {
return ErrInvalidState
}
if toIndex < 0 {
toIndex = 0
}
if toIndex >= len(next.Items) {
toIndex = len(next.Items) - 1
}
if from == toIndex {
return nil
}
it := next.Items[from]
next.Items = append(next.Items[:from], next.Items[from+1:]...)
if toIndex > len(next.Items) {
toIndex = len(next.Items)
}
next.Items = append(next.Items[:toIndex], append([]InboxItemMeta{it}, next.Items[toIndex:]...)...)
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.notifyLocked(s.snapshotLocked())
return nil
}
// SetPaused toggles the recovery/inspection pause flag.
func (s *Store) SetPaused(paused bool) error {
if s == nil {
return ErrClosed
}
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
if s.man.Paused == paused {
return nil
}
next := s.man.clone()
next.Paused = paused
if !paused {
next.Recovered = false
next.RecoveredN = 0
}
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.notifyLocked(s.snapshotLocked())
return nil
}
// SetState transitions one item's durable state.
func (s *Store) SetState(id string, state InboxState, blockReason string) error {
if s == nil {
return ErrClosed
}
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
next := s.man.clone()
i := next.indexOf(id)
if i < 0 {
return ErrNotFound
}
next.Items[i].State = state
next.Items[i].BlockReason = blockReason
next.Items[i].UpdatedAt = time.Now().UTC()
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.notifyLocked(s.snapshotLocked())
return nil
}
// ClaimItem atomically transitions one queued item to running. It is the
// durable admission boundary for both asynchronous and synchronous frontends.
func (s *Store) ClaimItem(id string) error {
if s == nil {
return ErrClosed
}
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
if s.man.Paused {
return ErrPaused
}
next := s.man.clone()
i := next.indexOf(id)
if i < 0 {
return ErrNotFound
}
if next.Items[i].State != StateQueued {
return ErrInvalidState
}
next.Items[i].State = StateRunning
next.Items[i].BlockReason = ""
next.Items[i].UpdatedAt = time.Now().UTC()
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.notifyLocked(s.snapshotLocked())
return nil
}
// ConvertIntent changes followup ↔ steer while keeping the item queued.
func (s *Store) ConvertIntent(id string, intent InboxIntent) error {
if s == nil {
return ErrClosed
}
if intent != IntentSteer {
intent = IntentFollowup
}
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
next := s.man.clone()
i := next.indexOf(id)
if i < 0 {
return ErrNotFound
}
if !isPendingState(next.Items[i].State) {
return ErrInvalidState
}
next.Items[i].Intent = intent
next.Items[i].UpdatedAt = time.Now().UTC()
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.notifyLocked(s.snapshotLocked())
return nil
}
// AckDequeue removes a running/consumed item after durable transcript commit.
func (s *Store) AckDequeue(id string) error {
if s == nil {
return ErrClosed
}
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
next := s.man.clone()
keys := next.idempotencyKeysFor(id)
next.rememberReceipt(keys, id, Disposition("acknowledged"), time.Now().UTC())
removed, ok := next.removeItem(id)
if !ok {
return ErrNotFound
}
switch removed.State {
case StateRunning, StateSteerAccepted, StateSteerConsumed:
default:
return ErrInvalidState
}
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.removeBlobLocked(blobNameFor(removed))
s.notifyLocked(s.snapshotLocked())
return nil
}
// RetryItem resets uncertain/blocked items to queued.
func (s *Store) RetryItem(id string) error {
if s == nil {
return ErrClosed
}
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if err := s.mutableLocked(); err != nil {
return err
}
next := s.man.clone()
i := next.indexOf(id)
if i < 0 {
return ErrNotFound
}
switch next.Items[i].State {
case StateUncertain, StateBlocked:
next.Items[i].State = StateQueued
next.Items[i].BlockReason = ""
next.Items[i].UpdatedAt = time.Now().UTC()
default:
return ErrInvalidState
}
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.notifyLocked(s.snapshotLocked())
return nil
}
// NextQueued returns the first FIFO queued follow-up (or rejected steer kept as
// follow-up) when the inbox is not paused.
func (s *Store) NextQueued() (InboxItemMeta, bool) {
if s == nil {
return InboxItemMeta{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
if release, err := s.beginDiskTransactionLocked(); err == nil {
release()
}
if s.man == nil || s.man.Paused || s.readonly {
return InboxItemMeta{}, false
}
for _, it := range s.man.Items {
if it.State == StateQueued && it.Intent == IntentFollowup {
return it, true
}
// Rejected steers that remain intent=steer but queued are still follow-ups
// for the dispatcher after ConvertIntent; only followup intent is admitted.
}
// Also admit steer-intent items that are still queued (user wants them as turns).
for _, it := range s.man.Items {
if it.State == StateQueued {
return it, true
}
}
return InboxItemMeta{}, false
}
// Pause marks paused=true without requiring a mutation check beyond schema.
func (s *Store) ForcePause(reasonRecovered bool, n int) error {
if s == nil {
return ErrClosed
}
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
defer release()
if s.closed || s.readonly {
if s.readonly {
return nil
}
return ErrClosed
}
next := s.man.clone()
next.Paused = true
if reasonRecovered {
next.Recovered = true
if n > 0 {
next.RecoveredN = n
}
}
if err := s.commitManifestLocked(next); err != nil {
return err
}
s.notifyLocked(s.snapshotLocked())
return nil
}
func isPendingState(state InboxState) bool {
switch state {
case StateQueued, StateBlocked, StateUncertain:
return true
default:
return false
}
}
+250
View File
@@ -0,0 +1,250 @@
package sessioninbox
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
const maxFrozenDirEntries = 100
// FreezeRefs snapshots explicit workspace paths without following symlinks.
// Controller-owned enqueue uses its typed resolver; this remains for old/API
// callers that provide an explicit path list.
func FreezeRefs(ctx context.Context, workspace string, paths []string) ([]RefSnapshot, error) {
_ = ctx
rootPath, err := filepath.Abs(strings.TrimSpace(workspace))
if err != nil || strings.TrimSpace(workspace) == "" {
return nil, fmt.Errorf("path requires workspace root")
}
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open workspace root: %w", err)
}
defer root.Close()
out := make([]RefSnapshot, 0, len(paths))
seen := map[string]struct{}{}
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
snapshot, freezeErr := freezeScopedRef(root, rootPath, path)
if freezeErr != nil {
snapshot = RefSnapshot{
Kind: "frozen",
Path: path,
DisplayPath: path,
Content: fmt.Appendf(nil, "/* ref freeze failed: %v */", freezeErr),
}
}
out = append(out, snapshot)
}
return out, nil
}
func freezeScopedRef(root *os.Root, rootPath, path string) (RefSnapshot, error) {
rel, err := scopedRelativePath(rootPath, path)
if err != nil {
return RefSnapshot{}, err
}
info, err := root.Lstat(rel)
if err != nil {
return RefSnapshot{}, err
}
if info.Mode()&os.ModeSymlink != 0 {
return RefSnapshot{}, fmt.Errorf("path escapes workspace through symlink: %s", path)
}
if info.IsDir() {
content, err := freezeDirectory(root, rel)
if err != nil {
return RefSnapshot{}, err
}
return frozenSnapshot(rel, content, false), nil
}
file, err := root.Open(rel)
if err != nil {
return RefSnapshot{}, err
}
defer file.Close()
opened, err := file.Stat()
if err != nil {
return RefSnapshot{}, err
}
if !os.SameFile(info, opened) {
return RefSnapshot{}, fmt.Errorf("reference changed while opening: %s", path)
}
data, err := io.ReadAll(io.LimitReader(file, DefaultMaxItemBytes+1))
if err != nil {
return RefSnapshot{}, err
}
truncated := len(data) > DefaultMaxItemBytes
if truncated {
data = data[:DefaultMaxItemBytes]
}
after, err := file.Stat()
if err != nil || !os.SameFile(opened, after) || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) {
return RefSnapshot{}, fmt.Errorf("reference changed while reading: %s", path)
}
return frozenSnapshot(rel, data, truncated), nil
}
func scopedRelativePath(rootPath, path string) (string, error) {
candidate := filepath.Clean(path)
if filepath.IsAbs(candidate) {
rel, err := filepath.Rel(rootPath, candidate)
if err != nil || !filepath.IsLocal(rel) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
candidate = rel
}
if !filepath.IsLocal(candidate) {
return "", fmt.Errorf("path outside workspace: %s", path)
}
return candidate, nil
}
func freezeDirectory(root *os.Root, rel string) ([]byte, error) {
var entries []string
if err := walkFrozenDirectory(root, rel, rel, &entries); err != nil {
return nil, err
}
sort.Strings(entries)
if len(entries) > maxFrozenDirEntries {
entries = append(entries[:maxFrozenDirEntries], "…[truncated; directory has more entries]…")
}
return []byte(strings.Join(entries, "\n")), nil
}
func walkFrozenDirectory(root *os.Root, dir, base string, entries *[]string) error {
if len(*entries) > maxFrozenDirEntries {
return nil
}
before, err := root.Lstat(dir)
if err != nil {
return err
}
if before.Mode()&os.ModeSymlink != 0 || !before.IsDir() {
return fmt.Errorf("directory reference changed while opening: %s", dir)
}
opened, err := root.Open(dir)
if err != nil {
return err
}
after, err := opened.Stat()
if err != nil || !os.SameFile(before, after) {
opened.Close()
return fmt.Errorf("directory reference changed while opening: %s", dir)
}
children, err := opened.ReadDir(-1)
opened.Close()
if err != nil {
return err
}
for _, child := range children {
path := filepath.Join(dir, child.Name())
info, err := root.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || skipFrozenDirEntry(child.Name(), info.IsDir()) {
continue
}
display, relErr := filepath.Rel(base, path)
if relErr != nil || !filepath.IsLocal(display) {
return fmt.Errorf("directory entry escapes reference root")
}
display = filepath.ToSlash(display)
if info.IsDir() {
*entries = append(*entries, display+"/")
if err := walkFrozenDirectory(root, path, base, entries); err != nil {
return err
}
} else {
*entries = append(*entries, display)
}
if len(*entries) > maxFrozenDirEntries {
return nil
}
}
return nil
}
func skipFrozenDirEntry(name string, isDir bool) bool {
if name == ".DS_Store" || name == "Thumbs.db" {
return true
}
if !isDir {
return false
}
switch name {
case ".git", ".idea", ".vscode", "build", "dist", "node_modules", "__pycache__":
return true
default:
return false
}
}
func frozenSnapshot(path string, content []byte, truncated bool) RefSnapshot {
sum := sha256.Sum256(content)
path = filepath.ToSlash(path)
return RefSnapshot{
Kind: "frozen",
Path: path,
DisplayPath: path,
Content: content,
ContentSHA: hex.EncodeToString(sum[:]),
Truncated: truncated,
}
}
// ApplyFrozenRefs keeps legacy version-1 blobs readable. New entries persist
// the exact typed reference block in PromptEnvelope.FrozenRefBlock.
func ApplyFrozenRefs(submit string, bodies map[string]string) string {
if len(bodies) == 0 {
return submit
}
paths := make([]string, 0, len(bodies))
for path := range bodies {
paths = append(paths, path)
}
sort.Strings(paths)
var b strings.Builder
b.WriteString(submit)
b.WriteString("\n\n<!-- frozen inbox references (enqueue-time snapshot) -->\n")
for _, path := range paths {
fmt.Fprintf(&b, "\n### @%s\n```\n%s\n```\n", path, bodies[path])
}
return b.String()
}
// MaterializeRefs validates stored legacy snapshots without reading live paths.
// Clean-git entries from pre-fix blobs are paused until the user refreshes them.
func MaterializeRefs(_ context.Context, _ string, refs []RefSnapshot) (string, map[string]string, error) {
bodies := make(map[string]string, len(refs))
for _, ref := range refs {
if ref.Kind == "clean_git" {
return fmt.Sprintf("legacy clean-git reference %s requires refresh", ref.Path), bodies, nil
}
if ref.ContentSHA != "" {
sum := sha256.Sum256(ref.Content)
if hex.EncodeToString(sum[:]) != ref.ContentSHA {
return fmt.Sprintf("checksum mismatch for frozen ref %s", ref.Path), bodies, nil
}
}
bodies[firstNonEmpty(ref.DisplayPath, ref.Path)] = string(ref.Content)
}
return "", bodies, nil
}
+511
View File
@@ -0,0 +1,511 @@
package sessioninbox
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"reasonix/internal/filelock"
"reasonix/internal/store"
)
const (
manifestName = "manifest.json"
blobsDirName = "blobs"
quarantineName = "quarantine"
blobSuffix = ".json"
diskLockName = "transaction.lock"
diskLockWait = 5 * time.Second
maxManifestBytes = 8 << 20
)
// Store is the transactional durable inbox for one session path.
// Disk I/O runs under store.mu only; callers must not hold Controller locks.
type Store struct {
mu sync.Mutex
dir string
session string // session transcript path
runID string
limits Limits
man *manifest
readonly bool
closed bool
// listeners receive revision bumps after durable commits (non-blocking).
listeners []func(InboxSnapshot)
}
// Open binds a Store to the session's inbox directory. The directory is created
// for its transaction lock; body blobs remain lazy. Cross-process recovery
// marks uncertain items and pauses.
func Open(sessionPath string, limits Limits) (*Store, error) {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return nil, fmt.Errorf("sessioninbox: empty session path")
}
dir := store.SessionInboxDir(sessionPath)
s := &Store{
dir: dir,
session: sessionPath,
runID: ProcessRunID(),
limits: limits.withDefaults(),
man: emptyManifest(ProcessRunID()),
}
if err := s.loadOrInit(); err != nil {
return nil, err
}
return s, nil
}
// Dir returns the on-disk inbox directory.
func (s *Store) Dir() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
return s.dir
}
// SessionPath returns the bound session transcript path.
func (s *Store) SessionPath() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
return s.session
}
// Rebind moves the store to a new session path without copying future work
// (used after rename migration that already relocated the directory).
func (s *Store) Rebind(sessionPath string) error {
if s == nil {
return ErrClosed
}
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return fmt.Errorf("sessioninbox: empty session path")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return ErrClosed
}
s.session = sessionPath
s.dir = store.SessionInboxDir(sessionPath)
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
release()
return nil
}
// Close seals the store. Further mutations fail with ErrClosed.
func (s *Store) Close() {
if s == nil {
return
}
s.mu.Lock()
s.closed = true
s.mu.Unlock()
}
// OnChange registers a non-blocking snapshot listener.
func (s *Store) OnChange(fn func(InboxSnapshot)) {
if s == nil || fn == nil {
return
}
s.mu.Lock()
s.listeners = append(s.listeners, fn)
s.mu.Unlock()
}
func (s *Store) loadOrInit() error {
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return err
}
release()
return nil
}
// beginDiskTransactionLocked serializes every manifest read/modify/write with
// other Store instances and processes, then refreshes the in-memory snapshot.
// The caller must hold s.mu and call the returned release function.
func (s *Store) beginDiskTransactionLocked() (func(), error) {
if err := ensurePrivateDir(s.dir); err != nil {
return nil, fmt.Errorf("sessioninbox: create inbox directory: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), diskLockWait)
defer cancel()
release, err := filelock.Acquire(ctx, filepath.Join(s.dir, diskLockName))
if err != nil {
return nil, fmt.Errorf("sessioninbox: acquire disk lock: %w", err)
}
if err := s.loadOrInitLocked(); err != nil {
release()
return nil, err
}
return release, nil
}
func (s *Store) loadOrInitLocked() error {
path := filepath.Join(s.dir, manifestName)
data, err := readRegularFile(path, maxManifestBytes)
if errors.Is(err, os.ErrNotExist) {
s.man = emptyManifest(s.runID)
s.readonly = false
return nil
}
if err != nil {
return fmt.Errorf("sessioninbox: read manifest: %w", err)
}
man, err := decodeManifest(data)
if err != nil {
// Corrupt manifest → quarantine, salvage orphan blobs as uncertain
// items, pause for user inspection. Never present "0 recovered".
_ = s.quarantineFileLocked(path, "manifest-corrupt")
salvaged := s.salvageOrphanBlobsLocked()
s.man = emptyManifest(s.runID)
s.man.Paused = true
s.man.Recovered = true
s.man.RecoveredN = len(salvaged)
s.man.Items = salvaged
return s.commitManifestLocked(s.man)
}
if man.SchemaVersion > SchemaVersion {
s.man = man
s.readonly = true
s.man.Paused = true
return nil
}
migrated := man.SchemaVersion < SchemaVersion
if migrated {
for key, id := range man.Idempotency {
if man.IdempotencyHashes[key] != "" {
continue
}
meta, ok := man.item(id)
if !ok {
return fmt.Errorf("sessioninbox: migrate idempotency target: %w", ErrNotFound)
}
env, err := s.readBlobLocked(blobNameFor(meta), meta.Checksum)
if err != nil {
return fmt.Errorf("sessioninbox: migrate idempotency body: %w", err)
}
hash, err := idempotencyRequestHash(env)
if err != nil {
return fmt.Errorf("sessioninbox: migrate idempotency hash: %w", err)
}
man.IdempotencyHashes[key] = hash
}
man.SchemaVersion = SchemaVersion
}
// Cross-process recovery: another run left in-flight items.
recovered := 0
if man.RunID != "" && man.RunID != s.runID {
for i := range man.Items {
switch man.Items[i].State {
case StateRunning, StateSteerAccepted, StateSteerConsumed:
man.Items[i].State = StateUncertain
man.Items[i].UpdatedAt = time.Now().UTC()
recovered++
case StateQueued, StateBlocked, StateUncertain:
recovered++
}
}
if recovered > 0 || len(man.Items) > 0 {
man.Paused = true
man.Recovered = true
man.RecoveredN = recovered
}
}
man.RunID = s.runID
s.man = man
s.readonly = false
if recovered > 0 || migrated {
return s.commitManifestLocked(man)
}
// GC orphan blobs without holding callers longer than needed.
s.gcOrphansLocked()
return nil
}
// Snapshot returns a copy of current metadata.
func (s *Store) Snapshot() InboxSnapshot {
if s == nil {
return InboxSnapshot{}
}
s.mu.Lock()
defer s.mu.Unlock()
if release, err := s.beginDiskTransactionLocked(); err == nil {
release()
}
return s.snapshotLocked()
}
func (s *Store) snapshotLocked() InboxSnapshot {
m := s.man
if m == nil {
m = emptyManifest(s.runID)
}
items := append([]InboxItemMeta(nil), m.Items...)
return InboxSnapshot{
SchemaVersion: m.SchemaVersion,
Revision: m.Revision,
Paused: m.Paused,
Recovered: m.Recovered,
RecoveredN: m.RecoveredN,
Readonly: s.readonly,
RunID: m.RunID,
Items: items,
Capacity: Capacity{
Items: len(items),
MaxItems: s.limits.MaxItems,
Bytes: m.totalBytes(),
MaxBytes: s.limits.MaxTotalBytes,
MaxItemBytes: s.limits.MaxItemBytes,
},
}
}
// Enqueue durably appends an item. Only returns a receipt after blob+manifest
// commit succeed. Idempotent keys return the original item.
func (s *Store) Enqueue(req EnqueueRequest) (InboxReceipt, error) {
if s == nil {
return InboxReceipt{}, ErrClosed
}
env := completeEnqueueEnvelope(req.Envelope)
hasInvocation := env.Invocation != nil || len(env.Invocations) > 0
if strings.TrimSpace(env.SubmitText) == "" && strings.TrimSpace(env.DisplayText) == "" && strings.TrimSpace(env.RawText) == "" && !hasInvocation {
return InboxReceipt{}, ErrEmpty
}
intent := req.Intent
if intent != IntentSteer {
intent = IntentFollowup
}
idem := strings.TrimSpace(firstNonEmpty(req.Idempotency, env.Idempotency))
if idem != "" && !validIdempotencyKey(idem) {
return InboxReceipt{}, fmt.Errorf("sessioninbox: invalid idempotency key")
}
source := strings.TrimSpace(firstNonEmpty(req.Source, env.Source))
blobBytes, checksum, byteSize, err := encodeEnvelope(env)
if err != nil {
return InboxReceipt{}, err
}
requestHash, err := idempotencyRequestHash(env)
if err != nil {
return InboxReceipt{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return InboxReceipt{}, err
}
defer release()
if s.closed {
return InboxReceipt{}, ErrClosed
}
if s.readonly {
return InboxReceipt{}, ErrSchemaReadonly
}
if receipt, found, err := s.idempotentReceiptLocked(idem, requestHash); err != nil || found {
return receipt, err
}
if byteSize > s.limits.MaxItemBytes {
return InboxReceipt{}, ErrItemTooLarge
}
if len(s.man.Items) >= s.limits.MaxItems {
return InboxReceipt{}, ErrCapacityItems
}
if s.man.totalBytes()+byteSize > s.limits.MaxTotalBytes {
return InboxReceipt{}, ErrCapacityBytes
}
id := newRandomID()
blobName := id
now := time.Now().UTC()
meta := InboxItemMeta{
ID: id,
SessionID: firstNonEmpty(req.SessionID, agentBranchID(s.session)),
Intent: intent,
State: StateQueued,
Revision: s.man.Revision + 1,
BlobName: blobName,
Source: source,
CreatedAt: now,
UpdatedAt: now,
Preview: PreviewText(env.DisplayText, DefaultPreviewRunes),
ByteSize: byteSize,
Checksum: checksum,
Idempotency: idem,
Refs: refSummaries(env.Refs),
RunID: s.runID,
}
// Transaction: write blob → commit manifest → receipt.
if err := s.writeBlobLocked(blobName, blobBytes); err != nil {
return InboxReceipt{}, err
}
next := s.man.clone()
next.Items = append(next.Items, meta)
bindIdempotency(next, idem, id, requestHash)
if err := s.commitManifestLocked(next); err != nil {
s.removeBlobLocked(blobName)
return InboxReceipt{}, err
}
snap := s.snapshotLocked()
s.notifyLocked(snap)
return InboxReceipt{
ItemID: id,
Disposition: DispositionQueuedFollowup,
Position: len(next.Items),
Paused: next.Paused,
Capacity: snap.Capacity,
}, nil
}
// ReadItem loads a full PromptEnvelope by ID.
func (s *Store) ReadItem(id string) (InboxItemMeta, PromptEnvelope, error) {
if s == nil {
return InboxItemMeta{}, PromptEnvelope{}, ErrClosed
}
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return InboxItemMeta{}, PromptEnvelope{}, err
}
defer release()
if s.closed {
return InboxItemMeta{}, PromptEnvelope{}, ErrClosed
}
meta, ok := s.man.item(id)
if !ok {
return InboxItemMeta{}, PromptEnvelope{}, ErrNotFound
}
env, err := s.readBlobLocked(blobNameFor(meta), meta.Checksum)
if err != nil {
return meta, PromptEnvelope{}, err
}
return meta, env, nil
}
// UpdateItem writes a new immutable blob, switches the manifest pointer, then
// deletes the old blob. Pre-commit crashes leave only a GC-able orphan; after
// commit the checksum always points at the new body.
func (s *Store) UpdateItem(id string, env PromptEnvelope) (InboxItemMeta, error) {
return s.UpdateItemWithIdempotency(id, env, "", PromptEnvelope{})
}
// UpdateItemWithIdempotency atomically updates an item and optionally binds an
// additional client idempotency key to it. aliasEnvelope is the original client
// request, not the merged body, so collect-mode redelivery remains deduplicated.
func (s *Store) UpdateItemWithIdempotency(id string, env PromptEnvelope, alias string, aliasEnvelope PromptEnvelope) (InboxItemMeta, error) {
if s == nil {
return InboxItemMeta{}, ErrClosed
}
id = strings.TrimSpace(id)
alias = strings.TrimSpace(alias)
if alias != "" && !validIdempotencyKey(alias) {
return InboxItemMeta{}, fmt.Errorf("sessioninbox: invalid idempotency key")
}
env = normalizeEnvelope(env)
if strings.TrimSpace(env.SubmitText) == "" && env.Invocation == nil && len(env.Invocations) == 0 {
return InboxItemMeta{}, ErrEmpty
}
blobBytes, checksum, byteSize, err := encodeEnvelope(env)
if err != nil {
return InboxItemMeta{}, err
}
aliasHash := ""
if alias != "" {
aliasEnvelope = completeEnqueueEnvelope(aliasEnvelope)
aliasHash, err = idempotencyRequestHash(aliasEnvelope)
if err != nil {
return InboxItemMeta{}, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
release, err := s.beginDiskTransactionLocked()
if err != nil {
return InboxItemMeta{}, err
}
defer release()
if err := s.mutableLocked(); err != nil {
return InboxItemMeta{}, err
}
meta, ok := s.man.item(id)
if !ok {
return InboxItemMeta{}, ErrNotFound
}
if !isPendingState(meta.State) {
return InboxItemMeta{}, ErrInvalidState
}
replayed, err := s.idempotentAliasReplayLocked(alias, aliasHash, id)
if err != nil {
return InboxItemMeta{}, err
}
if replayed {
return meta, nil
}
if byteSize > s.limits.MaxItemBytes {
return InboxItemMeta{}, ErrItemTooLarge
}
delta := byteSize - meta.ByteSize
if s.man.totalBytes()+delta > s.limits.MaxTotalBytes {
return InboxItemMeta{}, ErrCapacityBytes
}
oldBlob := blobNameFor(meta)
newBlob := id + "." + newRandomID()
if err := s.writeBlobLocked(newBlob, blobBytes); err != nil {
return InboxItemMeta{}, err
}
next := s.man.clone()
i := next.indexOf(id)
next.Items[i].BlobName = newBlob
next.Items[i].ByteSize = byteSize
next.Items[i].Checksum = checksum
next.Items[i].Preview = PreviewText(env.DisplayText, DefaultPreviewRunes)
next.Items[i].Refs = refSummaries(env.Refs)
next.Items[i].UpdatedAt = time.Now().UTC()
next.Items[i].Revision = next.Revision + 1
bindIdempotency(next, alias, id, aliasHash)
if next.Items[i].State == StateBlocked {
next.Items[i].State = StateQueued
next.Items[i].BlockReason = ""
}
if err := s.commitManifestLocked(next); err != nil {
s.removeBlobLocked(newBlob)
return InboxItemMeta{}, err
}
if oldBlob != newBlob {
s.removeBlobLocked(oldBlob)
}
updated := next.Items[i]
s.notifyLocked(s.snapshotLocked())
return updated, nil
}
func (s *Store) removeBlobLocked(blobName string) {
if err := validatePrivateDir(filepath.Join(s.dir, blobsDirName)); err != nil {
return
}
path, err := s.blobPath(blobName)
if err == nil {
_ = os.Remove(path)
}
}
+798
View File
@@ -0,0 +1,798 @@
package sessioninbox
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"reasonix/internal/fileutil"
"reasonix/internal/store"
)
func TestEnqueueSnapshotAndRead(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
if err := os.WriteFile(session, []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
rec, err := s.Enqueue(EnqueueRequest{
Intent: IntentFollowup,
Envelope: PromptEnvelope{
DisplayText: "hello world",
SubmitText: "hello world",
},
Source: "test",
})
if err != nil {
t.Fatal(err)
}
if rec.ItemID == "" || rec.Position != 1 {
t.Fatalf("receipt = %+v", rec)
}
snap := s.Snapshot()
if len(snap.Items) != 1 || snap.Items[0].Preview == "" {
t.Fatalf("snapshot = %+v", snap)
}
// Body must not appear in snapshot metadata beyond preview.
if strings.Contains(snap.Items[0].Preview, "\x00") {
t.Fatal("unexpected binary in preview")
}
meta, env, err := s.ReadItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if meta.ID != rec.ItemID || env.SubmitText != "hello world" {
t.Fatalf("read = meta=%+v env=%+v", meta, env)
}
// Permissions: dir 0700, blob 0600.
info, err := os.Stat(store.SessionInboxDir(session))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o700 {
t.Fatalf("inbox dir perm = %o", info.Mode().Perm())
}
}
func TestIdempotentEnqueue(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
a, err := s.Enqueue(EnqueueRequest{
Intent: IntentFollowup,
Envelope: PromptEnvelope{SubmitText: "x"},
Idempotency: "msg-1",
})
if err != nil {
t.Fatal(err)
}
b, err := s.Enqueue(EnqueueRequest{
Intent: IntentFollowup,
Envelope: PromptEnvelope{SubmitText: "x"},
Idempotency: "msg-1",
})
if err != nil {
t.Fatal(err)
}
if a.ItemID != b.ItemID || !b.Idempotent {
t.Fatalf("idempotency failed: a=%+v b=%+v", a, b)
}
if len(s.Snapshot().Items) != 1 {
t.Fatalf("want 1 item, got %d", len(s.Snapshot().Items))
}
}
func TestIdempotencyConflictRejectsDifferentInput(t *testing.T) {
session := filepath.Join(t.TempDir(), "s.jsonl")
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "first"}, Idempotency: "msg-1"}); err != nil {
t.Fatal(err)
}
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "different"}, Idempotency: "msg-1"}); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("different input error = %v, want ErrIdempotencyConflict", err)
}
}
func TestIdempotencyHashTreatsLegacyAndModernInvocationAsEquivalent(t *testing.T) {
legacy, err := idempotencyRequestHash(completeEnqueueEnvelope(PromptEnvelope{
DisplayText: "/init", Invocation: &StructuredInvocation{Name: "init"},
}))
if err != nil {
t.Fatal(err)
}
modern, err := idempotencyRequestHash(completeEnqueueEnvelope(PromptEnvelope{
DisplayText: "/init", Invocations: []StructuredInvocation{{Name: "init", Kind: "skill", Offset: 7}},
}))
if err != nil {
t.Fatal(err)
}
if legacy != modern {
t.Fatalf("legacy hash %q != modern hash %q", legacy, modern)
}
}
func TestIdempotencyReceiptSurvivesAckAndReopen(t *testing.T) {
session := filepath.Join(t.TempDir(), "s.jsonl")
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
first, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "write once"}, Idempotency: "msg-1"})
if err != nil {
t.Fatal(err)
}
if err := s.ClaimItem(first.ItemID); err != nil {
t.Fatal(err)
}
if err := s.AckDequeue(first.ItemID); err != nil {
t.Fatal(err)
}
s.Close()
reopened, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
retry, err := reopened.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "write once"}, Idempotency: "msg-1"})
if err != nil {
t.Fatal(err)
}
if !retry.Idempotent || retry.ItemID != first.ItemID || len(reopened.Snapshot().Items) != 0 {
t.Fatalf("completed retry = %+v items=%+v", retry, reopened.Snapshot().Items)
}
if _, err := reopened.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "write twice"}, Idempotency: "msg-1"}); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("completed conflicting retry error = %v", err)
}
}
func TestCollectAliasReceiptSurvivesAck(t *testing.T) {
session := filepath.Join(t.TempDir(), "s.jsonl")
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
first, err := s.Enqueue(EnqueueRequest{
Envelope: PromptEnvelope{SubmitText: "first"}, Idempotency: "msg-1",
})
if err != nil {
t.Fatal(err)
}
secondRequest := PromptEnvelope{SubmitText: "second", Source: "bot", Extra: map[string]string{"route": "chat-1"}}
if _, err := s.UpdateItemWithIdempotency(
first.ItemID,
PromptEnvelope{SubmitText: "first\nsecond"},
"msg-2",
secondRequest,
); err != nil {
t.Fatal(err)
}
if err := s.ClaimItem(first.ItemID); err != nil {
t.Fatal(err)
}
if err := s.AckDequeue(first.ItemID); err != nil {
t.Fatal(err)
}
retry, err := s.Enqueue(EnqueueRequest{Envelope: secondRequest, Idempotency: "msg-2"})
if err != nil {
t.Fatal(err)
}
if !retry.Idempotent || retry.ItemID != first.ItemID || len(s.Snapshot().Items) != 0 {
t.Fatalf("completed collect replay = %+v snapshot=%+v", retry, s.Snapshot())
}
}
func TestV1ManifestMigratesIdempotencyFingerprint(t *testing.T) {
session := filepath.Join(t.TempDir(), "s.jsonl")
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
first, err := s.Enqueue(EnqueueRequest{
Envelope: PromptEnvelope{SubmitText: "legacy durable input"}, Idempotency: "legacy-msg-1",
})
if err != nil {
t.Fatal(err)
}
s.Close()
manifestPath := filepath.Join(store.SessionInboxDir(session), manifestName)
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
var legacy map[string]any
if err := json.Unmarshal(data, &legacy); err != nil {
t.Fatal(err)
}
legacy["schemaVersion"] = float64(1)
delete(legacy, "idempotencyHashes")
delete(legacy, "receipts")
data, err = json.MarshalIndent(legacy, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(manifestPath, append(data, '\n'), 0o600); err != nil {
t.Fatal(err)
}
reopened, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
retry, err := reopened.Enqueue(EnqueueRequest{
Envelope: PromptEnvelope{SubmitText: "legacy durable input"}, Idempotency: "legacy-msg-1",
})
if err != nil {
t.Fatal(err)
}
if !retry.Idempotent || retry.ItemID != first.ItemID {
t.Fatalf("migrated replay = %+v, first = %+v", retry, first)
}
if reopened.man.SchemaVersion != SchemaVersion || !validSHA256(reopened.man.IdempotencyHashes["legacy-msg-1"]) {
t.Fatalf("manifest was not upgraded with fingerprint: %+v", reopened.man)
}
}
func TestManifestBlobPathEscapeIsQuarantinedWithoutTouchingTarget(t *testing.T) {
session := filepath.Join(t.TempDir(), "s.jsonl")
inboxDir := store.SessionInboxDir(session)
if err := os.MkdirAll(filepath.Join(inboxDir, blobsDirName), 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(inboxDir, "outside.json")
if err := os.WriteFile(target, []byte("do-not-touch"), 0o600); err != nil {
t.Fatal(err)
}
bad := manifest{
SchemaVersion: SchemaVersion,
RunID: ProcessRunID(),
Items: []InboxItemMeta{{
ID: newRandomID(),
BlobName: "../outside",
Intent: IntentFollowup,
State: StateQueued,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}},
}
data, err := json.Marshal(bad)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(inboxDir, manifestName), data, 0o600); err != nil {
t.Fatal(err)
}
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
if snap := s.Snapshot(); !snap.Paused || !snap.Recovered || len(snap.Items) != 0 {
t.Fatalf("invalid manifest was not quarantined: %+v", snap)
}
got, err := os.ReadFile(target)
if err != nil || string(got) != "do-not-touch" {
t.Fatalf("path escape target changed: %q err=%v", got, err)
}
if _, err := s.blobPath("../outside"); err == nil {
t.Fatal("blobPath accepted a parent traversal")
}
}
func TestValidateManifestRejectsSemanticCorruption(t *testing.T) {
id := newRandomID()
base := InboxItemMeta{
ID: id, BlobName: id, Intent: IntentFollowup, State: StateQueued,
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
}
for name, mutate := range map[string]func(*manifest){
"negative size": func(m *manifest) { m.Items[0].ByteSize = -1 },
"duplicate id": func(m *manifest) { m.Items = append(m.Items, m.Items[0]) },
"invalid state": func(m *manifest) { m.Items[0].State = InboxState("mystery") },
"orphan idempotency": func(m *manifest) {
m.Idempotency["msg-1"] = newRandomID()
m.IdempotencyHashes["msg-1"] = strings.Repeat("a", 64)
},
} {
t.Run(name, func(t *testing.T) {
m := emptyManifest(ProcessRunID())
m.Items = []InboxItemMeta{base}
mutate(m)
if err := validateManifest(m, false); err == nil {
t.Fatalf("semantic corruption %q was accepted", name)
}
})
}
}
func TestStoreInstancesReloadManifestBeforeMutation(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
first, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer first.Close()
second, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer second.Close()
a, err := first.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "from first"}})
if err != nil {
t.Fatal(err)
}
b, err := second.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "from second"}})
if err != nil {
t.Fatal(err)
}
items := first.Snapshot().Items
if len(items) != 2 || items[0].ID != a.ItemID || items[1].ID != b.ItemID {
t.Fatalf("cross-store writes lost or reordered an item: %+v", items)
}
}
func TestCapacityLimits(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{MaxItems: 2, MaxItemBytes: 200, MaxTotalBytes: 1 << 20})
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: strings.Repeat("a", 400)}}); !errors.Is(err, ErrItemTooLarge) {
t.Fatalf("item too large: %v", err)
}
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "one"}}); err != nil {
t.Fatal(err)
}
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "two"}}); err != nil {
t.Fatal(err)
}
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "three"}}); !errors.Is(err, ErrCapacityItems) {
t.Fatalf("cap items: %v", err)
}
}
func TestDeleteThenBlobGone(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
rec, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "bye"}})
if err := s.DeleteItem(rec.ItemID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(store.SessionInboxDir(session), "blobs", rec.ItemID+".json")); !os.IsNotExist(err) {
t.Fatalf("blob should be gone, err=%v", err)
}
}
func TestCrashAfterBlobBeforeManifestLeavesNoValidItem(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
// Inject crash after blob rename, before manifest commit.
fileutil.CrashPoint = func(op, path string) {
if op == "inbox-manifest-write" {
panic("inject crash before manifest")
}
}
t.Cleanup(func() { fileutil.CrashPoint = nil })
func() {
defer func() { _ = recover() }()
_, _ = s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "orphan"}})
}()
fileutil.CrashPoint = nil
// Re-open: no valid items; orphan blob may exist and is GC'd/quarantined.
s2, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if n := len(s2.Snapshot().Items); n != 0 {
t.Fatalf("want 0 valid items after crash, got %d", n)
}
}
func TestUpdateCrashPointsPreserveCompleteRevision(t *testing.T) {
tests := []struct {
crashOp string
want string
}{
{crashOp: "inbox-blob-write", want: "old body"},
{crashOp: "inbox-blob-rename", want: "old body"},
{crashOp: "inbox-manifest-write", want: "old body"},
{crashOp: "inbox-manifest-commit", want: "new body"},
}
for _, tt := range tests {
t.Run(tt.crashOp, func(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
rec, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "old body"}})
if err != nil {
t.Fatal(err)
}
fileutil.CrashPoint = func(op, _ string) {
if op == tt.crashOp {
panic("injected update crash")
}
}
func() {
defer func() { _ = recover() }()
_, _ = s.UpdateItem(rec.ItemID, PromptEnvelope{SubmitText: "new body"})
}()
fileutil.CrashPoint = nil
s.Close()
reopened, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
_, env, err := reopened.ReadItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
if env.SubmitText != tt.want {
t.Fatalf("recovered body = %q, want %q", env.SubmitText, tt.want)
}
})
}
t.Cleanup(func() { fileutil.CrashPoint = nil })
}
func TestCrossProcessRecoveryPauses(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
rec, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "work"}})
if err != nil {
t.Fatal(err)
}
if err := s.SetState(rec.ItemID, StateRunning, ""); err != nil {
t.Fatal(err)
}
s.Close()
// Simulate another process by rewriting runID in a fresh Open with different ProcessRunID.
// Open always uses ProcessRunID(); force recovery by editing manifest runId.
manPath := filepath.Join(store.SessionInboxDir(session), "manifest.json")
data, _ := os.ReadFile(manPath)
data = []byte(strings.Replace(string(data), ProcessRunID(), "other-run-id-0000", 1))
_ = os.WriteFile(manPath, data, 0o600)
s2, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s2.Close()
snap := s2.Snapshot()
if !snap.Paused || !snap.Recovered {
t.Fatalf("want paused+recovered, got %+v", snap)
}
if len(snap.Items) != 1 || snap.Items[0].State != StateUncertain {
t.Fatalf("want uncertain item, got %+v", snap.Items)
}
}
func TestPreviewDoesNotMaterializeHugeBody(t *testing.T) {
huge := strings.Repeat("x", 1<<20)
p := PreviewText(huge, 40)
if len(p) > 80 {
t.Fatalf("preview too long: %d", len(p))
}
if !strings.HasSuffix(p, "…") {
t.Fatalf("want ellipsis, got %q", p)
}
}
func TestUpdateUsesImmutableBlobRevision(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
rec, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "original"}})
if err != nil {
t.Fatal(err)
}
meta, _, err := s.ReadItem(rec.ItemID)
if err != nil {
t.Fatal(err)
}
oldBlob := blobNameFor(meta)
updated, err := s.UpdateItem(rec.ItemID, PromptEnvelope{SubmitText: "revised"})
if err != nil {
t.Fatal(err)
}
if blobNameFor(updated) == oldBlob {
t.Fatal("update must write a new blob name, not overwrite in place")
}
oldPath, err := s.blobPath(oldBlob)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old blob should be removed after successful update, err=%v", err)
}
_, env, err := s.ReadItem(rec.ItemID)
if err != nil || env.SubmitText != "revised" {
t.Fatalf("read after update = %+v err=%v", env, err)
}
}
func TestCorruptManifestSalvagesBlobs(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "keep-me"}}); err != nil {
t.Fatal(err)
}
s.Close()
// Corrupt the manifest.
manPath := filepath.Join(store.SessionInboxDir(session), "manifest.json")
if err := os.WriteFile(manPath, []byte("{not-json"), 0o600); err != nil {
t.Fatal(err)
}
s2, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s2.Close()
snap := s2.Snapshot()
if !snap.Paused || !snap.Recovered {
t.Fatalf("want paused+recovered after corrupt manifest, got %+v", snap)
}
if len(snap.Items) == 0 || snap.RecoveredN == 0 {
t.Fatalf("salvage must surface blobs, got items=%d recoveredN=%d", len(snap.Items), snap.RecoveredN)
}
}
func TestCorruptManifestSalvageRefusesSymlinkBlob(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation may require elevated privileges")
}
session := filepath.Join(t.TempDir(), "s.jsonl")
inboxDir := store.SessionInboxDir(session)
blobsDir := filepath.Join(inboxDir, blobsDirName)
if err := os.MkdirAll(blobsDir, 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(t.TempDir(), "external.json")
if err := os.WriteFile(target, []byte(`{"submitText":"external secret"}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, filepath.Join(blobsDir, newRandomID()+blobSuffix)); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(inboxDir, manifestName), []byte("{broken"), 0o600); err != nil {
t.Fatal(err)
}
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
if got := s.Snapshot(); !got.Paused || !got.Recovered || len(got.Items) != 0 {
t.Fatalf("symlink blob was salvaged: %+v", got)
}
data, err := os.ReadFile(target)
if err != nil || !strings.Contains(string(data), "external secret") {
t.Fatalf("external symlink target changed: %q err=%v", data, err)
}
}
func TestEnqueueRefusesSymlinkBlobsDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation may require elevated privileges")
}
session := filepath.Join(t.TempDir(), "s.jsonl")
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
target := t.TempDir()
if err := os.Symlink(target, filepath.Join(store.SessionInboxDir(session), blobsDirName)); err != nil {
t.Fatal(err)
}
if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "must stay scoped"}}); err == nil {
t.Fatal("enqueue accepted a symlink blobs directory")
}
entries, err := os.ReadDir(target)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("enqueue wrote through symlink: %+v", entries)
}
}
func TestFreezeRefsRejectsWorkspaceEscape(t *testing.T) {
ws := t.TempDir()
refs, err := FreezeRefs(context.Background(), ws, []string{"/etc/passwd"})
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 {
t.Fatalf("want 1 ref, got %d", len(refs))
}
if !strings.Contains(string(refs[0].Content), "outside workspace") && !strings.Contains(string(refs[0].Content), "freeze failed") {
t.Fatalf("want workspace escape rejection, got %q", refs[0].Content)
}
}
func TestFreezeRefsRejectsSymlinkEscape(t *testing.T) {
ws := t.TempDir()
external := t.TempDir()
secret := filepath.Join(external, "secret.txt")
if err := os.WriteFile(secret, []byte("outside-secret"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(ws, "linked-secret.txt")
if err := os.Symlink(secret, link); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
refs, err := FreezeRefs(context.Background(), ws, []string{"linked-secret.txt"})
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 {
t.Fatalf("want one blocked marker, got %d", len(refs))
}
if strings.Contains(string(refs[0].Content), "outside-secret") {
t.Fatal("workspace-local symlink leaked content from outside the workspace")
}
if !strings.Contains(string(refs[0].Content), "path escapes workspace") {
t.Fatalf("want symlink escape rejection, got %q", refs[0].Content)
}
}
func TestApplyFrozenRefsIsDeterministic(t *testing.T) {
bodies := map[string]string{
"z/file.txt": "z-body",
"a/file.txt": "a-body",
}
first := ApplyFrozenRefs("inspect refs", bodies)
for range 20 {
if got := ApplyFrozenRefs("inspect refs", bodies); got != first {
t.Fatalf("frozen reference serialization changed between calls:\n%s\n---\n%s", first, got)
}
}
if strings.Index(first, "@a/file.txt") > strings.Index(first, "@z/file.txt") {
t.Fatalf("frozen references are not sorted: %q", first)
}
}
func TestMoveAndPause(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
a, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "a"}})
b, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "b"}})
if err := s.MoveItem(b.ItemID, 0); err != nil {
t.Fatal(err)
}
items := s.Snapshot().Items
if items[0].ID != b.ItemID || items[1].ID != a.ItemID {
t.Fatalf("order = %v", items)
}
if err := s.SetPaused(true); err != nil {
t.Fatal(err)
}
if _, ok := s.NextQueued(); ok {
t.Fatal("paused inbox must not dispatch")
}
}
func TestDiscardPendingItemsIsScopedAndAtomic(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
a, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "a"}})
b, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "b"}})
c, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "c"}})
if err := s.SetState(b.ItemID, StateRunning, ""); err != nil {
t.Fatal(err)
}
if err := s.DiscardPendingItems([]string{a.ItemID, b.ItemID}); !errors.Is(err, ErrInvalidState) {
t.Fatalf("discard admitted item error = %v, want ErrInvalidState", err)
}
if got := len(s.Snapshot().Items); got != 3 {
t.Fatalf("failed batch discard changed manifest: got %d items", got)
}
if err := s.DiscardPendingItems([]string{a.ItemID, "already-consumed"}); err != nil {
t.Fatal(err)
}
items := s.Snapshot().Items
if len(items) != 2 || items[0].ID != b.ItemID || items[1].ID != c.ItemID {
t.Fatalf("scoped discard left items = %+v", items)
}
}
func TestDiscardPendingItemsEnforcesSourceOwnership(t *testing.T) {
dir := t.TempDir()
session := filepath.Join(dir, "s.jsonl")
_ = os.WriteFile(session, []byte("{}\n"), 0o644)
s, err := Open(session, Limits{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
desktop, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "desktop"}, Source: "desktop"})
bot, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "bot"}, Source: "bot"})
if err := s.SetState(bot.ItemID, StateRunning, ""); err != nil {
t.Fatal(err)
}
if err := s.DiscardPendingItemsOwned([]string{desktop.ItemID, bot.ItemID}, "desktop"); err != nil {
t.Fatal(err)
}
items := s.Snapshot().Items
if len(items) != 1 || items[0].ID != bot.ItemID || items[0].State != StateRunning {
t.Fatalf("source-scoped discard changed foreign work: %+v", items)
}
}
+207
View File
@@ -0,0 +1,207 @@
package sessioninbox
import (
"errors"
"time"
)
// SchemaVersion is the on-disk format version. Unknown higher versions load
// read-only and force pause; they never auto-execute.
const SchemaVersion = 2
// Default capacity limits.
const (
DefaultMaxItems = 64
DefaultMaxItemBytes = 4 << 20 // 4 MiB
DefaultMaxTotalBytes = 64 << 20 // 64 MiB
DefaultPreviewRunes = 120
)
// InboxIntent distinguishes durable follow-up turns from mid-turn steers.
type InboxIntent string
const (
IntentFollowup InboxIntent = "followup"
IntentSteer InboxIntent = "steer"
)
// InboxState is the durable lifecycle of one queue item.
type InboxState string
const (
StateQueued InboxState = "queued"
StateSteerAccepted InboxState = "steer_accepted"
StateSteerConsumed InboxState = "steer_consumed"
StateRunning InboxState = "running"
StateBlocked InboxState = "blocked"
StateUncertain InboxState = "uncertain"
)
// Disposition reports how an admission attempt settled.
type Disposition string
const (
DispositionStarted Disposition = "started"
DispositionSteerAccepted Disposition = "steer_accepted"
DispositionQueuedFollowup Disposition = "queued_followup"
DispositionRejectedBusy Disposition = "rejected_busy"
DispositionRejectedRotating Disposition = "rejected_rotating"
DispositionRejectedClosed Disposition = "rejected_closed"
DispositionRejectedCapacity Disposition = "rejected_capacity"
DispositionIdempotentHit Disposition = "idempotent_hit"
)
// Sentinel errors for capacity and validation.
var (
ErrCapacityItems = errors.New("session inbox item limit reached")
ErrCapacityBytes = errors.New("session inbox byte limit reached")
ErrItemTooLarge = errors.New("inbox item exceeds single-item size limit")
ErrNotFound = errors.New("inbox item not found")
ErrInvalidState = errors.New("inbox item state does not allow this operation")
ErrSchemaReadonly = errors.New("inbox schema is newer and is read-only")
ErrClosed = errors.New("inbox is closed")
ErrEmpty = errors.New("inbox item body is empty")
ErrPaused = errors.New("inbox is paused")
ErrIdempotencyConflict = errors.New("idempotency key was already used for different input")
)
// InboxItemMeta is the durable metadata kept in the manifest (never the body).
type InboxItemMeta struct {
ID string `json:"id"`
SessionID string `json:"sessionId,omitempty"`
Intent InboxIntent `json:"intent"`
State InboxState `json:"state"`
Revision int64 `json:"revision"`
// BlobName is the on-disk blob filename stem (without .json). Empty means
// legacy layout where the blob is named by item ID. Updates write a new
// immutable blob and switch this pointer after manifest commit.
BlobName string `json:"blobName,omitempty"`
Source string `json:"source,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Preview string `json:"preview"`
ByteSize int64 `json:"byteSize"`
Checksum string `json:"checksum"`
Idempotency string `json:"idempotencyKey,omitempty"`
Refs []RefSummary `json:"refs,omitempty"`
BlockReason string `json:"blockReason,omitempty"`
RunID string `json:"runId,omitempty"`
}
// RefSummary is a short reference summary stored in the manifest.
type RefSummary struct {
Kind string `json:"kind"` // clean_git | frozen | external | attachment
Path string `json:"path,omitempty"`
Commit string `json:"commit,omitempty"`
Bytes int64 `json:"bytes,omitempty"`
Preview string `json:"preview,omitempty"`
}
// RefSnapshot freezes a resolved @-reference at enqueue time.
type RefSnapshot struct {
Kind string `json:"kind"` // clean_git | frozen | external | attachment | mcp
Path string `json:"path,omitempty"`
DisplayPath string `json:"displayPath,omitempty"`
RepoIdentity string `json:"repoIdentity,omitempty"`
Commit string `json:"commit,omitempty"`
RangeStart int `json:"rangeStart,omitempty"`
RangeEnd int `json:"rangeEnd,omitempty"`
Content []byte `json:"content,omitempty"`
ContentSHA string `json:"contentSha,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Server string `json:"server,omitempty"`
URI string `json:"uri,omitempty"`
}
// StructuredInvocation is a frozen slash/command invocation.
type StructuredInvocation struct {
Name string `json:"name,omitempty"`
Kind string `json:"kind,omitempty"`
Offset int `json:"offset,omitempty"`
Args map[string]string `json:"args,omitempty"`
Display string `json:"display,omitempty"`
}
// PromptEnvelope is the full durable body stored only in blobs/<id>.json.
type PromptEnvelope struct {
DisplayText string `json:"displayText"`
RawText string `json:"rawText"`
SubmitText string `json:"submitText"`
// Invocation is retained for schema-v1 compatibility. New writers use
// Invocations so multiple rich-composer entities preserve visual order.
Invocation *StructuredInvocation `json:"invocation,omitempty"`
Invocations []StructuredInvocation `json:"invocations,omitempty"`
Format string `json:"format,omitempty"`
Attachments []string `json:"attachments,omitempty"`
Refs []RefSnapshot `json:"refs,omitempty"`
// FrozenRefBlock is the exact typed reference context rendered at enqueue.
// FrozenImages contains already-authorized data URLs for direct image input.
FrozenRefBlock string `json:"frozenRefBlock,omitempty"`
FrozenImages []string `json:"frozenImages,omitempty"`
ReferenceErrors []string `json:"referenceErrors,omitempty"`
ExplicitRefs []string `json:"explicitRefs,omitempty"`
Idempotency string `json:"idempotencyKey,omitempty"`
Source string `json:"source,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// Capacity describes current usage against limits.
type Capacity struct {
Items int `json:"items"`
MaxItems int `json:"maxItems"`
Bytes int64 `json:"bytes"`
MaxBytes int64 `json:"maxBytes"`
MaxItemBytes int64 `json:"maxItemBytes"`
}
// InboxSnapshot is the frontend-safe view: metadata only, never full bodies.
type InboxSnapshot struct {
SchemaVersion int `json:"schemaVersion"`
Revision int64 `json:"revision"`
Paused bool `json:"paused"`
Recovered bool `json:"recovered"`
RecoveredN int `json:"recoveredCount,omitempty"`
Readonly bool `json:"readonly,omitempty"`
RunID string `json:"runId,omitempty"`
Items []InboxItemMeta `json:"items"`
Capacity Capacity `json:"capacity"`
}
// InboxReceipt is returned after a durable enqueue or admission attempt.
type InboxReceipt struct {
ItemID string `json:"itemId"`
Disposition Disposition `json:"disposition"`
Position int `json:"position"`
Paused bool `json:"paused"`
Capacity Capacity `json:"capacity"`
Idempotent bool `json:"idempotent,omitempty"`
}
// EnqueueRequest is the input for durable admission.
type EnqueueRequest struct {
Intent InboxIntent
Envelope PromptEnvelope
Source string
Idempotency string
SessionID string
}
// Limits configures capacity. Zero fields use defaults.
type Limits struct {
MaxItems int
MaxItemBytes int64
MaxTotalBytes int64
}
func (l Limits) withDefaults() Limits {
if l.MaxItems <= 0 {
l.MaxItems = DefaultMaxItems
}
if l.MaxItemBytes <= 0 {
l.MaxItemBytes = DefaultMaxItemBytes
}
if l.MaxTotalBytes <= 0 {
l.MaxTotalBytes = DefaultMaxTotalBytes
}
return l
}
+10
View File
@@ -167,6 +167,16 @@ func SessionJobsDir(sessionPath string) string {
return sessionStem(sessionPath) + ".jobs"
}
// SessionInboxDir is the durable session-level instruction inbox
// (<id>.inbox/). Manifest metadata and frozen prompt blobs live here.
func SessionInboxDir(sessionPath string) string {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
return ""
}
return sessionStem(sessionPath) + ".inbox"
}
// SessionCleanupPending is the delayed-cleanup marker (<id>.cleanup-pending.json).
func SessionCleanupPending(sessionPath string) string {
sessionPath = strings.TrimSpace(sessionPath)
+2
View File
@@ -22,6 +22,7 @@ func TestSessionSidecarLayout(t *testing.T) {
{"lease-info", SessionLeaseInfo(p), p + ".lease.json"},
{"checkpoint", SessionCheckpointDir(p), "/home/u/.reasonix/sessions/abc.ckpt"},
{"jobs", SessionJobsDir(p), "/home/u/.reasonix/sessions/abc.jobs"},
{"inbox", SessionInboxDir(p), "/home/u/.reasonix/sessions/abc.inbox"},
{"cleanup-pending", SessionCleanupPending(p), "/home/u/.reasonix/sessions/abc.cleanup-pending.json"},
{"context", SessionContext(p), "/home/u/.reasonix/sessions/abc.context.json"},
}
@@ -49,6 +50,7 @@ func TestSessionSidecarEmptyPath(t *testing.T) {
{"lease-info", SessionLeaseInfo},
{"checkpoint", SessionCheckpointDir},
{"jobs", SessionJobsDir},
{"inbox", SessionInboxDir},
{"cleanup-pending", SessionCleanupPending},
{"context", SessionContext},
} {
+44 -43
View File
@@ -2,14 +2,14 @@
"limits": {
"banner": 15,
"commented-code": 0,
"complexity": 2085,
"essay": 4099,
"file-size": 110865,
"function-size": 9157,
"complexity": 2112,
"essay": 4090,
"file-size": 111363,
"function-size": 9254,
"layering": 1,
"marker": 0,
"narrative": 64,
"test-file-size": 70093
"test-file-size": 70202
},
"files": {
"cmd/e2ebench/main.go": {
@@ -84,7 +84,7 @@
"essay": 2
},
"desktop/frontend/src/App.tsx": {
"file-size": 4749
"file-size": 4725
},
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
"test-file-size": 19
@@ -93,10 +93,10 @@
"test-file-size": 307
},
"desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx": {
"test-file-size": 1701
"test-file-size": 1758
},
"desktop/frontend/src/__tests__/composer-session-draft.test.tsx": {
"test-file-size": 551
"test-file-size": 578
},
"desktop/frontend/src/__tests__/decision-surface.test.tsx": {
"test-file-size": 250
@@ -123,7 +123,7 @@
"file-size": 2658
},
"desktop/frontend/src/components/Composer.tsx": {
"file-size": 3959
"file-size": 4042
},
"desktop/frontend/src/components/MemoryPanel.tsx": {
"file-size": 1088
@@ -156,13 +156,13 @@
"file-size": 276
},
"desktop/frontend/src/components/WorkspacePanel.tsx": {
"file-size": 1411
"file-size": 1408
},
"desktop/frontend/src/custom/features/heartbeat/HeartbeatPanel.tsx": {
"file-size": 415
},
"desktop/frontend/src/lib/bridge.ts": {
"file-size": 4676
"file-size": 4751
},
"desktop/frontend/src/lib/crash.ts": {
"file-size": 225
@@ -171,10 +171,10 @@
"file-size": 307
},
"desktop/frontend/src/lib/types.ts": {
"file-size": 1455
"file-size": 1457
},
"desktop/frontend/src/lib/useController.ts": {
"file-size": 3925
"file-size": 3929
},
"desktop/heartbeat.go": {
"essay": 20,
@@ -204,7 +204,7 @@
},
"desktop/main.go": {
"essay": 7,
"function-size": 16
"function-size": 9
},
"desktop/main_test.go": {
"essay": 2
@@ -273,7 +273,7 @@
},
"desktop/sessions.go": {
"essay": 5,
"file-size": 458
"file-size": 459
},
"desktop/sessions_lease_guard_test.go": {
"essay": 3
@@ -309,7 +309,7 @@
"desktop/tabs.go": {
"complexity": 74,
"essay": 133,
"file-size": 8719,
"file-size": 8716,
"function-size": 630
},
"desktop/tabs_order_test.go": {
@@ -414,7 +414,8 @@
"essay": 1
},
"internal/acp/protocol.go": {
"essay": 13
"essay": 13,
"file-size": 26
},
"internal/acp/server.go": {
"essay": 4
@@ -425,7 +426,7 @@
},
"internal/acp/service.go": {
"essay": 66,
"file-size": 2172,
"file-size": 2219,
"function-size": 97
},
"internal/acp/service_lock_test.go": {
@@ -437,8 +438,8 @@
},
"internal/agent/agent.go": {
"complexity": 61,
"essay": 108,
"file-size": 2762,
"essay": 107,
"file-size": 2807,
"function-size": 125
},
"internal/agent/ask.go": {
@@ -492,13 +493,13 @@
"test-file-size": 501
},
"internal/agent/execute_batch.go": {
"complexity": 48,
"function-size": 213
"complexity": 45,
"function-size": 208
},
"internal/agent/execute_one.go": {
"complexity": 13,
"essay": 23,
"file-size": 158,
"file-size": 157,
"function-size": 20
},
"internal/agent/extensions.go": {
@@ -575,13 +576,13 @@
"internal/agent/run_loop.go": {
"complexity": 8,
"essay": 32,
"file-size": 254,
"file-size": 258,
"function-size": 45
},
"internal/agent/save.go": {
"complexity": 49,
"essay": 113,
"file-size": 1543,
"file-size": 1544,
"function-size": 124
},
"internal/agent/save_test.go": {
@@ -739,16 +740,16 @@
"file-size": 210
},
"internal/bot/gateway.go": {
"complexity": 54,
"essay": 28,
"file-size": 2109,
"function-size": 253
"complexity": 59,
"essay": 23,
"file-size": 2172,
"function-size": 274
},
"internal/bot/gateway_lock_test.go": {
"essay": 8
},
"internal/bot/gateway_test.go": {
"test-file-size": 1698
"test-file-size": 1705
},
"internal/bot/project_index.go": {
"file-size": 31
@@ -807,17 +808,17 @@
"essay": 15
},
"internal/cli/chat_tui.go": {
"complexity": 300,
"complexity": 326,
"essay": 110,
"file-size": 4546,
"function-size": 1195
"file-size": 4676,
"function-size": 1286
},
"internal/cli/chat_tui_paste.go": {
"essay": 9
},
"internal/cli/chat_tui_test.go": {
"essay": 8,
"test-file-size": 3582
"test-file-size": 3604
},
"internal/cli/cli.go": {
"complexity": 75,
@@ -834,7 +835,7 @@
"file-size": 49
},
"internal/cli/complete_test.go": {
"test-file-size": 15
"test-file-size": 14
},
"internal/cli/composer_selection.go": {
"essay": 1
@@ -1047,7 +1048,7 @@
"essay": 3
},
"internal/config/provider_presets.go": {
"file-size": 282
"file-size": 267
},
"internal/config/render.go": {
"complexity": 250,
@@ -1068,10 +1069,10 @@
"essay": 9
},
"internal/control/controller.go": {
"complexity": 11,
"essay": 165,
"file-size": 5408,
"function-size": 78,
"complexity": 10,
"essay": 162,
"file-size": 5452,
"function-size": 75,
"narrative": 4
},
"internal/control/controller_test.go": {
@@ -1197,7 +1198,7 @@
},
"internal/event/event.go": {
"essay": 10,
"file-size": 43,
"file-size": 46,
"narrative": 1
},
"internal/event/subagent_progress.go": {
@@ -1568,7 +1569,7 @@
},
"internal/provider/responses/responses_test.go": {
"essay": 6,
"test-file-size": 377
"test-file-size": 374
},
"internal/provider/responses/vendor.go": {
"essay": 21
@@ -1718,7 +1719,7 @@
},
"internal/serve/serve.go": {
"essay": 27,
"file-size": 856
"file-size": 872
},
"internal/serve/serve_lease_test.go": {
"essay": 3