mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Fix canonical inbox identity and settled tool state / 修复会话队列身份与工具完成状态 (#10646)
* fix: preserve canonical inbox identity and settled tool status Problem: canonical sessions could reject queued messages or count another session's background jobs, while settled tool rows could remain running. Diagnostic exports could also stop when text redaction damaged JSON. Root cause: logical session locators were conflated with backing paths, and sampled job states were treated as the output reader's invocation state. Diagnostic redaction operated on serialized JSON instead of string values. Fix: separate inbox identity from storage, use canonical identities for receipt ownership and runtime metadata, settle background launch receipts, retain output-reader completion during history restore, and redact JSON structurally without changing counters or the persisted inbox format. Verification: root Go suite; focused control and desktop race tests; sessioninbox and secrets tests; frontend typecheck and transcript suite; Chromium transcript regression; repolint and git diff --check passed. * fix: close query workers and reconcile CI validation Problem: Linux CI raced temporary-directory cleanup with query-cache writes, and desktop preparation exceeded its initial raw bundle gate by 35 bytes. Root cause: the mirror-listing fixture retired runtimes without shutting down its service-owned query workers. Settled tool-state handling adds 104 bytes to the same-toolchain base and consumes the previous rounded payload margin. Fix: register terminal service shutdown before directory cleanup and isolate the listing regression in its own test file. Round only the measured initial raw bundle budget from 2053.8 to 2053.9 KiB; keep all other gates unchanged. Also document the unchanged user workflow through PR impact metadata. Verification: the listing regression passed 20 race-enabled repetitions; identity lifecycle race tests, transcript and submission regressions, manual compaction regressions, repolint, and documentation impact validation passed. Both base and candidate Electron bundles were measured with one toolchain. * fix: order snapshot eviction independently of clock precision Problem: Windows CI retained an old read handle after the snapshot bound was reached, failing the read-snapshot eviction regression. Root cause: multiple accesses can share one clock sample. Comparing only timestamps made LRU selection depend on nondeterministic map traversal. Fix: assign a monotonic access sequence under the store lock at publication, handle acquisition, and page access. Use that order for eviction and retain elapsed time for idle and absolute expiry. Verification: a forced-equal-timestamp regression covering insertion and page promotion fails against the old implementation. The read-snapshot suite passed five race-enabled repetitions; desktop go vet and repolint passed. Native Windows validation remains in the replacement CI run. * test: range directly over snapshot eviction expectations Problem: CI's Windows desktop lint rejected the eviction regression loop. Root cause: the test ranged over len(ordered) instead of the slice itself, which violates the intrange rule enforced by the pinned linter. Fix: range directly over ordered, preserving the same eviction assertions. Verification: run golangci-lint 2.12.2 for Windows amd64, matching CI.
This commit is contained in:
1 parent
f26283afb2
commit
866ad4cb8e
27 files changed
+658
-108
No files matched your search
@@ -417,6 +417,14 @@ try {
|
||||
await page.locator('[data-web="search"]').waitFor();
|
||||
assert.equal(await page.locator('[data-web="search"] a').count(), 1, 'web sources retain safe host links');
|
||||
report.weatherRows = weatherRows;
|
||||
await page.evaluate(() => window.chatFixture.backgroundLaunch()); await frame();
|
||||
await page.locator('.chat-process').click(); await frame();
|
||||
assert.equal(await page.locator('.chat-tool[data-state="done"]').count(), 2, 'background launch receipts settle for PowerShell and Bash');
|
||||
assert.equal(await page.locator('.chat-tool [data-state="ongoing"]').count(), 0, 'settled launches have no perpetual running animation');
|
||||
await page.evaluate(() => window.chatFixture.backgroundOutputHistory()); await frame();
|
||||
await page.locator('.chat-process').click(); await frame();
|
||||
assert.equal(await page.locator('.chat-tool[data-state="done"]').count(), 4, 'restored output reads settle independently of sampled job state');
|
||||
assert.equal(await page.locator('.chat-tool [data-state="ongoing"], .chat-tool[data-state="error"], .chat-tool[data-state="stopped"]').count(), 0, 'job snapshots cannot restart or fail a completed output read');
|
||||
await page.evaluate(() => window.chatFixture.toolAliasRegression()); await frame();
|
||||
const aliasProcess = page.locator('.chat-process');
|
||||
assert.equal(await aliasProcess.getAttribute('aria-expanded'), 'false', 'recovered alias regression folds after completion');
|
||||
|
||||
@@ -47,7 +47,7 @@ function weatherTurn(): Item[] {
|
||||
{ ...end, id: "weather-final", text: "今天上海天气如下。以下为界面回放测试数据。\n\n## 上海 · 今日实况\n\n| 项目 | 数值 |\n|---|---|\n| 天气 | 晴 ☀️ |\n| 气温 | **25.5 °C** |\n| 湿度 | 66% |\n\n**全天**:多云转晴,23~30 °C。", reasoning: "" } as Item,
|
||||
];
|
||||
}
|
||||
declare global { interface Window { chatFixture: { maintenance(stage: "start" | "refresh" | "completed" | "unknown"): void; authored(): void; toolAliasRegression(): void; weather(): void; replace(count: number): void; reset(count: number): void; older(): void; tick(index: number): void; settle(): void; switchSession(): void; prepend(): void; ready: number; pending(): number } } }
|
||||
declare global { interface Window { chatFixture: { maintenance(stage: "start" | "refresh" | "completed" | "unknown"): void; authored(): void; toolAliasRegression(): void; backgroundLaunch(): void; backgroundOutputHistory(): void; weather(): void; replace(count: number): void; reset(count: number): void; older(): void; tick(index: number): void; settle(): void; switchSession(): void; prepend(): void; ready: number; pending(): number } } }
|
||||
function Fixture() {
|
||||
const [items, setItems] = useState(() => new URLSearchParams(window.location.search).has("deliverables") ? weatherTurn() : makeTurns(20));
|
||||
const [session, setSession] = useState(0);
|
||||
@@ -102,6 +102,27 @@ function Fixture() {
|
||||
clearLive(); setItems(historyMessagesToItems(messages, "authored").items); setRunning(false); setSession(value => value + 1);
|
||||
},
|
||||
weather: () => { clearLive(); setItems(weatherTurn()); setRunning(false); setSession(value => value + 1); },
|
||||
backgroundLaunch: () => {
|
||||
clearLive(); setItems([
|
||||
{ kind: "user", id: "launch-user", text: "Run the build" },
|
||||
...["pwsh", "bash"].map((name): Item => ({ kind: "tool", id: `launch-${name}`, name, args: "{}", status: "done", output: "Background job started", execution: { state: "background_started", shell: name } })),
|
||||
{ kind: "assistant", id: "launch-final", text: "Build started.", streaming: false },
|
||||
]); setRunning(false); setSession(value => value + 1);
|
||||
},
|
||||
backgroundOutputHistory: () => {
|
||||
const calls = ["running", "failed", "cancelled", "completed"].map(state => ({
|
||||
id: `poll-${state}`, name: "job_output", arguments: "{}",
|
||||
resultObservation: { state: "completed" as const, messageId: `result-${state}`, version: 1 },
|
||||
}));
|
||||
const projected = historyMessagesToItems([
|
||||
{ role: "user", content: "Check the background jobs" },
|
||||
{ role: "assistant", content: "", toolCalls: calls },
|
||||
...calls.map(call => ({ role: "tool", messageId: call.resultObservation.messageId, toolName: call.name,
|
||||
toolCallId: call.id, content: "Output retrieved", execution: { state: call.id.slice("poll-".length) } })),
|
||||
{ role: "assistant", content: "Output checked." },
|
||||
], "output-history");
|
||||
clearLive(); setItems(projected.items); setRunning(false); setSession(value => value + 1);
|
||||
},
|
||||
toolAliasRegression: () => {
|
||||
const user: Item = { kind: "user", id: "alias-user", text: "创建文件", checkpointTurn: 1 };
|
||||
const tool: Item = { kind: "tool", id: "alias-call", name: "bash", args: "printf ok", readOnly: false, status: "done", output: "ok",
|
||||
|
||||
@@ -547,6 +547,9 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
// Manual compaction admission feedback and its running summary hint measure
|
||||
// 2103022 B in the Linux Electron producer. Retain 69 B at the next one-decimal
|
||||
// ceiling; compressed, chunk, CSS, locale, and resident-history gates stay fixed.
|
||||
const rawInitialBudgetKiB = 2_053.8;
|
||||
// Settled launch receipts and output-reader history states add 104 B over the
|
||||
// same-toolchain base (2102940 B versus 2102836 B). The Linux producer measures
|
||||
// 2103126 B; retain the next one-decimal ceiling with all other gates fixed.
|
||||
const rawInitialBudgetKiB = 2_053.9;
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -62,3 +62,19 @@ assert.equal(historyToolStatus(undefined), "unknown", "unloaded results must not
|
||||
assert.equal(historyToolStatus(undefined, { id: "call", name: "bash", arguments: "{}", resultObservation: { state: "completed", messageId: "result", version: 1 } }), "done");
|
||||
assert.equal(toolPresentation(tool({ status: "unknown", resultMissing: true })).state, "unknown");
|
||||
assert.equal(historyToolStatus({ role: "tool", content: "", execution: { state: "cancelled" } }), "stopped");
|
||||
for (const state of ["running", "failed", "cancelled", "completed"]) {
|
||||
const call = { id: "poll", name: "job_output", arguments: "{}", resultObservation: { state: "completed" as const, messageId: "poll-result", version: 1 } };
|
||||
const result = { role: "tool", toolName: "job_output", toolCallId: "poll", content: `[status: ${state}]`, execution: { state } };
|
||||
const projected = historyMessagesToItems([{ role: "assistant", content: "", toolCalls: [call] }, result], "poll");
|
||||
const item = projected.items.find((item): item is ToolItem => item.kind === "tool")!;
|
||||
assert.equal(item.status, "done", "the output read completed independently of the observed job state");
|
||||
assert.equal(toolPresentation(item).dot, "done", "job state metadata must not override the output-read receipt");
|
||||
assert.equal(toolPresentation({ ...item, status: "running" }).dot, "ongoing", "an in-flight output read still has its own running state");
|
||||
}
|
||||
for (const name of ["pwsh", "bash"]) {
|
||||
const launch = tool({ name, execution: { state: "background_started" } });
|
||||
assert.deepEqual(toolPresentation(launch), { state: "done", dot: "done", label: "chat.background" },
|
||||
"a background launch receipt cannot remain a perpetual running indicator");
|
||||
assert.equal(toolPresentation(tool({ name, status: "running" })).dot, "ongoing",
|
||||
"a foreground call without a result still reports running");
|
||||
}
|
||||
@@ -6,6 +6,18 @@ import { FakeBackend } from "./helpers/transcriptFakeBackend";
|
||||
|
||||
const toolsOf = (items: Item[]) => items.filter((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool");
|
||||
|
||||
test("a persisted background-output read does not restart when its job snapshot was running", () => {
|
||||
const store = new TranscriptStore(new FakeBackend([]));
|
||||
const projection = store.installSlice("poll", "session-id:poll", {
|
||||
entries: [
|
||||
{ entryId: "m:call", turn: 1, order: 0, message: { role: "assistant", content: "", toolCalls: [{ id: "poll", name: "job_output", arguments: "{}", resultObservation: { state: "completed", messageId: "result" } }] }, refs: [] },
|
||||
{ entryId: "m:result", turn: 1, order: 1, message: { role: "tool", toolCallId: "poll", toolName: "job_output", messageId: "result", content: "[status: running]", execution: { state: "running" } }, refs: [] },
|
||||
], nextCursor: "", newerCursor: "", hasOlder: false, hasNewer: false, totalTurns: 1, startTurn: 1, endTurn: 1,
|
||||
revision: 1, revisionKnown: true, digest: "poll", stale: false,
|
||||
});
|
||||
assert.equal(toolsOf(projection.items)[0].status, "done");
|
||||
});
|
||||
|
||||
test("formal message aliases retain one canonical result", () => {
|
||||
const store = new TranscriptStore(new FakeBackend([]));
|
||||
const projection = store.installSlice("formal-representation-alias", "/formal-representation-alias", {
|
||||
|
||||
@@ -4,18 +4,20 @@ import { isShellToolName, isPowerShellToolName } from "./shellToolIdentity";
|
||||
export type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
export type ToolPresentationKind = "search" | "web" | "shell" | "agent" | "file" | "present" | "tool";
|
||||
|
||||
/** Execution metadata is authoritative, including old timeouts carrying a false zero exit code. */
|
||||
/** Shell execution metadata is authoritative for shell calls, not output readers. */
|
||||
export function toolPresentation(item: ToolItem): {
|
||||
state: ToolItem["status"]; dot: "ongoing" | "done" | "error" | "warning" | "idle";
|
||||
label: "chat.running" | "chat.done" | "chat.failed" | "chat.stopped" | "chat.timedOut" | "chat.notRun" | "chat.background" | "chat.unknown";
|
||||
exitCode?: number;
|
||||
} {
|
||||
const execution = item.execution;
|
||||
const execution = classifyTool(item) === "shell" ? item.execution : undefined;
|
||||
switch (execution?.state) {
|
||||
case "timed_out": return { state: "error", dot: "error", label: "chat.timedOut" };
|
||||
case "cancelled": return { state: "stopped", dot: "warning", label: "chat.stopped" };
|
||||
case "not_run": return { state: "stopped", dot: "warning", label: "chat.notRun" };
|
||||
case "background_started": return { state: "stopped", dot: "ongoing", label: "chat.background" };
|
||||
// This is the settled launch receipt, not a live observation of the job.
|
||||
// Job progress is reported by its own output/status tools across turns.
|
||||
case "background_started": return { state: "done", dot: "done", label: "chat.background" };
|
||||
case "failed": return { state: "error", dot: "error", label: "chat.failed", exitCode: execution.exitCode };
|
||||
case "completed":
|
||||
if (execution.exitCode == null) return { state: "unknown", dot: "idle", label: "chat.unknown" };
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { HistoryMessage, HistoryToolCall } from "./types";
|
||||
import type { ToolStatus } from "./useController";
|
||||
import { isShellToolName } from "./shellToolIdentity";
|
||||
|
||||
/** Missing resident payload is not evidence that execution stopped. */
|
||||
export function historyToolStatus(result: HistoryMessage | undefined, call?: HistoryToolCall, error?: string): ToolStatus {
|
||||
const evidence = result?.execution?.state ?? call?.resultObservation?.state;
|
||||
const name = call?.name || result?.toolName;
|
||||
const shell = !name || isShellToolName(name) || (call?.id || result?.toolCallId || "").startsWith("shell-");
|
||||
// A job-output read can carry a snapshot of a still-running (or failed)
|
||||
// process. That process is not the invocation whose history we are restoring.
|
||||
const evidence = (shell ? result?.execution?.state : undefined) ?? call?.resultObservation?.state;
|
||||
if (evidence === "cancelled" || evidence === "not_started") return "stopped";
|
||||
if (evidence === "failed" || evidence === "error") return "error";
|
||||
if (error) return "error";
|
||||
|
||||
@@ -36,7 +36,7 @@ export const en = {
|
||||
"chat.stopped": "Stopped",
|
||||
"chat.timedOut": "Timed out",
|
||||
"chat.notRun": "Not executed",
|
||||
"chat.background": "Running in background",
|
||||
"chat.background": "Started in background",
|
||||
"chat.unknown": "Result unavailable",
|
||||
"chat.submissionUnknown": "Delivery not yet confirmed; reconnecting to check. Do not resend yet",
|
||||
"chat.failed": "Failed",
|
||||
|
||||
@@ -66,7 +66,15 @@ func (a *App) localReceiptTarget(path string) (localReceiptOwner, error) {
|
||||
}
|
||||
|
||||
func (a *App) localReceiptOwnerCurrent(owner localReceiptOwner) bool {
|
||||
if sessionRuntimeKey(owner.ctrl.SessionPath()) != sessionRuntimeKey(owner.path) {
|
||||
path := owner.ctrl.SessionPath()
|
||||
if lifecycle, ok := owner.ctrl.(control.IdentityLifecycle); ok && lifecycle.UsesExclusiveSession() {
|
||||
ref, bound := lifecycle.SessionRef()
|
||||
if !bound {
|
||||
return false
|
||||
}
|
||||
path = "session-id:" + ref.SessionID
|
||||
}
|
||||
if sessionRuntimeKey(path) != sessionRuntimeKey(owner.path) {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -8,12 +9,57 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/session"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestCanonicalInboxTargetEnqueuesAndConfirmsReceipt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
service, err := session.NewService("local", session.NewFilesystemPersistence(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executor := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: executor, SessionService: service, Sink: event.Discard})
|
||||
t.Cleanup(func() {
|
||||
ctrl.Close()
|
||||
<-ctrl.Closed()
|
||||
_ = service.CloseAll(context.Background())
|
||||
})
|
||||
if _, err := ctrl.BindFreshSession(t.Context(), "queue-target"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ctrl.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const path = "session-id:queue-target"
|
||||
a := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", Ctrl: ctrl, SessionPath: path, SessionGeneration: 1, Ready: true}}}
|
||||
target, err := a.CaptureInboxTarget("tab", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := a.EnqueueInboxFollowupForTarget(target, "queued input", "queued input", nil, "request")
|
||||
if err != nil || receipt.ItemID == "" {
|
||||
t.Fatalf("canonical enqueue: %+v %v", receipt, err)
|
||||
}
|
||||
confirmed, err := a.LookupInboxFollowupForTarget(target, "request")
|
||||
if err != nil || confirmed.ItemID != receipt.ItemID {
|
||||
t.Fatalf("canonical confirmation: %+v %v", confirmed, err)
|
||||
}
|
||||
a.tabs["tab"].SessionGeneration++
|
||||
if _, err := a.EnqueueInboxFollowupForTarget(target, "stale input", "stale input", nil, "stale"); err == nil {
|
||||
t.Fatal("stale generation accepted a new write")
|
||||
}
|
||||
if confirmed, err := a.LookupInboxFollowupForTarget(target, "request"); err != nil || confirmed.ItemID != receipt.ItemID {
|
||||
t.Fatalf("same-session receipt after rebind: %+v %v", confirmed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteInboxTargetLostReceiptOnlyQueriesOriginalRequest(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -35,6 +35,7 @@ type readSnapshotStore struct {
|
||||
cancel context.CancelFunc
|
||||
closed bool
|
||||
memory, disk int64
|
||||
accessOrder uint64
|
||||
}
|
||||
|
||||
type readSnapshotBuild struct {
|
||||
@@ -64,6 +65,7 @@ type readSnapshot struct {
|
||||
type readSnapshotLifetime struct {
|
||||
created time.Time
|
||||
used time.Time
|
||||
order uint64
|
||||
}
|
||||
|
||||
type readSnapshotCursor struct {
|
||||
@@ -195,6 +197,7 @@ func (s *readSnapshotStore) publishBuild(binding string, job *readSnapshotBuild,
|
||||
if len(s.entries) >= 64 {
|
||||
victims = append(victims, s.evictOldestLocked())
|
||||
}
|
||||
s.touchLocked(snap)
|
||||
s.entries[snap.id] = snap
|
||||
job.snapshot = snap
|
||||
} else if job.err == nil {
|
||||
@@ -222,7 +225,8 @@ func (s *readSnapshotStore) acquireBuildHandle(binding string, root *readSnapsho
|
||||
return nil, snapshotStale("evicted")
|
||||
}
|
||||
root.leases++
|
||||
handle := &readSnapshot{id: hex.EncodeToString(id[:]), binding: binding, lifetime: readSnapshotLifetime{created: root.lifetime.created, used: time.Now()}, data: root}
|
||||
handle := &readSnapshot{id: hex.EncodeToString(id[:]), binding: binding, lifetime: readSnapshotLifetime{created: root.lifetime.created}, data: root}
|
||||
s.touchLocked(handle)
|
||||
var victim *readSnapshot
|
||||
if len(s.entries) >= 64 {
|
||||
victim = s.evictOldestLocked()
|
||||
@@ -247,10 +251,17 @@ func (s *readSnapshotStore) evictExpiredLocked() []*readSnapshot {
|
||||
return victims
|
||||
}
|
||||
|
||||
// Access order owns LRU eviction; wall-clock samples only own expiration.
|
||||
func (s *readSnapshotStore) touchLocked(snap *readSnapshot) {
|
||||
s.accessOrder++
|
||||
snap.lifetime.used = time.Now()
|
||||
snap.lifetime.order = s.accessOrder
|
||||
}
|
||||
|
||||
func (s *readSnapshotStore) evictOldestLocked() *readSnapshot {
|
||||
var oldest *readSnapshot
|
||||
for _, snap := range s.entries {
|
||||
if oldest == nil || snap.lifetime.used.Before(oldest.lifetime.used) {
|
||||
if oldest == nil || snap.lifetime.order < oldest.lifetime.order {
|
||||
oldest = snap
|
||||
}
|
||||
}
|
||||
@@ -380,7 +391,7 @@ func (s *readSnapshotStore) page(ctx context.Context, binding, cursor string, fi
|
||||
s.dispose(snap)
|
||||
return "", "", 0, nil, snapshotStale("expired")
|
||||
}
|
||||
snap.lifetime.used = time.Now()
|
||||
s.touchLocked(snap)
|
||||
s.mu.Unlock()
|
||||
if snap.data != nil {
|
||||
snap = snap.data
|
||||
|
||||
@@ -295,3 +295,41 @@ func TestReadSnapshotEvictionKeepsBoundAndRejectsOldHandle(t *testing.T) {
|
||||
t.Fatal("eviction leaked storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSnapshotEvictionOrdersEqualTimestampsAndPageAccess(t *testing.T) {
|
||||
var store readSnapshotStore
|
||||
defer store.close()
|
||||
var ordered []*readSnapshot
|
||||
for i := range 32 {
|
||||
handle, err := store.build(t.Context(), fmt.Sprint(i), func(ctx context.Context, snap *readSnapshot) error {
|
||||
return store.append(ctx, snap, i)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ordered = append(ordered, handle.data, handle)
|
||||
}
|
||||
// Reading the oldest handle must refresh its position, even when every
|
||||
// clock sample has the same value (as on a coarse platform clock).
|
||||
first := ordered[1]
|
||||
if _, _, _, _, err := store.page(t.Context(), "0", "", first, 1, func([]byte) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ordered = append(append(ordered[:1:1], ordered[2:]...), first)
|
||||
sharedTime := time.Now()
|
||||
store.mu.Lock()
|
||||
for _, snap := range store.entries {
|
||||
snap.lifetime.used = sharedTime
|
||||
}
|
||||
var victims []*readSnapshot
|
||||
for range ordered {
|
||||
victims = append(victims, store.evictOldestLocked())
|
||||
}
|
||||
store.mu.Unlock()
|
||||
for i, victim := range victims {
|
||||
store.dispose(victim)
|
||||
if victim != ordered[i] {
|
||||
t.Errorf("eviction %d did not follow insertion and page-access order", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/session"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func canonicalIdentityController(t *testing.T, service *session.Service, id, legacy string, manager *jobs.Manager) *Controller {
|
||||
t.Helper()
|
||||
runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
return newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, Jobs: manager,
|
||||
SessionService: service, SessionRuntime: runtime, ExclusiveSession: true, SessionPath: legacy})
|
||||
}
|
||||
|
||||
func TestCanonicalInboxWithLegacyImportPathUsesTemporaryStorage(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
service, err := session.NewService("local", session.NewFilesystemPersistence(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := filepath.Join(t.TempDir(), "old.jsonl")
|
||||
c := canonicalIdentityController(t, service, "imported.session", legacy, nil)
|
||||
st, err := c.ensureInbox()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.SessionPath() != "session-id:imported.session" || !filepath.IsAbs(st.Dir()) {
|
||||
t.Fatalf("mixed locator and storage path: locator=%q directory=%q", st.SessionPath(), st.Dir())
|
||||
}
|
||||
if _, err := os.Stat("session-id:imported.session.inbox"); !os.IsNotExist(err) {
|
||||
t.Fatalf("canonical locator became a file path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(legacy), "old.inbox")); !os.IsNotExist(err) {
|
||||
t.Fatalf("canonical inbox wrote beside the import source: %v", err)
|
||||
}
|
||||
receipt, err := c.EnqueueInbox(InboxRequest{ExpectedSessionPath: "session-id:imported.session", Submit: "later"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, _, err := c.ReadInboxItem(receipt.ItemID)
|
||||
if err != nil || meta.SessionID != "imported.session" {
|
||||
t.Fatalf("queue item lost its exact canonical ID: %+v %v", meta, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalRuntimeJobsAndPermissionSnapshotsUseExactSessionIdentity(t *testing.T) {
|
||||
service, err := session.NewService("local", session.NewFilesystemPersistence(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager := jobs.NewManager(event.Discard)
|
||||
t.Cleanup(manager.Close)
|
||||
a := canonicalIdentityController(t, service, "a", "", manager)
|
||||
b := canonicalIdentityController(t, service, "b", "", manager)
|
||||
empty := canonicalIdentityController(t, service, "empty", "", manager)
|
||||
start := func(id string) *jobs.Job {
|
||||
return manager.StartForSession(id, "bash", id, func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
})
|
||||
}
|
||||
jobA, jobB := start("a"), start("b")
|
||||
for _, tc := range []struct {
|
||||
c *Controller
|
||||
id string
|
||||
count int
|
||||
}{{a, "a", 1}, {b, "b", 1}, {empty, "empty", 0}} {
|
||||
if got := tc.c.RuntimeStateSnapshot().BackgroundJobs; got != tc.count {
|
||||
t.Errorf("session %s counted %d jobs, want %d", tc.id, got, tc.count)
|
||||
}
|
||||
if got := tc.c.PermissionSnapshot().SessionID; got != tc.id {
|
||||
t.Errorf("session %s permission identity = %q", tc.id, got)
|
||||
}
|
||||
}
|
||||
if a.CancelJob(jobB.ID) || !a.CancelJob(jobA.ID) || !b.CancelJob(jobB.ID) {
|
||||
t.Fatal("job cancellation crossed its session boundary")
|
||||
}
|
||||
}
|
||||
@@ -121,18 +121,10 @@ func (c *Controller) WriteSessionDiagnostics(ctx context.Context, dst io.Writer,
|
||||
extra["exportSnapshot"] = exportSnapshot
|
||||
}
|
||||
for name, value := range extra {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redacted := json.RawMessage(secrets.Redact(string(encoded)))
|
||||
if !json.Valid(redacted) {
|
||||
return errors.New("invalid redacted diagnostic field")
|
||||
}
|
||||
fields = append(fields, struct {
|
||||
name string
|
||||
value any
|
||||
}{name, redacted})
|
||||
}{name, value})
|
||||
}
|
||||
for _, field := range fields {
|
||||
if err := writeGoalDiagnosticField(dst, field.name, field.value, true); err != nil {
|
||||
@@ -155,10 +147,15 @@ func writeSessionDiagnosticCommits(ctx context.Context, dst io.Writer, store *se
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded = []byte(secrets.Redact(string(encoded)))
|
||||
if !json.Valid(encoded) {
|
||||
return errors.New("redacted goal diagnostic commit is not valid JSON")
|
||||
encoded, err = secrets.RedactJSON(encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var formatted bytes.Buffer
|
||||
if err := json.Indent(&formatted, encoded, " ", " "); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded = formatted.Bytes()
|
||||
separator := "\n "
|
||||
if !first {
|
||||
separator = ",\n "
|
||||
@@ -228,10 +225,19 @@ func visitAcceptedGoalDiagnosticCommits(ctx context.Context, store *session.Sess
|
||||
}
|
||||
|
||||
func writeGoalDiagnosticField(dst io.Writer, name string, value any, comma bool) error {
|
||||
encoded, err := json.MarshalIndent(value, " ", " ")
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err = secrets.RedactJSON(encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var formatted bytes.Buffer
|
||||
if err := json.Indent(&formatted, encoded, " ", " "); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded = formatted.Bytes()
|
||||
if _, err := fmt.Fprintf(dst, " %q: ", name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
@@ -23,7 +25,7 @@ func TestGoalDiagnosticExportReadsCompleteDurableV3Log(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolPayload := json.RawMessage(`{"id":"call-1","name":"bash","output":"full diagnostic output; Authorization: Bearer secret-token-123456; api_key=sk-proj-1234567890abcdef"}`)
|
||||
toolPayload := json.RawMessage(`{"id":"call-1","name":"bash","output":"full diagnostic output; Authorization: Bearer secret-token-123456; api_key=sk-proj-1234567890abcdef\nTOKEN=这是很长的中文测试凭证内容"}`)
|
||||
if _, err := runtime.Session().AppendBatch(t.Context(), "tool-evidence", []session.Event{{Kind: "tool/result", Payload: toolPayload}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -46,6 +48,9 @@ func TestGoalDiagnosticExportReadsCompleteDurableV3Log(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(payload)
|
||||
if !utf8.Valid(payload) {
|
||||
t.Fatal("diagnostic export contains invalid UTF-8")
|
||||
}
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(payload, &document); err != nil {
|
||||
t.Fatalf("diagnostic export is not valid JSON: %v\n%s", err, text)
|
||||
@@ -60,4 +65,15 @@ func TestGoalDiagnosticExportReadsCompleteDurableV3Log(t *testing.T) {
|
||||
t.Fatalf("diagnostic export leaked credential %q", secret)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "accepted event traversal failed") {
|
||||
t.Fatal("redaction truncated the accepted history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticFieldRedactionPreservesNumericCounters(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
err := writeGoalDiagnosticField(&output, "test", map[string]any{"token_count": uint64(9007199254740993), "api_key": "secret value"}, false)
|
||||
if err != nil || !json.Valid([]byte("{"+output.String()+"}")) || !strings.Contains(output.String(), `"token_count": 9007199254740993`) || strings.Contains(output.String(), "secret value") {
|
||||
t.Fatalf("invalid redacted diagnostic field: %s, %v", output.String(), err)
|
||||
}
|
||||
}
|
||||
+39
-19
@@ -14,6 +14,7 @@ import (
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/sessioninbox"
|
||||
"reasonix/internal/sessiontemp"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// TurnAdmission is the exported classification of TrySubmitInboxItem /
|
||||
@@ -204,20 +205,39 @@ func (c *Controller) bindInboxStoreNotifications(st *sessioninbox.Store) {
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Controller) ensureInbox() (*sessioninbox.Store, error) {
|
||||
path := c.SessionPath()
|
||||
c.inbox.mu.Lock()
|
||||
defer c.inbox.mu.Unlock()
|
||||
if path == "" && c.sessionEngineEnabled() {
|
||||
// inboxBinding selects identity and storage ownership from the same runtime.
|
||||
// An import/display path can coexist with a canonical binding during startup;
|
||||
// it must not turn the canonical locator into a filesystem path.
|
||||
func (c *Controller) inboxBinding() (locator string, temporary bool) {
|
||||
if _, runtime, exclusive := c.v3Binding(); exclusive {
|
||||
if runtime == nil {
|
||||
return "", true
|
||||
}
|
||||
return "session-id:" + runtime.Ref().SessionID, true
|
||||
}
|
||||
return c.SessionPath(), false
|
||||
}
|
||||
|
||||
// inboxDirectoryLocked retains the existing process-local storage lifetime.
|
||||
// c.inbox.mu must be held by the caller.
|
||||
func (c *Controller) inboxDirectoryLocked(path string, temporary bool) (string, error) {
|
||||
if temporary {
|
||||
if c.inbox.tempLease == nil {
|
||||
lease, err := c.sessionTemp.Acquire()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open v3 runtime inbox: %w", err)
|
||||
return "", fmt.Errorf("open runtime inbox: %w", err)
|
||||
}
|
||||
c.inbox.tempLease = lease
|
||||
}
|
||||
path = filepath.Join(c.inbox.tempLease.Dir(), "runtime-inbox.jsonl")
|
||||
return store.SessionInboxDir(filepath.Join(c.inbox.tempLease.Dir(), "runtime-inbox.jsonl")), nil
|
||||
}
|
||||
return store.SessionInboxDir(path), nil
|
||||
}
|
||||
|
||||
func (c *Controller) ensureInbox() (*sessioninbox.Store, error) {
|
||||
path, temporary := c.inboxBinding()
|
||||
c.inbox.mu.Lock()
|
||||
defer c.inbox.mu.Unlock()
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("inbox requires a session identity")
|
||||
}
|
||||
@@ -231,7 +251,11 @@ func (c *Controller) ensureInbox() (*sessioninbox.Store, error) {
|
||||
c.inbox.store.Close()
|
||||
c.inbox.store = nil
|
||||
}
|
||||
st, err := sessioninbox.Open(path, sessioninbox.Limits{})
|
||||
dir, err := c.inboxDirectoryLocked(path, temporary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := sessioninbox.OpenAt(path, dir, sessioninbox.Limits{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,7 +277,7 @@ func (c *Controller) ensureInbox() (*sessioninbox.Store, error) {
|
||||
// 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()
|
||||
path, temporary := c.inboxBinding()
|
||||
c.inbox.mu.Lock()
|
||||
defer c.inbox.mu.Unlock()
|
||||
if c.inbox.closed {
|
||||
@@ -273,19 +297,15 @@ func (c *Controller) rebindInbox() {
|
||||
c.inbox.tempLease.Release()
|
||||
c.inbox.tempLease = nil
|
||||
}
|
||||
if path == "" && c.sessionEngineEnabled() {
|
||||
lease, err := c.sessionTemp.Acquire()
|
||||
if err != nil {
|
||||
slog.Warn("controller: open v3 runtime inbox", "err", err)
|
||||
return
|
||||
}
|
||||
c.inbox.tempLease = lease
|
||||
path = filepath.Join(lease.Dir(), "runtime-inbox.jsonl")
|
||||
}
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
st, err := sessioninbox.Open(path, sessioninbox.Limits{})
|
||||
dir, err := c.inboxDirectoryLocked(path, temporary)
|
||||
if err != nil {
|
||||
slog.Warn("controller: open runtime inbox", "err", err)
|
||||
return
|
||||
}
|
||||
st, err := sessioninbox.OpenAt(path, dir, sessioninbox.Limits{})
|
||||
if err != nil {
|
||||
slog.Warn("controller: open session inbox", "err", err, "path", path)
|
||||
return
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/session"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestInboxExpectedSessionCannotSubmitOrConfirmReplacement(t *testing.T) {
|
||||
@@ -43,3 +46,94 @@ func TestInboxExpectedSessionCannotSubmitOrConfirmReplacement(t *testing.T) {
|
||||
t.Fatalf("legacy request no longer works: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalInboxUsesSessionIdentityAcrossQueueOperations(t *testing.T) {
|
||||
service, err := session.NewService("desktop", session.NewFilesystemPersistence(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service})
|
||||
if _, err := c.BindFreshSession(t.Context(), "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const first = "session-id:first"
|
||||
if err := c.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := c.TryEnqueueFollowup(InboxRequest{ExpectedSessionPath: first, Submit: "follow up", Idempotency: "request"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confirmed, found, err := c.LookupInboxReceiptForSession(first, "request")
|
||||
if err != nil || !found || confirmed.ItemID != receipt.ItemID {
|
||||
t.Fatalf("canonical receipt: %+v %v %v", confirmed, found, err)
|
||||
}
|
||||
result, err := c.InboxQueue(first, InboxQueueRequest{Kind: "snapshot"})
|
||||
if err != nil || result.Outcome != "unchanged" || result.Snapshot.SessionPath != first || len(result.Snapshot.Items) != 1 {
|
||||
t.Fatalf("canonical queue: %+v %v", result, err)
|
||||
}
|
||||
result, err = c.InboxQueue(first, InboxQueueRequest{Kind: "read", ItemID: receipt.ItemID})
|
||||
if err != nil || result.Edit == nil || result.Edit.Text != "follow up" {
|
||||
t.Fatalf("canonical queue read: %+v %v", result, err)
|
||||
}
|
||||
if _, err := c.trySteerInboxItemForSession(receipt.ItemID, "old-turn", first); errors.Is(err, ErrInboxSessionChanged) {
|
||||
t.Fatal("same-session steer rejected its identity")
|
||||
}
|
||||
if _, err := c.BindFreshSession(t.Context(), "second"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.EnqueueInbox(InboxRequest{ExpectedSessionPath: first, Submit: "stale"}); !errors.Is(err, ErrInboxSessionChanged) {
|
||||
t.Fatalf("stale canonical enqueue: %v", err)
|
||||
}
|
||||
if _, _, err := c.LookupInboxReceiptForSession(first, "request"); !errors.Is(err, ErrInboxSessionChanged) {
|
||||
t.Fatalf("stale canonical receipt: %v", err)
|
||||
}
|
||||
result, err = c.InboxQueue(first, InboxQueueRequest{Kind: "snapshot"})
|
||||
if err != nil || result.Reason != "session_changed" {
|
||||
t.Fatalf("stale canonical queue: %+v %v", result, err)
|
||||
}
|
||||
if got := c.InboxSnapshot(); got.SessionPath != "session-id:second" || len(got.Items) != 0 {
|
||||
t.Fatalf("queue crossed the session boundary: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalInboxAcceptsMessagesDuringTurnAndDispatchesFIFO(t *testing.T) {
|
||||
service, err := session.NewService("desktop", session.NewFilesystemPersistence(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := &gatedInboxDispatchRunner{inputs: make(chan string, 8), firstStarted: make(chan struct{}), releaseFirst: make(chan struct{})}
|
||||
done := make(chan struct{}, 8)
|
||||
exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
c := newOwnedTestController(t, Options{Executor: exec, Runner: runner, SessionService: service, Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.TurnDone {
|
||||
done <- struct{}{}
|
||||
}
|
||||
})})
|
||||
if _, err := c.BindFreshSession(t.Context(), "active-queue"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Submit("active turn")
|
||||
inputs := &inboxDispatchRunner{inputs: runner.inputs}
|
||||
if got := waitForInboxDispatch(t, c, inputs); got != "active turn" {
|
||||
t.Fatalf("initial input: %q", got)
|
||||
}
|
||||
for _, text := range []string{"queued one", "queued two"} {
|
||||
receipt, err := c.TryEnqueueFollowup(InboxRequest{ExpectedSessionPath: "session-id:active-queue", Submit: text, Idempotency: text})
|
||||
if err != nil || receipt.ItemID == "" {
|
||||
t.Fatalf("running canonical enqueue: %+v %v", receipt, err)
|
||||
}
|
||||
}
|
||||
close(runner.releaseFirst)
|
||||
waitForInboxTurnDone(t, c, done)
|
||||
for _, want := range []string{"queued one", "queued two"} {
|
||||
if got := waitForInboxDispatch(t, c, inputs); got != want {
|
||||
t.Fatalf("canonical FIFO input = %q, want %q", got, want)
|
||||
}
|
||||
waitForInboxTurnDone(t, c, done)
|
||||
}
|
||||
if got := c.InboxSnapshot(); got.SessionPath != "session-id:active-queue" || len(got.Items) != 0 || got.Paused {
|
||||
t.Fatalf("canonical completion left queued work: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,16 @@ func (c *Controller) EnqueueInboxContext(ctx context.Context, req InboxRequest)
|
||||
if intent != sessioninbox.IntentSteer {
|
||||
intent = sessioninbox.IntentFollowup
|
||||
}
|
||||
sessionID := agent.BranchID(st.SessionPath())
|
||||
if id, canonical := strings.CutPrefix(st.SessionPath(), "session-id:"); canonical {
|
||||
sessionID = id
|
||||
}
|
||||
rec, err := st.Enqueue(sessioninbox.EnqueueRequest{
|
||||
Intent: intent,
|
||||
Envelope: env,
|
||||
Source: req.Source,
|
||||
Idempotency: req.Idempotency,
|
||||
SessionID: agent.BranchID(st.SessionPath()),
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/permissionpreset"
|
||||
"reasonix/internal/sandbox"
|
||||
)
|
||||
@@ -97,7 +96,7 @@ func (c *Controller) PermissionSnapshot() PermissionSnapshot {
|
||||
grants = append(grants, SessionGrantSummary{Scope: "command-prefix", Target: target})
|
||||
}
|
||||
return PermissionSnapshot{
|
||||
SessionID: agent.BranchID(c.SessionPath()), Generation: c.runtimeGeneration,
|
||||
SessionID: c.parentSessionID(), Generation: c.runtimeGeneration,
|
||||
Revision: c.permissionRevision.Load(), Preset: c.ToolApprovalMode(),
|
||||
WorkspaceRoot: strings.TrimSpace(c.workspaceRoot), Grants: grants,
|
||||
Capabilities: platformPermissionCapabilities(),
|
||||
|
||||
@@ -196,7 +196,7 @@ func (c *Controller) refreshRuntimeStateAttempt(e event.Event, attempt int) {
|
||||
}
|
||||
next.BackgroundJobs = 0
|
||||
if c.jobs != nil {
|
||||
next.BackgroundJobs = len(c.jobs.RunningForSession(agent.BranchID(path)))
|
||||
next.BackgroundJobs = len(c.jobs.RunningForSession(c.parentSessionID()))
|
||||
}
|
||||
// Sampling owners is off their locks. Do not commit a mixture if the
|
||||
// admission/close/binding boundary advanced while another owner was read.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// RedactJSON scrubs diagnostic text without applying text replacements to JSON
|
||||
// syntax or numeric counters. UseNumber preserves sequence numbers exactly.
|
||||
func RedactJSON(data []byte) ([]byte, error) {
|
||||
if !json.Valid(data) {
|
||||
return nil, errors.New("invalid diagnostic JSON")
|
||||
}
|
||||
var value any
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(redactJSONValue(value))
|
||||
}
|
||||
|
||||
func redactJSONValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return Redact(v)
|
||||
case []any:
|
||||
for i := range v {
|
||||
v[i] = redactJSONValue(v[i])
|
||||
}
|
||||
case map[string]any:
|
||||
for key, child := range v {
|
||||
if text, ok := child.(string); ok && credentialTextKeySensitive(key) && text != "" {
|
||||
v[key] = redactedValue
|
||||
} else {
|
||||
v[key] = redactJSONValue(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestRedactJSONPreservesStructureAndCounters(t *testing.T) {
|
||||
input := []byte(`{"token_count":9007199254740993,"api_key":"a secret with spaces","nested":[{"output":"TOKEN=这是很长的中文测试凭证内容\nCookie: sid=abc; secure\npassword=\"escape-me\""}],"empty":[],"flag":true}`)
|
||||
got, err := RedactJSON(input)
|
||||
if err != nil || !json.Valid(got) || !utf8.Valid(got) {
|
||||
t.Fatalf("invalid diagnostic JSON: %q, %v", got, err)
|
||||
}
|
||||
for _, secret := range []string{"a secret with spaces", "escape-me", "sid=abc", "这是很长的中文测试凭证内容"} {
|
||||
if strings.Contains(string(got), secret) {
|
||||
t.Fatalf("diagnostic leaked %q", secret)
|
||||
}
|
||||
}
|
||||
for _, preserved := range []string{`"token_count":9007199254740993`, `"empty":[]`, `"flag":true`} {
|
||||
if !strings.Contains(string(got), preserved) {
|
||||
t.Fatalf("lost structural value %s: %s", preserved, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactJSONRejectsInvalidDocument(t *testing.T) {
|
||||
for _, input := range []string{`{} {}`, `{"broken":`, `not json`} {
|
||||
if _, err := RedactJSON([]byte(input)); err == nil {
|
||||
t.Fatalf("accepted invalid document %q", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
// TestSessionsFoldsEngineMirrorOfLegacyTranscript pins the listing fold: the
|
||||
// engine mirrors an in-flight legacy transcript into a final-format event log
|
||||
// keyed by the legacy branch id; the /sessions view must keep one row for that
|
||||
// conversation instead of a transcript row plus its mirror.
|
||||
func TestSessionsFoldsEngineMirrorOfLegacyTranscript(t *testing.T) {
|
||||
legacyDir := t.TempDir()
|
||||
legacy := filepath.Join(legacyDir, "mirror-me.jsonl")
|
||||
if err := os.WriteFile(legacy, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v4Root := filepath.Join(t.TempDir(), "sessions-v4")
|
||||
persistence := session.NewFilesystemPersistence(v4Root)
|
||||
mirror, err := persistence.Create(session.CreateOptions{SessionID: "mirror-me"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mirror.Close(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := session.NewService("serve-test", persistence)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := service.Shutdown(context.Background()); err != nil {
|
||||
t.Errorf("shutdown session service: %v", err)
|
||||
}
|
||||
})
|
||||
exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true})
|
||||
if _, err := ctrl.BindFreshSession(t.Context(), "foreground"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
srv := newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{})
|
||||
recorder := httptest.NewRecorder()
|
||||
srv.sessions(recorder, httptest.NewRequest(http.MethodGet, "/sessions", nil))
|
||||
var rows []sessionListEntry
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyKey := agent.CanonicalSessionPath(legacy)
|
||||
sawLegacy, sawMirror := false, false
|
||||
for _, row := range rows {
|
||||
if agent.CanonicalSessionPath(row.Path) == legacyKey {
|
||||
sawLegacy = true
|
||||
}
|
||||
if row.SessionID == "mirror-me" {
|
||||
sawMirror = true
|
||||
}
|
||||
}
|
||||
if !sawLegacy {
|
||||
t.Fatalf("legacy row missing from listing: %+v", rows)
|
||||
}
|
||||
if sawMirror {
|
||||
t.Fatalf("engine mirror listed beside its transcript: %+v", rows)
|
||||
}
|
||||
retireExclusiveForeground(t, ctrl, service)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -451,61 +450,6 @@ func TestHistoryIdentityRouteDetectsRuntimeChangeDuringRead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionsFoldsEngineMirrorOfLegacyTranscript pins the listing fold: the
|
||||
// engine mirrors an in-flight legacy transcript into a final-format event log
|
||||
// keyed by the legacy branch id; the /sessions view must keep one row for that
|
||||
// conversation instead of a transcript row plus its mirror.
|
||||
func TestSessionsFoldsEngineMirrorOfLegacyTranscript(t *testing.T) {
|
||||
legacyDir := t.TempDir()
|
||||
legacy := filepath.Join(legacyDir, "mirror-me.jsonl")
|
||||
if err := os.WriteFile(legacy, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v4Root := filepath.Join(t.TempDir(), "sessions-v4")
|
||||
persistence := session.NewFilesystemPersistence(v4Root)
|
||||
mirror, err := persistence.Create(session.CreateOptions{SessionID: "mirror-me"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mirror.Close(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := session.NewService("serve-test", persistence)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true})
|
||||
if _, err := ctrl.BindFreshSession(t.Context(), "foreground"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
srv := newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{})
|
||||
recorder := httptest.NewRecorder()
|
||||
srv.sessions(recorder, httptest.NewRequest(http.MethodGet, "/sessions", nil))
|
||||
var rows []sessionListEntry
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyKey := agent.CanonicalSessionPath(legacy)
|
||||
sawLegacy, sawMirror := false, false
|
||||
for _, row := range rows {
|
||||
if agent.CanonicalSessionPath(row.Path) == legacyKey {
|
||||
sawLegacy = true
|
||||
}
|
||||
if row.SessionID == "mirror-me" {
|
||||
sawMirror = true
|
||||
}
|
||||
}
|
||||
if !sawLegacy {
|
||||
t.Fatalf("legacy row missing from listing: %+v", rows)
|
||||
}
|
||||
if sawMirror {
|
||||
t.Fatalf("engine mirror listed beside its transcript: %+v", rows)
|
||||
}
|
||||
retireExclusiveForeground(t, ctrl, service)
|
||||
}
|
||||
|
||||
// TestIdentityStatusAnswersFreeWriterWithRouteMatch pins the serve-restart
|
||||
// recovery: a spectator identity whose writer exited and whose mirror entry
|
||||
// was lost to the restart must get an explicit route-matching status with
|
||||
|
||||
@@ -409,6 +409,9 @@ func firstNonEmpty(vals ...string) string {
|
||||
}
|
||||
|
||||
func agentBranchID(sessionPath string) string {
|
||||
if id, canonical := strings.CutPrefix(sessionPath, "session-id:"); canonical {
|
||||
return id
|
||||
}
|
||||
base := filepath.Base(sessionPath)
|
||||
return strings.TrimSuffix(base, ".jsonl")
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ const (
|
||||
maxManifestBytes = 8 << 20
|
||||
)
|
||||
|
||||
// Store is the transactional durable inbox for one session path.
|
||||
// Store is the transactional durable inbox for one session locator.
|
||||
// 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
|
||||
session string // logical locator: transcript path or canonical session route
|
||||
runID string
|
||||
limits Limits
|
||||
man *manifest
|
||||
@@ -43,11 +43,19 @@ type Store struct {
|
||||
// for its transaction lock; body blobs remain lazy. Cross-process recovery
|
||||
// marks uncertain items and pauses.
|
||||
func Open(sessionPath string, limits Limits) (*Store, error) {
|
||||
return OpenAt(sessionPath, store.SessionInboxDir(sessionPath), limits)
|
||||
}
|
||||
|
||||
// OpenAt separates the logical session locator from its physical inbox directory.
|
||||
// Canonical runtimes have an immutable identity, not a transcript file path.
|
||||
func OpenAt(sessionPath, dir string, limits Limits) (*Store, error) {
|
||||
sessionPath = strings.TrimSpace(sessionPath)
|
||||
if sessionPath == "" {
|
||||
return nil, fmt.Errorf("sessioninbox: empty session path")
|
||||
}
|
||||
dir := store.SessionInboxDir(sessionPath)
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, fmt.Errorf("sessioninbox: empty inbox directory")
|
||||
}
|
||||
s := &Store{
|
||||
dir: dir,
|
||||
session: sessionPath,
|
||||
@@ -71,7 +79,7 @@ func (s *Store) Dir() string {
|
||||
return s.dir
|
||||
}
|
||||
|
||||
// SessionPath returns the bound session transcript path.
|
||||
// SessionPath returns the bound logical session locator.
|
||||
func (s *Store) SessionPath() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package sessioninbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
func TestOpenAtSeparatesLocatorFromStorageWithoutChangingFormat(t *testing.T) {
|
||||
storagePath := filepath.Join(t.TempDir(), "runtime-inbox.jsonl")
|
||||
dir := store.SessionInboxDir(storagePath)
|
||||
const locator = "session-id:canonical.with.dots"
|
||||
s, err := OpenAt(locator, dir, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
receipt, err := s.Enqueue(EnqueueRequest{Intent: IntentFollowup, Idempotency: "request", Envelope: PromptEnvelope{SubmitText: "queued"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.SessionPath() != locator || s.Snapshot().SessionPath != locator || s.Dir() != dir {
|
||||
t.Fatal("logical locator was replaced by its storage path")
|
||||
}
|
||||
if meta, _, err := s.ReadItem(receipt.ItemID); err != nil || meta.SessionID != "canonical.with.dots" {
|
||||
t.Fatalf("default metadata lost the canonical session identity: %+v %v", meta, err)
|
||||
}
|
||||
s.Close()
|
||||
// An old path-based reader can still read the same manifest and blobs.
|
||||
legacy, err := Open(storagePath, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer legacy.Close()
|
||||
if got, found := legacy.LookupReceipt("request"); !found || got.ItemID != receipt.ItemID {
|
||||
t.Fatalf("legacy reader lost receipt: %+v %v", got, found)
|
||||
}
|
||||
if _, env, err := legacy.ReadItem(receipt.ItemID); err != nil || env.SubmitText != "queued" {
|
||||
t.Fatalf("legacy reader lost body: %+v %v", env, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user