mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #8061 from SivanCola/fix/session-inbox-durability
Persist Session Inbox for durable queued instructions / 持久化会话收件箱以恢复排队指令
This commit is contained in:
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],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -600,6 +600,7 @@ func recoveryTrashSidecars(path string) []string {
|
||||
path+".telemetry.json",
|
||||
store.SessionCheckpointDir(path),
|
||||
store.SessionJobsDir(path),
|
||||
store.SessionInboxDir(path),
|
||||
)
|
||||
return artifacts
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user