diff --git a/CHANGELOG.md b/CHANGELOG.md index 56185239a7..0bbb0a1df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ branch. ### Fixed +- Goal is now the sole long-task runtime. Historical AutoResearch sidecars + migrate transactionally into research-budget Goals. Invalid archives block + fail closed and remain read-only; successful Goal-only sidecars omit the old + task id and write an explicit downgrade fence so previous readers cannot + reactivate the removed runtime. + - **Issue #7575:** Linux Bash under bubblewrap no longer mounts a fresh empty `--tmpfs /tmp` on every call. Consecutive commands in the same logical session now share a private temporary directory (bound at `/tmp` on Linux, exported via diff --git a/desktop/app.go b/desktop/app.go index 2dd49d7bd1..9561c2e192 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -33,7 +33,6 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" "reasonix/internal/agent" - "reasonix/internal/autoresearch" "reasonix/internal/billing" "reasonix/internal/boot" "reasonix/internal/botruntime" @@ -6927,26 +6926,25 @@ func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView { // Meta describes the session for the frontend's header and status line. type Meta struct { - Label string `json:"label"` - Ready bool `json:"ready"` - Runtime SessionRuntimeView `json:"runtime"` - StartupErr string `json:"startupErr,omitempty"` - EventChannel string `json:"eventChannel"` - Cwd string `json:"cwd"` - WorkspaceRoot string `json:"workspaceRoot,omitempty"` - WorkspaceName string `json:"workspaceName,omitempty"` - WorkspacePath string `json:"workspacePath,omitempty"` - GitBranch string `json:"gitBranch,omitempty"` - ImageInputEnabled bool `json:"imageInputEnabled"` - AutoApproveTools bool `json:"autoApproveTools"` - Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval - CollaborationMode string `json:"collaborationMode"` - ToolApprovalMode string `json:"toolApprovalMode"` - TokenMode string `json:"tokenMode"` - Goal string `json:"goal,omitempty"` - GoalStatus string `json:"goalStatus,omitempty"` - GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"` - AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"` + Label string `json:"label"` + Ready bool `json:"ready"` + Runtime SessionRuntimeView `json:"runtime"` + StartupErr string `json:"startupErr,omitempty"` + EventChannel string `json:"eventChannel"` + Cwd string `json:"cwd"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + WorkspaceName string `json:"workspaceName,omitempty"` + WorkspacePath string `json:"workspacePath,omitempty"` + GitBranch string `json:"gitBranch,omitempty"` + ImageInputEnabled bool `json:"imageInputEnabled"` + AutoApproveTools bool `json:"autoApproveTools"` + Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval + CollaborationMode string `json:"collaborationMode"` + ToolApprovalMode string `json:"toolApprovalMode"` + TokenMode string `json:"tokenMode"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` + GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"` // A nil pointer means the controller cannot provide an authoritative snapshot; // a non-nil pointer preserves an empty list as an explicit panel clear. CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"` @@ -6983,60 +6981,6 @@ func goalRuntimeViewFromController(ctrl control.SessionAPI) *GoalRuntimeView { } } -type AutoResearchCompactView struct { - TaskID string `json:"taskId"` - Status string `json:"status"` - Iteration int `json:"iteration"` - PivotRequired bool `json:"pivotRequired"` - StaleCount int `json:"staleCount"` -} - -type AutoResearchCriterionView struct { - ID string `json:"id"` - Description string `json:"description"` - Required bool `json:"required"` - EvidenceCount int `json:"evidenceCount"` - Status string `json:"status"` -} - -type AutoResearchStatusView struct { - TaskID string `json:"taskId"` - Goal string `json:"goal"` - Status string `json:"status"` - Iteration int `json:"iteration"` - CurrentDirection string `json:"currentDirection"` - StaleCount int `json:"staleCount"` - PivotCount int `json:"pivotCount"` - PivotRequired bool `json:"pivotRequired"` - LastHeartbeatAt string `json:"lastHeartbeatAt"` - FindingCount int `json:"findingCount"` - OpenCriteria []AutoResearchCriterionView `json:"openCriteria"` - Blocker string `json:"blocker"` - TaskPath string `json:"taskPath"` - NextRequiredAction string `json:"nextRequiredAction"` -} - -type AutoResearchFindingView struct { - ID string `json:"id"` - Kind string `json:"kind"` - Summary string `json:"summary"` - Source string `json:"source"` - Command string `json:"command,omitempty"` - Paths []string `json:"paths,omitempty"` - Accepted bool `json:"accepted"` - CreatedAt string `json:"createdAt"` -} - -type AutoResearchEvidenceView struct { - ID string `json:"id"` - Kind string `json:"kind"` - Summary string `json:"summary"` - Source string `json:"source"` - Command string `json:"command,omitempty"` - Paths []string `json:"paths,omitempty"` - Accepted bool `json:"accepted"` -} - // Meta reports the model label, readiness, any startup error, the working // directory (for the status line), and the runtime event channel the frontend // subscribes to. @@ -7112,29 +7056,10 @@ func (a *App) MetaForTab(tabID string) Meta { Goal: goal, GoalStatus: goalStatus, GoalRuntime: goalRuntimeViewFromController(snap.ctrl), - AutoResearch: compactAutoResearchFromController(snap.ctrl), CanonicalTodos: ctrlTodos(snap.ctrl), } } -func compactAutoResearchFromController(ctrl control.SessionAPI) *AutoResearchCompactView { - if ctrl == nil { - return nil - } - - summary, ok := ctrl.AutoResearchSummary() - if !ok || summary == nil || summary.TaskID == "" { - return nil - } - return &AutoResearchCompactView{ - TaskID: summary.TaskID, - Status: summary.Status, - Iteration: summary.Iteration, - PivotRequired: summary.PivotRequired, - StaleCount: summary.StaleCount, - } -} - // ctrlTodos returns the canonical task list from a session controller, or nil // if the controller is not yet bound. Used by MetaForTab so the frontend // task panel has access to the authoritative server-side todo state. @@ -7149,136 +7074,6 @@ func ctrlTodos(ctrl control.SessionAPI) *[]evidence.TodoItem { return &todos } -func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView { - if tab == nil || tab.Ctrl == nil { - return nil - } - summary, ok := tab.Ctrl.AutoResearchSummary() - if !ok || summary == nil || summary.TaskID == "" { - return nil - } - return &AutoResearchCompactView{ - TaskID: summary.TaskID, - Status: summary.Status, - Iteration: summary.Iteration, - PivotRequired: summary.PivotRequired, - StaleCount: summary.StaleCount, - } -} - -func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView { - if summary == nil { - return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} - } - open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria)) - for _, criterion := range summary.OpenCriteria { - open = append(open, AutoResearchCriterionView{ - ID: criterion.ID, - Description: criterion.Description, - Required: criterion.Required, - EvidenceCount: criterion.EvidenceCount, - Status: criterion.Status, - }) - } - return AutoResearchStatusView{ - TaskID: summary.TaskID, - Goal: summary.Goal, - Status: summary.Status, - Iteration: summary.Iteration, - CurrentDirection: summary.CurrentDirection, - StaleCount: summary.StaleCount, - PivotCount: summary.PivotCount, - PivotRequired: summary.PivotRequired, - LastHeartbeatAt: summary.LastHeartbeatAt.Format(time.RFC3339), - FindingCount: summary.FindingCount, - OpenCriteria: open, - Blocker: summary.Blocker, - TaskPath: summary.TaskPath, - NextRequiredAction: summary.NextRequiredAction, - } -} - -func (a *App) AutoResearchCurrent() AutoResearchStatusView { - return a.AutoResearchStatus("") -} - -func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} - } - summary, ok := ctrl.AutoResearchSummary() - if !ok { - return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} - } - return autoResearchStatusView(summary) -} - -func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return []AutoResearchStatusView{} - } - summaries, ok := ctrl.AutoResearchList() - if !ok { - return []AutoResearchStatusView{} - } - out := make([]AutoResearchStatusView, 0, len(summaries)) - for i := range summaries { - out = append(out, autoResearchStatusView(&summaries[i])) - } - return out -} - -func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return []AutoResearchFindingView{} - } - findings, ok := ctrl.AutoResearchFindings(limit) - if !ok { - return []AutoResearchFindingView{} - } - out := make([]AutoResearchFindingView, 0, len(findings)) - for _, finding := range findings { - out = append(out, AutoResearchFindingView{ - ID: finding.ID, - Kind: finding.Kind, - Summary: finding.Summary, - Source: finding.Source, - Command: finding.Command, - Paths: append([]string(nil), finding.Paths...), - Accepted: finding.Accepted, - CreatedAt: finding.CreatedAt.Format(time.RFC3339), - }) - } - return out -} - -func (a *App) AutoResearchOpenTask(tabID string) error { - status := a.AutoResearchStatus(tabID) - if strings.TrimSpace(status.TaskPath) == "" { - return os.ErrInvalid - } - return a.RevealPath(status.TaskPath) -} - -func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return os.ErrInvalid - } - return ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{ - ID: input.ID, - Kind: input.Kind, - Summary: input.Summary, - Source: input.Source, - Command: input.Command, - Paths: append([]string(nil), input.Paths...), - Accepted: input.Accepted, - }) -} - func (a *App) SetGoal(goal string) error { return a.SetGoalForTab("", goal) } diff --git a/desktop/bound_array_contract_test.go b/desktop/bound_array_contract_test.go index c479b3d20d..3d45308244 100644 --- a/desktop/bound_array_contract_test.go +++ b/desktop/bound_array_contract_test.go @@ -29,8 +29,6 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) { {"ListTabs", app.ListTabs()}, {"ListProjectTree", app.ListProjectTree()}, {"AvailableSubagentTools", app.AvailableSubagentTools()}, - {"AutoResearchList", app.AutoResearchList("missing")}, - {"AutoResearchFindings", app.AutoResearchFindings("missing", 10)}, {"MCPServers", app.MCPServers()}, {"Plugins", app.Plugins()}, {"HeartbeatListTasks", app.HeartbeatListTasks()}, diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index ab2c26d0af..a3e2bdf9b8 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -252,8 +252,6 @@ function noticePreviewItems(): Item[] { notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"), notice(9, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"), notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"), - notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"), - notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"), notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"), notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"), notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"), @@ -299,7 +297,7 @@ const CHAT_MIN_WIDTH = 400; const CHAT_COMFORT_MIN_WIDTH = 560; const WORKSPACE_RESIZER_WIDTH = 8; -function stripGoalResearchFlags(arg: string): string { +function stripLegacyGoalBudgetFlags(arg: string): string { const parts = arg.trim().split(/\s+/).filter(Boolean); while (parts.length > 0) { const flag = parts[0].toLowerCase(); @@ -309,7 +307,7 @@ function stripGoalResearchFlags(arg: string): string { return parts.join(" "); } -function hasGoalResearchFlag(arg: string): boolean { +function hasLegacyGoalBudgetFlag(arg: string): boolean { const first = arg.trim().split(/\s+/, 1)[0]?.toLowerCase(); return first === "--research" || first === "--auto-research" || first === "--deep" || first === "--simple" || first === "--no-research"; } @@ -2380,9 +2378,9 @@ export default function App() { const goalCommand = /^\/goal(?:\s+(.*))?$/.exec(trimmed); if (goalCommand) { const arg = (goalCommand[1] ?? "").trim(); - const displayGoal = stripGoalResearchFlags(arg); + const displayGoal = stripLegacyGoalBudgetFlags(arg); if (displayGoal && !["status", "clear", "off", "stop", "done"].includes(displayGoal.toLowerCase())) { - if (hasGoalResearchFlag(arg)) { + if (hasLegacyGoalBudgetFlag(arg)) { userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false); patchActiveComposerProfile({ collaborationMode: "goal", diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 157ff161fe..cdfbdb426d 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -19,9 +19,6 @@ import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normal import { decisionSurfaceMockFromInput, isLongDecisionOptionsMockInput } from "./decisionSurfaceMock"; import type { - AutoResearchFindingView, - AutoResearchEvidenceView, - AutoResearchStatusView, RemoteHostView, RemoteHostInput, RemoteConnectionStatus, @@ -127,7 +124,7 @@ import type { const GLOBAL_PROJECT_ORDER_KEY = "__global__"; -function stripGoalResearchFlags(arg: string): string { +function stripLegacyGoalBudgetFlags(arg: string): string { const parts = arg.trim().split(/\s+/).filter(Boolean); while (parts.length > 0) { const flag = parts[0].toLowerCase(); @@ -319,12 +316,6 @@ export interface AppBindings { ToolResultForTab(tabID: string, toolID: string): Promise<{ args: string; output: string; execution?: import("./types").WireShellExecution } | null>; Meta(): Promise; MetaForTab(tabID: string): Promise; - AutoResearchCurrent(): Promise; - AutoResearchStatus(tabID: string): Promise; - AutoResearchList(tabID: string): Promise; - AutoResearchFindings(tabID: string, limit: number): Promise; - AutoResearchOpenTask(tabID: string): Promise; - AutoResearchRecordEvidence(tabID: string, criterionID: string, input: AutoResearchEvidenceView): Promise; Commands(): Promise; Capabilities(): Promise; MCPServers(): Promise; @@ -2426,7 +2417,7 @@ function makeMockApp(): AppBindings { const decisionSurfaceMock = decisionSurfaceMockFromInput(trimmedInput); const goalMatch = /^\/goal(?:\s+([\s\S]*))?$/.exec(input.trim()); if (goalMatch) { - const arg = stripGoalResearchFlags((goalMatch[1] ?? "").trim()); + const arg = stripLegacyGoalBudgetFlags((goalMatch[1] ?? "").trim()); const lowered = arg.toLowerCase(); const active = mockTabs.find((tab) => tab.active); if (!arg || lowered === "status") { @@ -3384,7 +3375,6 @@ function makeMockApp(): AppBindings { tokenMode: normalizeTokenMode(active?.tokenMode), goal: active?.goal ?? "", goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"), - autoResearch: active?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined, }; }, async MetaForTab(tabID) { @@ -3410,80 +3400,8 @@ function makeMockApp(): AppBindings { tokenMode: normalizeTokenMode(tab?.tokenMode), goal: tab?.goal ?? "", goalStatus: tab?.goalStatus ?? (tab?.goal ? "running" : "stopped"), - autoResearch: tab?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined, }; }, - async AutoResearchCurrent() { - return { - taskId: "mock-autoresearch", - goal: "Mock long-running research", - status: "running", - iteration: 4, - currentDirection: "Inspect status chip", - staleCount: 0, - pivotCount: 0, - pivotRequired: false, - lastHeartbeatAt: "2026-06-29T00:00:00Z", - findingCount: 1, - openCriteria: [], - blocker: "", - taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch", - nextRequiredAction: "continue with the next evidence-producing step", - }; - }, - async AutoResearchStatus(_tabID) { - return { - taskId: "mock-autoresearch", - goal: "Mock long-running research", - status: "running", - iteration: 4, - currentDirection: "Inspect status chip", - staleCount: 0, - pivotCount: 0, - pivotRequired: false, - lastHeartbeatAt: "2026-06-29T00:00:00Z", - findingCount: 1, - openCriteria: [], - blocker: "", - taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch", - nextRequiredAction: "continue with the next evidence-producing step", - }; - }, - async AutoResearchList(_tabID) { - return [{ - taskId: "mock-autoresearch", - goal: "Mock long-running research", - status: "running", - iteration: 4, - currentDirection: "Inspect status chip", - staleCount: 0, - pivotCount: 0, - pivotRequired: false, - lastHeartbeatAt: "2026-06-29T00:00:00Z", - findingCount: 1, - openCriteria: [], - blocker: "", - taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch", - nextRequiredAction: "continue with the next evidence-producing step", - }]; - }, - async AutoResearchFindings(_tabID, limit) { - return [{ - id: "f1", - kind: "test", - summary: "Mock accepted finding", - source: "command", - command: "go test ./...", - accepted: true, - createdAt: "2026-06-29T00:00:00Z", - }].slice(0, Math.max(0, limit || 1)); - }, - async AutoResearchOpenTask(_tabID) { - console.info("mock AutoResearchOpenTask"); - }, - async AutoResearchRecordEvidence(_tabID, _criterionID, _input) { - console.info("mock AutoResearchRecordEvidence"); - }, async Commands() { const commands: CommandInfo[] = [ { name: "new", description: "start new session; save transcript", kind: "builtin" as const, group: "actions" }, diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 009bc6c777..ea9948e11f 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -367,7 +367,6 @@ export interface TabMeta { tokenMode?: TokenMode; goal?: string; goalStatus?: GoalStatus; - autoResearch?: AutoResearchCompactView; recovered?: boolean; recoveryReason?: string; recoveryDigest?: string; @@ -807,7 +806,6 @@ export interface Meta { goal?: string; goalStatus?: GoalStatus; goalRuntime?: GoalRuntime; - autoResearch?: AutoResearchCompactView; canonicalTodos?: Todo[]; } @@ -832,55 +830,6 @@ export interface GoalRuntime { budgetExtensions: number; } -export interface AutoResearchCompactView { - taskId: string; - status: "running" | "blocked" | "complete" | "stopped" | "invalid"; - iteration: number; - pivotRequired: boolean; - staleCount: number; -} - -export interface AutoResearchCriterionView { - id: string; - description: string; - required: boolean; - evidenceCount: number; - status: string; -} - -export interface AutoResearchStatusView extends AutoResearchCompactView { - goal: string; - currentDirection: string; - pivotCount: number; - lastHeartbeatAt: string; - findingCount: number; - openCriteria: AutoResearchCriterionView[]; - blocker: string; - taskPath: string; - nextRequiredAction: string; -} - -export interface AutoResearchFindingView { - id: string; - kind: string; - summary: string; - source: string; - command?: string; - paths?: string[]; - accepted: boolean; - createdAt: string; -} - -export interface AutoResearchEvidenceView { - id: string; - kind: string; - summary: string; - source: string; - command?: string; - paths?: string[]; - accepted: boolean; -} - export function normalizeCollaborationMode(mode?: string, goal?: string, legacyMode?: Mode): CollaborationMode { if (mode === "plan" || mode === "goal" || mode === "normal") return mode; if (legacyMode && modeHasPlan(legacyMode)) return "plan"; diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 6a0d99e7e2..8b046af217 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -2336,10 +2336,6 @@ function backendNoticeKey(msg: string): DictKey | "" { return "notice.goalNotReady"; case "Goal still has unfinished task state; continuing the remaining work.": return "notice.goalUnfinished"; - case "AutoResearch status update failed.": - return "notice.autoresearchStatusFailed"; - case "AutoResearch task marked blocked.": - return "notice.autoresearchBlocked"; case "Job artifact migration failed.": return "notice.jobArtifactMigrationFailed"; case "Background job teardown timed out.": diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 65b94c3faf..2c3c58d310 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2864,8 +2864,6 @@ export const en = { "notice.compactionNoSummary": "Context was compacted without a generated summary.", "notice.goalNotReady": "Goal is not ready to complete yet; continuing the remaining work.", "notice.goalUnfinished": "Goal still has unfinished task state; continuing the remaining work.", - "notice.autoresearchStatusFailed": "AutoResearch status update failed.", - "notice.autoresearchBlocked": "AutoResearch task marked blocked.", "notice.backgroundJobFailed": "Background {kind} needs attention.", "notice.jobArtifactMigrationFailed": "Job artifact migration failed.", "notice.jobTeardownTimeout": "Background job teardown timed out.", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 9555adc4ce..fc70c4d2a2 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1976,8 +1976,6 @@ export const zhTW: Record = { "notice.compactionNoSummary": "上下文已壓縮,但未產生摘要。", "notice.goalNotReady": "目標暫未達到完成條件,繼續處理剩餘工作。", "notice.goalUnfinished": "目標還有未完成的任務狀態,繼續處理。", - "notice.autoresearchStatusFailed": "AutoResearch 狀態更新失敗。", - "notice.autoresearchBlocked": "AutoResearch 任務已標記為阻塞。", "notice.backgroundJobFailed": "背景 {kind} 需要處理。", "notice.jobArtifactMigrationFailed": "任務產物遷移失敗。", "notice.jobTeardownTimeout": "背景任務清理逾時。", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 513a8a8460..8a1cad9187 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2867,8 +2867,6 @@ export const zh: Record = { "notice.compactionNoSummary": "上下文已压缩,但未生成摘要。", "notice.goalNotReady": "目标暂未达到完成条件,继续处理剩余工作。", "notice.goalUnfinished": "目标还有未完成的任务状态,继续处理。", - "notice.autoresearchStatusFailed": "AutoResearch 状态更新失败。", - "notice.autoresearchBlocked": "AutoResearch 任务已标记为阻塞。", "notice.backgroundJobFailed": "后台 {kind} 需要处理。", "notice.jobArtifactMigrationFailed": "任务产物迁移失败。", "notice.jobTeardownTimeout": "后台任务清理超时。", diff --git a/desktop/goal_delivery_yolo_test.go b/desktop/goal_delivery_yolo_test.go index 537c8a0cdd..3161496807 100644 --- a/desktop/goal_delivery_yolo_test.go +++ b/desktop/goal_delivery_yolo_test.go @@ -54,8 +54,8 @@ func newGoalDeliveryYoloTestApp(t *testing.T, goalStatus string) (*App, *Workspa state := map[string]any{ "goal": "ship the combined mode", "status": goalStatus, - "researchMode": control.GoalResearchOn, - "autoResearchTaskID": "research-task-1", + "budgetClass": "research", + "turnsLimit": 40, "scopeID": checkpoint.ScopeID, "deliveryCheckpoint": checkpoint, } @@ -227,7 +227,7 @@ func TestPlanWinsRunningGoalConflictDuringDeliveryRebuild(t *testing.T) { } } -func TestRunningGoalDeliveryYoloRebuildKeepsScopeAndAutoResearch(t *testing.T) { +func TestRunningGoalDeliveryYoloRebuildKeepsUnifiedGoalScope(t *testing.T) { app, tab, oldCtrl, path := newGoalDeliveryYoloTestApp(t, control.GoalStatusRunning) if err := app.SetTokenModeForTab(tab.ID, boot.TokenModeDelivery); err != nil { t.Fatalf("SetTokenModeForTab: %v", err) @@ -251,7 +251,7 @@ func TestRunningGoalDeliveryYoloRebuildKeepsScopeAndAutoResearch(t *testing.T) { if err := json.Unmarshal(data, &persisted); err != nil { t.Fatal(err) } - if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "research-task-1" { + if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "" { t.Fatalf("restored Goal identity = %+v", persisted) } if persisted.DeliveryCheckpoint.ScopeID != persisted.ScopeID || !persisted.DeliveryCheckpoint.PendingMutation { @@ -336,7 +336,7 @@ func TestGoalDeliveryYoloSurvivesEveryControllerRebuildPath(t *testing.T) { if err := json.Unmarshal(data, &persisted); err != nil { t.Fatalf("decode Goal sidecar: %v", err) } - if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "research-task-1" { + if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "" { t.Fatalf("restored Goal identity = %+v", persisted) } if persisted.DeliveryCheckpoint.ScopeID != persisted.ScopeID || !persisted.DeliveryCheckpoint.PendingMutation { diff --git a/desktop/tab_profile_test.go b/desktop/tab_profile_test.go index 2c2b9abe70..ada38eb429 100644 --- a/desktop/tab_profile_test.go +++ b/desktop/tab_profile_test.go @@ -490,177 +490,6 @@ func TestMetaReportsGoalStatus(t *testing.T) { } } -func TestAutoResearchStatusSurfaceForActiveTab(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("identify the root cause", control.GoalResearchOn) - - meta := app.MetaForTab(tab.ID) - if meta.AutoResearch == nil || meta.AutoResearch.TaskID == "" { - t.Fatalf("MetaForTab AutoResearch = %+v, want compact active task summary", meta.AutoResearch) - } - if meta.AutoResearch.Status != control.GoalStatusRunning || meta.AutoResearch.Iteration != 0 { - t.Fatalf("compact AutoResearch summary = %+v", meta.AutoResearch) - } - - current := app.AutoResearchCurrent() - if current.TaskID != meta.AutoResearch.TaskID || current.Goal != "identify the root cause" { - t.Fatalf("AutoResearchCurrent = %+v, want task %q", current, meta.AutoResearch.TaskID) - } - if current.TaskPath == "" || current.Status != control.GoalStatusRunning { - t.Fatalf("AutoResearchCurrent missing status/path: %+v", current) - } - heartbeatPath := filepath.Join(root, ".reasonix", "autoresearch", current.TaskID, "logs", "heartbeat.jsonl") - if err := os.WriteFile(heartbeatPath, []byte(`{"status":"turn_done","iteration":1,"created_at":"2026-06-30T00:00:00Z"}`+"\n"), 0o644); err != nil { - t.Fatalf("write heartbeat: %v", err) - } - current = app.AutoResearchCurrent() - if current.LastHeartbeatAt != "2026-06-30T00:00:00Z" || current.NextRequiredAction == "" { - t.Fatalf("AutoResearchCurrent missing runtime fields: %+v", current) - } - - tabs := app.ListTabs() - if len(tabs) != 1 || tabs[0].AutoResearch == nil || tabs[0].AutoResearch.TaskID != current.TaskID { - t.Fatalf("ListTabs AutoResearch = %+v, want compact summary for task %q", tabs, current.TaskID) - } -} - -func TestAutoResearchFindingsAreLoadedOnDemand(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("collect accepted findings", control.GoalResearchOn) - current := app.AutoResearchCurrent() - if current.TaskID == "" { - t.Fatal("expected active AutoResearch task") - } - findingsPath := filepath.Join(root, ".reasonix", "autoresearch", current.TaskID, "state", "findings.jsonl") - if err := os.WriteFile(findingsPath, []byte( - `{"id":"f1","kind":"test","summary":"old","source":"command","command":"go test ./...","accepted":true,"created_at":"2026-06-29T10:00:00Z"}`+"\n"+ - `{"id":"f2","kind":"review","summary":"new","source":"manual","accepted":true,"created_at":"2026-06-29T11:00:00Z"}`+"\n", - ), 0o644); err != nil { - t.Fatalf("write findings: %v", err) - } - - findings := app.AutoResearchFindings(tab.ID, 1) - if len(findings) != 1 || findings[0].ID != "f2" || findings[0].Summary != "new" { - t.Fatalf("AutoResearchFindings = %+v, want newest capped finding", findings) - } - if meta := app.MetaForTab(tab.ID); meta.AutoResearch == nil || meta.AutoResearch.TaskID != current.TaskID { - t.Fatalf("MetaForTab compact summary lost task id: %+v", meta.AutoResearch) - } -} - -func TestAutoResearchListReturnsWorkspaceTasks(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("list active research task", control.GoalResearchOn) - list := app.AutoResearchList(tab.ID) - if len(list) != 1 || list[0].TaskID == "" || list[0].Goal != "list active research task" { - t.Fatalf("AutoResearchList = %+v, want active workspace task", list) - } -} - -func TestAutoResearchOpenTaskRevealsTaskDirectory(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("open task folder", control.GoalResearchOn) - current := app.AutoResearchCurrent() - var revealed string - oldReveal := revealPath - revealPath = func(path string) error { - revealed = path - return nil - } - defer func() { revealPath = oldReveal }() - - if err := app.AutoResearchOpenTask(tab.ID); err != nil { - t.Fatalf("AutoResearchOpenTask: %v", err) - } - if revealed != current.TaskPath { - t.Fatalf("revealed path = %q, want task path %q", revealed, current.TaskPath) - } -} - -func TestAutoResearchRecordEvidenceThroughDesktopAPI(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("record evidence through desktop", control.GoalResearchOn) - err := app.AutoResearchRecordEvidence(tab.ID, "objective_evidence", AutoResearchEvidenceView{ - ID: "f1", - Kind: "test", - Summary: "desktop evidence recorded", - Source: "manual", - Accepted: true, - }) - if err != nil { - t.Fatalf("AutoResearchRecordEvidence: %v", err) - } - findings := app.AutoResearchFindings(tab.ID, 10) - if len(findings) != 1 || findings[0].ID != "f1" { - t.Fatalf("findings = %+v, want f1", findings) - } -} - func TestMetaReportsStoredCollaborationModeWhileControllerRebuilds(t *testing.T) { isolateDesktopUserDirs(t) diff --git a/desktop/tabs.go b/desktop/tabs.go index 4fa29ef158..f1fb486dbb 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -2188,41 +2188,40 @@ type wireEventTab struct { // TabMeta is the frontend-facing shape of one tab. type TabMeta struct { - ID string `json:"id"` - Scope string `json:"scope"` - WorkspaceRoot string `json:"workspaceRoot"` - WorkspaceName string `json:"workspaceName"` - WorkspacePath string `json:"workspacePath,omitempty"` - GitBranch string `json:"gitBranch,omitempty"` - IsolatedWorktree bool `json:"isolatedWorktree,omitempty"` - TopicID string `json:"topicId"` - TopicTitle string `json:"topicTitle"` - SessionPath string `json:"sessionPath,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - ProjectColor string `json:"projectColor,omitempty"` - Label string `json:"label"` - Ready bool `json:"ready"` - Runtime SessionRuntimeView `json:"runtime"` - Running bool `json:"running"` - PendingPrompt bool `json:"pendingPrompt,omitempty"` - RemoteControlled bool `json:"remoteControlled,omitempty"` - BackgroundJobs int `json:"backgroundJobs,omitempty"` - CancelRequested bool `json:"cancelRequested,omitempty"` - Cancellable bool `json:"cancellable"` - Mode string `json:"mode"` - CollaborationMode string `json:"collaborationMode"` - ToolApprovalMode string `json:"toolApprovalMode"` - TokenMode string `json:"tokenMode"` - Goal string `json:"goal,omitempty"` - GoalStatus string `json:"goalStatus,omitempty"` - AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"` - Recovered bool `json:"recovered,omitempty"` - RecoveryReason string `json:"recoveryReason,omitempty"` - RecoveryDigest string `json:"recoveryDigest,omitempty"` - RecoveryParentID string `json:"recoveryParentId,omitempty"` - StartupErr string `json:"startupErr,omitempty"` - Active bool `json:"active"` - Cwd string `json:"cwd"` + ID string `json:"id"` + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot"` + WorkspaceName string `json:"workspaceName"` + WorkspacePath string `json:"workspacePath,omitempty"` + GitBranch string `json:"gitBranch,omitempty"` + IsolatedWorktree bool `json:"isolatedWorktree,omitempty"` + TopicID string `json:"topicId"` + TopicTitle string `json:"topicTitle"` + SessionPath string `json:"sessionPath,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + ProjectColor string `json:"projectColor,omitempty"` + Label string `json:"label"` + Ready bool `json:"ready"` + Runtime SessionRuntimeView `json:"runtime"` + Running bool `json:"running"` + PendingPrompt bool `json:"pendingPrompt,omitempty"` + RemoteControlled bool `json:"remoteControlled,omitempty"` + BackgroundJobs int `json:"backgroundJobs,omitempty"` + CancelRequested bool `json:"cancelRequested,omitempty"` + Cancellable bool `json:"cancellable"` + Mode string `json:"mode"` + CollaborationMode string `json:"collaborationMode"` + ToolApprovalMode string `json:"toolApprovalMode"` + TokenMode string `json:"tokenMode"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` + Recovered bool `json:"recovered,omitempty"` + RecoveryReason string `json:"recoveryReason,omitempty"` + RecoveryDigest string `json:"recoveryDigest,omitempty"` + RecoveryParentID string `json:"recoveryParentId,omitempty"` + StartupErr string `json:"startupErr,omitempty"` + Active bool `json:"active"` + Cwd string `json:"cwd"` } func enrichTabMeta(meta TabMeta) TabMeta { @@ -2262,7 +2261,6 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta { TokenMode: currentTabTokenMode(tab), Goal: currentTabGoal(tab), GoalStatus: currentTabGoalStatus(tab), - AutoResearch: compactAutoResearch(tab), StartupErr: tab.StartupErr, Active: active, Cwd: tab.WorkspaceRoot, diff --git a/desktop/topic_activation_test.go b/desktop/topic_activation_test.go index d96d0ad773..b0d6f0332d 100644 --- a/desktop/topic_activation_test.go +++ b/desktop/topic_activation_test.go @@ -10,7 +10,6 @@ import ( "time" "reasonix/internal/agent" - "reasonix/internal/autoresearch" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/evidence" @@ -334,9 +333,6 @@ func (c *activationStubController) Turn() int { return 0 } func (c *activationStubController) GoalRuntime() control.GoalRuntimeView { return control.GoalRuntimeView{} } -func (c *activationStubController) AutoResearchSummary() (*autoresearch.Summary, bool) { - return nil, false -} func (c *activationStubController) Todos() []evidence.TodoItem { return nil } func (c *activationStubController) SnapshotForShutdown() error { return nil } diff --git a/docs/COLLABORATION_MODES.zh-CN.md b/docs/COLLABORATION_MODES.zh-CN.md index 1a59d315b8..54a2c1673e 100644 --- a/docs/COLLABORATION_MODES.zh-CN.md +++ b/docs/COLLABORATION_MODES.zh-CN.md @@ -50,8 +50,7 @@ Reasonix 桌面端输入框左下角的菜单包含两条互相独立的轴: - 任务需要探索、实现、验证多个阶段。 - 你希望减少中途反复下指令,让 Reasonix 在目标范围内持续推进。 - 目标明显是长周期研究、排障或优化,例如“持续排查直到根因明确”“彻底实现并验证” - “不要原地打转”。这类目标会自动进入 AutoResearch 策略,把状态写到 - `.reasonix/autoresearch//`。 + “不要原地打转”。这类目标会自动使用 40 轮研究预算,但仍由同一个 Goal 状态机推进。 ### 推荐目标写法 @@ -65,10 +64,9 @@ Goal 模式会把这些部分当作任务边界;除非下一步涉及不可逆 - 目标要写得具体。推荐包含范围、成功标准和限制,例如“只改桌面端输入栏,补前端测试,不改后端协议”。 - 目标模式不是跳过审批。遇到高风险操作、权限限制、阻塞或需要产品判断时,仍可能停下来询问。 - 如果目标过大或边界不清,Reasonix 可能需要更多探索轮次,也会消耗更多 token。 -- AutoResearch 是 Goal 的自动持久化策略,不是独立的后台 daemon,也不是 Settings 里的全局 skill。 - 可以用 `/goal --research <目标>` 强制启用,或用 `/goal --simple <目标>` 强制保持轻量 Goal。 - 普通聊天不会因为目标文本看起来复杂或长周期而自动切换模式。只有明确选择“目标”或使用 - `/goal` 后,Reasonix 才会进入 Goal,并在 Goal 内判断是否采用 AutoResearch。 + `/goal` 后,Reasonix 才会进入 Goal,并自动选择简单、写入或研究轮次预算。 +- 旧 `.reasonix/autoresearch/...` 目录不会被新版本创建或改写;显式引用旧任务路径时只会读取并恢复成普通 Goal。 - 目标模式和计划模式是同一协作轴。切到计划模式时,会退出目标草稿/目标显示状态;运行模式不会因此改变。 ## 运行模式 diff --git a/docs/GOAL_ENFORCEMENT.zh-CN.md b/docs/GOAL_ENFORCEMENT.zh-CN.md index 6ffe4327c6..fe0646550c 100644 --- a/docs/GOAL_ENFORCEMENT.zh-CN.md +++ b/docs/GOAL_ENFORCEMENT.zh-CN.md @@ -9,7 +9,7 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive | 结构化完成协议 | `update_goal` 工具 | 每轮目标 turn 结束时模型通过工具报告 continue/complete/blocked(含 reason 与 next_action),取代旧的 `[goal:*]` footer 文本标记 | | 完成校验 | 默认 | `complete` 声明必须通过 Delivery readiness(todos、验证、review、签收、能力门禁)才会真正完成;不满足时用缺失项开启下一轮 | | 独立评审 | 无报告时 | 模型未调用 `update_goal` 时,宿主调用一次独立 bounded evaluator 判定;评审不可用/出错/不确定时安全暂停,绝不默认继续 | -| 执行预算 | 默认 | **轮次与无进展熔断**:简单 10 轮、写入型 20 轮、AutoResearch 40 轮;连续 4 轮无宿主可验证进展则暂停。累计 token 只做观测展示,**没有 token 硬上限**,也没有 provider 请求前预算准入 | +| 执行预算 | 默认 | **轮次与无进展熔断**:简单 10 轮、写入型 20 轮、研究型 40 轮;连续 4 轮无宿主可验证进展则暂停。累计 token 只做观测展示,**没有 token 硬上限**,也没有 provider 请求前预算准入 | | 暂停/恢复 | `/goal pause` / `/goal resume` | 暂停保留 Goal、todo、Delivery checkpoint 与运行历史;轮次型暂停恢复时追加一档同类别**轮数**(`budget_extensions` 统计轮次追加次数) | | 立即阻塞 | `blocked` 报告 | 单个 blocked 报告立即结束目标,不再重复三轮确认 | | 并行调度 | `parallel_tasks` 工具 | 并发派发多个子 agent,各自独立显示结果 | @@ -36,7 +36,7 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive - **写入型(write,20 轮)**:含明确修改动词(修复/实现/更新…),或 Goal 中**不带问句/解释意图/只读诊断/否定修改约束**的故障陈述(如「数据模型管理器又出现历史 BUG 了」「应用打开设置时崩溃」)。 - **简单型(simple,10 轮)**:咨询、解释、「为什么…」、只分析/诊断/复现定位且不要修复等。 -- **研究型(research,40 轮)**:AutoResearch 目标。 +- **研究型(research,40 轮)**:带有明显长周期信号或多个独立阶段的目标。 普通 Delivery 的只读/咨询分类不变;上述「裸故障默认 write」只作用于 Goal 轮数类别。 @@ -140,7 +140,7 @@ Delivery 不再自行注入隐藏模型消息做 3/6 次 readiness 重试:普 ### 进展签名 -只有宿主可验证信息才能重置停滞计数:todo 状态变化、新的有效 mutation/verification/review/signoff receipt、Delivery checkpoint 变化、新接受的 AutoResearch evidence、终态 `update_goal` 报告。任意工具调用、重复读取、仅改变措辞的回答或重复 continue 理由都不能伪造进展。 +只有宿主可验证信息才能重置停滞计数:todo 状态变化、新的有效 mutation/verification/review/signoff receipt、Delivery checkpoint 变化、终态 `update_goal` 报告。任意工具调用、重复读取、仅改变措辞的回答或重复 continue 理由都不能伪造进展。 ### Todo 状态流 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 68ba36a3d7..0309688970 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -619,7 +619,7 @@ Mode and display shortcuts: | `/theme [auto|light|dark|style]` | Shows or switches the CLI theme | Bare `/theme` lists background modes and named accent palettes. The choice is saved to the user config; `REASONIX_THEME` and `REASONIX_THEME_STYLE` can override it for one run. | | `Ctrl+O` | Toggles verbose reasoning display | Also available through `/verbose`. | | `Ctrl+B` | Expands or collapses long shell output | Long shell-output hint lines can also be clicked in the transcript; text selection is handled in-app while the full-screen TUI has mouse reporting enabled. | -| `/goal `, `/goal --research `, `/goal --simple `, `/goal status`, `/goal clear` | Starts, checks, or clears Goal | Goal is not in any keyboard cycle; clearly long-horizon goals automatically enable AutoResearch after Goal is explicitly started. | +| `/goal `, `/goal status`, `/goal pause`, `/goal resume`, `/goal clear` | Starts, checks, pauses, resumes, or clears Goal | Goal automatically selects a simple, write, or research turn budget. | | `/migrate`, `/migrate --from ` | Retries legacy migration or imports sessions from a chosen v0.x source | Use `--from` for custom Windows v0.52 install/data directories; it imports sessions only. See [Configuration paths](./CONFIG_PATHS.md). | Picker and approval shortcuts: @@ -1087,19 +1087,15 @@ permission, or tool behavior must declare whether embedded documentation was updated. When no documentation change is needed, the declaration must explain why the existing version-matched guidance remains correct. -## Goal and AutoResearch +## Goal -Goal is the unified runtime for long-running objectives. Ordinary `/goal` -objectives stay lightweight: Reasonix keeps working until the goal is complete, -blocked, paused, or cleared. When a goal is clearly long-horizon, Goal -automatically enables the AutoResearch strategy instead of requiring a separate -`/auto-research` skill; `auto-research` is not listed as a standalone built-in -skill in Settings -> Skills or the slash menu. Ordinary chat never changes the -collaboration mode implicitly; choose Goal in the composer or use `/goal` to -start a long-running objective. +Goal is the unified runtime for long-running objectives. Reasonix keeps working +until the goal is complete, blocked, paused, or cleared. Ordinary chat never +changes collaboration mode implicitly; choose Goal in the composer or use +`/goal` to start a long-running objective. Goal runs under a per-class **turn** budget: simple goals get 10 turns, write -goals 20 turns, and AutoResearch goals 40 turns; four consecutive turns without +goals 20 turns, and research goals 40 turns; four consecutive turns without host-verifiable progress pause the goal. Cumulative token usage is still tracked and shown for diagnostics, but there is **no token hard limit** and no pre-provider request admission. In Goal mode, a bare bug/crash/exception @@ -1121,38 +1117,15 @@ for autonomous work. It keeps going with sensible defaults unless the next step requires an irreversible or externally visible operation, a scope change, or information only the user can provide. -AutoResearch is enabled for goals with strong signals such as "keep -researching", "long-running", "thoroughly", "debug until the root cause is -clear", "do not spin", "run experiments", "verify repeatedly", or "turn this -into a complete plan". It can also trigger when the objective combines multiple -phases such as research/diagnosis, implementation/fixing, verification/testing, -optimization/documentation/release, or when the user names an existing -`.reasonix/autoresearch//` directory. Advanced users can force it with -`/goal --research ` or force lightweight Goal with -`/goal --simple `. Outside an explicitly started Goal, those signals -remain ordinary chat text and do not create durable AutoResearch state. - -Once AutoResearch is active, the agent treats the goal as a stateful research -loop instead of a chat-only continuation. It creates or reuses a project-local -`.reasonix/autoresearch//` directory. For new tasks, the default id -shape is `YYYYMMDD-HHMMSS-slug`, such as `20260618-224530-cache-audit`; Reasonix -checks the project directory first and appends `-2`, `-3`, and so on only if -that id already exists. The task state includes `task_spec.md`, `progress.json`, -`findings.jsonl`, `directions_tried.json`, and `iteration_log.jsonl`, records -each iteration's direction, evidence, verification result, and blocker, and uses -`stale_count` to detect repeated weak progress. Repeated stalls force a -structural pivot, such as changing evidence source, entrypoint, test oracle, -decomposition, benchmark, or worker strategy, rather than retrying the same -tactic. - -Workers and subagents may explore independently, but the orchestrator owns the -canonical state files. Completion requires a requirement-by-requirement evidence -audit against `task_spec.md`; a passing narrow check is not treated as proof of a -broad requirement. Dynamic run state stays in `.reasonix/autoresearch/...`, not -in `REASONIX.md`, `AGENTS.md`, project memory, tool schemas, or the cache-stable -system prompt. Public publishing, destructive operations, credentials, payments, -and external notifications still follow the normal approval, privacy, and cache -gates. +Research budgets are selected automatically for goals with strong long-horizon +signals or several distinct phases. There is no separate research mode or +runtime to configure. Goal state stays in the normal session sidecar, progress +comes only from host receipts, canonical todos, `complete_step`, review and the +Delivery checkpoint, and completion is decided by Delivery readiness plus the +bounded Goal evaluator. Legacy `.reasonix/autoresearch//` archives are +read-only: an explicit old path can be recovered as an ordinary Goal, but new +runs never create or update those directories. Deprecated budget flags are +accepted for compatibility but are hidden from help and completion. ## @ references diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 01c6da7d16..30f7dfe914 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -503,7 +503,7 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u | `/theme [auto|light|dark|style]` | 查看或切换 CLI 主题 | 不带参数会列出背景模式和命名配色。选择会保存到用户配置;单次运行可用 `REASONIX_THEME` 和 `REASONIX_THEME_STYLE` 覆盖。 | | `Ctrl+O` | 切换详细 reasoning 显示 | 也可通过 `/verbose` 使用。 | | `Ctrl+B` | 展开或收起较长 shell 输出 | 较长 shell 输出的提示行也可点击;全屏 TUI 开启鼠标接管时,文本选区由应用内处理。 | -| `/goal <目标>`、`/goal --research <目标>`、`/goal --simple <目标>`、`/goal status`、`/goal clear` | 启动、查看或清除 Goal | Goal 不进入任何快捷键循环;显式启动 Goal 后,明显长周期目标会自动启用 AutoResearch。 | +| `/goal <目标>`、`/goal status`、`/goal pause`、`/goal resume`、`/goal clear` | 启动、查看、暂停、恢复或清除 Goal | Goal 自动选择简单、写入或研究轮次预算。 | | `/migrate`、`/migrate --from <旧目录>` | 重试旧数据迁移,或从指定 v0.x 来源导入 sessions | Windows v0.52 自定义安装/数据目录用 `--from`;该形式只导入 sessions。详见[配置路径](./CONFIG_PATHS.zh-CN.md)。 | 选择器与审批: @@ -856,15 +856,12 @@ Skill 别名会继续拥有 `/docs`;发生冲突时,CLI 与桌面端通常 如果 Pull Request 修改了用户可见的 CLI、桌面端、配置、Provider、权限或工具行为,必须声明 是否已同步更新内置文档;如果无需更新,则必须说明现有的版本匹配说明为何仍然正确。 -## Goal 与 AutoResearch +## Goal -Goal 是长期目标的统一运行机制。普通 `/goal` 继续走轻量 Goal:Reasonix 会持续推进,直到 -完成、阻塞、暂停或被清除。对于明显长周期的目标,Goal 会自动进入 AutoResearch 策略,而不是 -要求用户单独运行 `/auto-research` skill;`auto-research` 也不会作为独立 builtin skill 出现在 -Settings -> Skills 或斜杠菜单里。普通聊天不会隐式改变协作模式;需要长目标时,请在输入框中 -明确选择 Goal,或使用 `/goal` 启动。 +Goal 是长期目标的统一运行机制。Reasonix 会持续推进,直到完成、阻塞、暂停或被清除。 +普通聊天不会隐式改变协作模式;需要长目标时,请在输入框中明确选择 Goal,或使用 `/goal` 启动。 -Goal 按类别运行在**轮次**预算内:简单目标 10 轮,写入型 20 轮,AutoResearch 40 轮; +Goal 按类别运行在**轮次**预算内:简单目标 10 轮,写入型 20 轮,研究型 40 轮; 连续 4 轮没有宿主可验证进展会暂停。累计 token 仍会统计并展示(便于诊断),但**没有 token 硬上限**,也不会在 provider 请求前做 token 准入拦截。Goal 中只陈述 BUG/崩溃/异常 且未要求分析或禁止修改时,默认按写入型轮数类别。暂停会保留 Goal、todo、Delivery @@ -878,27 +875,11 @@ continue/complete/blocked;没有报告时由独立的有界 evaluator 判定 Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当作自主执行的边界; 除非下一步需要不可逆或对外可见操作、任务范围变化,或必须由用户提供信息,否则会继续采用合理默认值推进,并在最后汇报假设与结果。 -AutoResearch 会在这些目标里自动启用:包含“持续”“长期”“彻底”“直到根因明确”“多轮排查” -“不要原地打转”“完整方案”“跑实验”“反复验证”“系统性研究”等强信号;或者目标同时包含 -研究/排查、实现/修复、验证/测试、优化/文档/发布等多个阶段;或者用户明确给出 -`.reasonix/autoresearch//` 任务目录。高级用户可以用 -`/goal --research <目标>` 强制启用,也可以用 `/goal --simple <目标>` 强制保持轻量 Goal。 -未显式启动 Goal 时,这些信号只作为普通聊天文本处理,不会创建持久化 AutoResearch 任务。 - -进入 AutoResearch 后,agent 会把目标当成有状态的研究循环,而不是只靠聊天上下文续写。 -它会创建或复用项目级 `.reasonix/autoresearch//` 目录。新任务默认使用 -`YYYYMMDD-HHMMSS-slug` 作为 id,例如 `20260618-224530-cache-audit`;创建前会先检查 -当前项目目录,只有同名已存在时才追加 `-2`、`-3` 等后缀。任务状态包括 -`task_spec.md`、`progress.json`、`findings.jsonl`、`directions_tried.json` 和 -`iteration_log.jsonl`,记录每轮方向、证据、验证结果和卡住原因,并用 `stale_count` 判断 -是否在低质量重复。连续停滞时,它会要求结构性 pivot,例如换证据源、入口、测试 oracle、 -拆解方式、benchmark 或 worker 策略,而不是继续重复同一种尝试。 - -worker/subagent 可以独立探索,但 canonical state 由 orchestrator 负责写入。完成前必须 -对照 `task_spec.md` 的 success criteria 做逐项证据审计;窄范围检查通过不能证明宽范围需求 -完成。动态运行态只写进 `.reasonix/autoresearch/...`,不写入 `REASONIX.md`、`AGENTS.md`、 -project memory、tool schema 或 cache-stable system prompt。公开发布、破坏性操作、凭证、 -付款和外部通知仍然遵守正常的 approval、privacy 与 cache gate。 +带有明显长周期信号或多个独立阶段的目标会自动获得研究型预算,不需要配置单独的研究模式或 +运行时。Goal 状态只保存在普通会话 sidecar;进展只来自宿主工具 receipt、canonical todo、 +`complete_step`、review 与 Delivery checkpoint,最终由 Delivery readiness 和有界 Goal +evaluator 判定。旧 `.reasonix/autoresearch//` 目录保持只读:显式引用旧路径时可恢复为 +普通 Goal,但新版本不会创建或改写这些目录。旧预算 flags 仅为兼容继续接受,不再出现在帮助和补全中。 ## @ 引用 diff --git a/docs/SPEC.md b/docs/SPEC.md index 35a0854093..df319afad3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -486,16 +486,13 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec assumption. Completion requires the concrete request, output format, constraints, and relevant verification expectations to be satisfied or explicitly reported as unverified. - Goals that look like long-horizon research, debugging, optimization, or - implementation work automatically add an AutoResearch protocol to the same - transient active-goal user block. AutoResearch is a Goal strategy, not a - standalone global skill: it writes project-local state under - `.reasonix/autoresearch/YYYYMMDD-HHMMSS-slug/` and keeps dynamic run state out - of `REASONIX.md`, `AGENTS.md`, project memory, tool schemas, and the - cache-stable system prompt. `/goal --research ` forces that - strategy; `/goal --simple ` forces lightweight Goal. Outside goal - mode, ordinary prompts never change collaboration mode or create durable - AutoResearch state; the user must choose Goal or use `/goal` explicitly. + Goal automatically selects a simple (10), write (20), or research (40) turn + budget from the objective. All classes use the same Goal FSM, host receipts, + Delivery readiness, and bounded evaluator; there is no second research + protocol or writable sidecar runtime. Legacy `.reasonix/autoresearch/...` + archives remain read-only and explicit old paths recover as ordinary Goals. + Outside goal mode, ordinary prompts never change collaboration mode; the user + must choose Goal or use `/goal` explicitly. `/goal clear` removes the active goal. Switching into plan/normal mode clears the active goal in the desktop UI so the collaboration mode remains one of the three choices, while the underlying tool approval posture is preserved. diff --git a/docs/SPEC.zh-CN.md b/docs/SPEC.zh-CN.md index f6fb037f94..ee8dd673ac 100644 --- a/docs/SPEC.zh-CN.md +++ b/docs/SPEC.zh-CN.md @@ -187,7 +187,7 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec - 安装 MCP server 即授权其全部工具,不再有 server、raw tool、writer 或 destructive 的第二套审批策略;项目 `reasonix.toml` 与 `.mcp.json` 声明同样默认可信,不需要额外启动确认,显式全局 `deny` 仍然优先。全局安装写入用户 `config.toml`,项目声明保留在原项目文件;同名时项目覆盖全局,项目内部 `reasonix.toml` 高于 `.mcp.json`。编辑写回当前生效来源,删除高优先级声明后露出下一层。`readOnlyHint` 与 `destructiveHint` 仅用于调度、Plan/严格只读边界及缓存到实时安全分类复核,不会新增逐调用审批。严格只读子智能体 registry 仍仅暴露已授权且 `readOnlyHint: true`、无 `destructiveHint` 的 MCP;双模型 Planner 通过固定 `use_capability` 代理(从不暴露直接 `mcp__*` schema)调用已授权、非 destructive 的 MCP,不再要求 `readOnlyHint`,destructive 工具留给 Executor。Balanced 双模型的 Executor 使用独立 frontend 复用同一稳定代理,因此 Planner 发现的 capability ID 可在 handoff 后直接执行,同时保持两侧 ledger/audit 隔离。分发前代理会再次复核当前 controller 的 enable、授权和完整运行时连接身份;共享 Host 中仅 server 同名不构成复用权限。 - Plan 是协作流程,不等于全工具只读。普通 built-in 与 Bash 仍走 Ask/Auto/YOLO 和 Sandbox;独立双模型 Planner 允许已授权、非 destructive 的 MCP(即使没有 `readOnlyHint`),但在规划阶段持续阻止 destructive 与未授权目标;没有独立 Planner 的单模型 Plan 仍阻止 MCP writer/destructive。 - Plan 只能由用户显式选择进入,与当前工具审批姿态相互独立;普通聊天不会自动切换到 Plan。Auto/YOLO 不会回答 `ask`,也不会替用户批准 `exit_plan_mode`,获批计划的短期自动执行窗口也不会自动批准后续计划或嵌套/间接 Bash。 -- 桌面端协作模式分为 `normal`、`plan` 和 `goal`。Goal 会持续推进目标,直到完成、同一阻塞状态重复三次、用户停止或达到安全续跑边界。只有用户在输入框中选择 Goal 或运行 `/goal` 显式启动后,长周期研究、调试、优化或实现目标才可启用 AutoResearch;普通聊天不会隐式切换协作模式,也不会创建持久化 AutoResearch 状态。动态状态保存在 `.reasonix/autoresearch/.../`。 +- 桌面端协作模式分为 `normal`、`plan` 和 `goal`。Goal 会持续推进目标,直到完成、阻塞、用户停止或达到轮次/无进展安全边界,并按目标自动选择简单(10)、写入(20)或研究(40)轮预算。三类预算共用同一个 Goal FSM、宿主 receipt、Delivery readiness 和有界 evaluator,不再存在第二套研究协议或可写 sidecar。普通聊天不会隐式切换协作模式;旧 `.reasonix/autoresearch/.../` 目录只读,显式旧路径可恢复为普通 Goal。 ### 3.8 Slash command diff --git a/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md b/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md index 699ae102ba..0bf311c58b 100644 --- a/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md +++ b/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md @@ -1,5 +1,8 @@ # AutoResearch Runtime Verification Matrix +> Superseded historical audit. The verified runtime described below is no +> longer active; Goal now owns budgeting, progress and completion directly. + Date: 2026-06-30 Scope: host-managed AutoResearch runtime, controller integration, desktop API, diff --git a/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md b/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md index bc493e7f91..cd40266224 100644 --- a/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md +++ b/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md @@ -1,5 +1,8 @@ # AutoResearch Runtime Implementation Plan +> Superseded historical plan. The standalone runtime and desktop surfaces were +> later removed in favor of the unified Goal runtime. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement the host-managed AutoResearch runtime from the design spec, including durable state, controller integration, desktop API, and frontend visibility. diff --git a/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md b/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md index 1426456b74..518aecd5f3 100644 --- a/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md +++ b/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md @@ -1,5 +1,9 @@ # AutoResearch Runtime Design +> Superseded: the active AutoResearch runtime was removed when Goal became the +> sole runtime. This document is retained as historical design context only; +> new Goal runs do not create or mutate `.reasonix/autoresearch/` state. + ## Context Reasonix already has Goal mode and AutoResearch instructions. When a goal looks @@ -318,7 +322,7 @@ Success criteria list: Findings list: - newest accepted findings first -- kind badge: command, file, test, benchmark, manual, review +- kind badge: command, file, test, benchmark, manual, review, verification - summary - source command/path if present - created time diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 5ed16db3ac..f751633c86 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -361,7 +361,7 @@ func (c *Coordinator) Run(ctx context.Context, input string) error { return c.executor.Run(ctx, input) } c.sink.Emit(event.Event{Kind: event.Phase, Text: c.planner.Name() + " · planning", Detail: routeDetail, Source: event.UsageSourcePlanner}) - plannerCtx := ctx + plannerCtx := tool.WithoutGoalTurnRecorder(ctx) if decision.MaxResearchRounds > 0 { plannerCtx = withRunStepLimit(plannerCtx, decision.MaxResearchRounds, "planner research rounds") } diff --git a/internal/agent/goal_display.go b/internal/agent/goal_display.go index 4705c21b61..00f05de95b 100644 --- a/internal/agent/goal_display.go +++ b/internal/agent/goal_display.go @@ -42,9 +42,9 @@ const ( autoResearchEvidenceClose = "" ) -// StripAutoResearchEvidenceBlocks removes protocol -// blocks the model emits for the controller's evidence recorder. Like goal -// markers, they stay in the session history for parsing (#6665). +// StripAutoResearchEvidenceBlocks removes historical +// blocks at display boundaries only. Raw transcripts remain unchanged; current +// Goal semantics never parse these blocks or write state from them. func StripAutoResearchEvidenceBlocks(text string) string { var b strings.Builder rest := text diff --git a/internal/agent/goal_recorder_isolation_test.go b/internal/agent/goal_recorder_isolation_test.go new file mode 100644 index 0000000000..80f395930a --- /dev/null +++ b/internal/agent/goal_recorder_isolation_test.go @@ -0,0 +1,104 @@ +package agent + +import ( + "context" + "slices" + "strings" + "testing" + + "reasonix/internal/event" + "reasonix/internal/provider" + "reasonix/internal/tool" +) + +type childIsolationGoalRecorder struct { + reports []tool.GoalReport +} + +func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) { + r.reports = append(r.reports, report) + return "recorded " + report.Status, nil +} + +func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) { + goalTool, ok := tool.LookupBuiltin("update_goal") + if !ok { + t.Fatal("update_goal builtin not registered") + } + reg := tool.NewRegistry() + reg.Add(goalTool) + prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{ + {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}}, + {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}}, + }} + recorder := &childIsolationGoalRecorder{} + ctx := tool.WithGoalTurnRecorder(context.Background(), recorder) + sess := NewSession("child system") + answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard) + if err != nil { + t.Fatalf("Goal child: %v", err) + } + if answer != "Child result." { + t.Fatalf("Goal child answer = %q", answer) + } + for i, req := range prov.requests { + if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") { + t.Fatalf("child request %d changed the static tool surface: %v", i+1, toolSchemaNames(req.Tools)) + } + } + if len(recorder.reports) != 0 { + t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports) + } + if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") { + t.Fatalf("child update_goal result = %q", got) + } +} + +type coordinatorGoalRecorder struct { + reports []tool.GoalReport +} + +func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) { + r.reports = append(r.reports, report) + return "recorded " + report.Status, nil +} + +func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) { + goalTool, ok := tool.LookupBuiltin("update_goal") + if !ok { + t.Fatal("update_goal builtin not registered") + } + reg := tool.NewRegistry() + reg.Add(goalTool) + planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{ + {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}}, + {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}}, + }} + exec := &mockProvider{name: "executor", chunks: []provider.Chunk{{Type: provider.ChunkText, Text: "Implemented and verified."}, {Type: provider.ChunkDone}}} + plannerSess := NewSession("planner-sys") + executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard) + customPlannerReg := tool.NewRegistry() + customPlannerReg.Add(goalTool) + coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil) + recorder := &coordinatorGoalRecorder{} + ctx := tool.WithGoalTurnRecorder(context.Background(), recorder) + if err := coord.Run(ctx, "fix the goal bug"); err != nil { + t.Fatalf("Run: %v", err) + } + for i, req := range planner.requests { + if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") { + t.Fatalf("planner request %d changed the static tool surface: %v", i+1, toolSchemaNames(req.Tools)) + } + } + if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") { + t.Fatalf("planner update_goal result = %q", got) + } + for i, req := range exec.requests { + if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") { + t.Fatalf("executor request %d lost update_goal: %v", i+1, toolSchemaNames(req.Tools)) + } + } + if len(recorder.reports) != 0 { + t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports) + } +} diff --git a/internal/agent/planner_registry.go b/internal/agent/planner_registry.go new file mode 100644 index 0000000000..a1ca1cfd52 --- /dev/null +++ b/internal/agent/planner_registry.go @@ -0,0 +1,44 @@ +package agent + +import ( + "strings" + + "reasonix/internal/tool" +) + +var plannerNonResearchTools = []string{ + "ask", + "bash_output", + "complete_step", + "slash_command", + "todo_write", + "wait", +} + +// PlannerToolRegistry returns read-only research tools plus an isolated +// use_capability proxy. Direct MCP schemas and selected workflow tools stay hidden. +func PlannerToolRegistry(parent *tool.Registry) *tool.Registry { + exclude := append(SubagentMetaTools(), plannerNonResearchTools...) + base := FilterReadOnlyRegistry(parent, exclude...) + sub := tool.NewRegistry() + if base != nil { + for _, name := range base.Names() { + if name == "use_capability" || strings.HasPrefix(name, tool.MCPNamePrefix) { + continue + } + if tl, ok := base.Get(name); ok { + sub.Add(tl) + } + } + } + if parent != nil { + if tl, ok := parent.Get("use_capability"); ok { + if uc, ok := tl.(*UseCapabilityTool); ok { + sub.Add(uc.CloneForAgent(nil, nil)) + } else { + sub.Add(tl) + } + } + } + return sub +} diff --git a/internal/agent/subagent_identity.go b/internal/agent/subagent_identity.go new file mode 100644 index 0000000000..94850892e5 --- /dev/null +++ b/internal/agent/subagent_identity.go @@ -0,0 +1,19 @@ +package agent + +import ( + "encoding/json" + "sort" + + "reasonix/internal/tool" +) + +func toolIdentity(reg *tool.Registry) ([]string, string) { + if reg == nil { + return nil, bytesHash(nil) + } + names := reg.Names() + sort.Strings(names) + schemas := normalizeToolSchemas(reg.Schemas()) + data, _ := json.Marshal(schemas) + return names, bytesHash(data) +} diff --git a/internal/agent/subagent_store.go b/internal/agent/subagent_store.go index 4e5e697226..833f532187 100644 --- a/internal/agent/subagent_store.go +++ b/internal/agent/subagent_store.go @@ -942,17 +942,6 @@ func validSubagentRef(ref string) bool { return true } -func toolIdentity(reg *tool.Registry) ([]string, string) { - if reg == nil { - return nil, bytesHash(nil) - } - names := reg.Names() - sort.Strings(names) - schemas := normalizeToolSchemas(reg.Schemas()) - data, _ := json.Marshal(schemas) - return names, bytesHash(data) -} - func bytesHash(data []byte) string { h := sha256.Sum256(data) return hex.EncodeToString(h[:]) diff --git a/internal/agent/task.go b/internal/agent/task.go index 41a5c27300..ab0ee93eee 100644 --- a/internal/agent/task.go +++ b/internal/agent/task.go @@ -1501,50 +1501,6 @@ func allowlistRequestsUnrestrictedProxy(names []string) bool { return false } -var plannerNonResearchTools = []string{ - "ask", - "bash_output", - "complete_step", - "slash_command", - "todo_write", - "wait", -} - -// PlannerToolRegistry returns the tool set exposed to the two-model planner: -// built-in read-only research tools plus the stable use_capability proxy. Direct -// mcp__* schemas are excluded so MCP connect/disconnect/tool-list churn never -// changes the Planner provider-visible tool prefix. Workflow/meta tools that are -// technically read-only but can prompt the user, update visible task state, wait -// on jobs, or expand commands are also excluded. -func PlannerToolRegistry(parent *tool.Registry) *tool.Registry { - exclude := append(SubagentMetaTools(), plannerNonResearchTools...) - base := FilterReadOnlyRegistry(parent, exclude...) - sub := tool.NewRegistry() - if base != nil { - for _, name := range base.Names() { - // Never copy the parent proxy or direct MCP: Delivery would share - // Executor ledger/audit; MCP schemas are proxy-only for the planner. - if name == "use_capability" || strings.HasPrefix(name, tool.MCPNamePrefix) { - continue - } - if tl, ok := base.Get(name); ok { - sub.Add(tl) - } - } - } - // Always install an isolated frontend (independent ledger/audit; shared Host). - if parent != nil { - if tl, ok := parent.Get("use_capability"); ok { - if uc, ok := tl.(*UseCapabilityTool); ok { - sub.Add(uc.CloneForAgent(nil, nil)) - } else { - sub.Add(tl) - } - } - } - return sub -} - // ReadOnlySubagentToolRegistry returns the tool set exposed to read-only // sub-agents: read-only research tools plus a bash wrapper that enforces the // permission-layer read-only command policy at execution time. Workflow/meta tools are @@ -1873,6 +1829,7 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to return "", fmt.Errorf("sub-agent session is nil") } // Isolate temporary files for this run before any tool execution. + ctx = tool.WithoutGoalTurnRecorder(ctx) ctx, releaseTemp := withSubagentSessionTemp(ctx) defer releaseTemp() if opts.SubagentDepth > 0 { diff --git a/internal/autoresearch/bounded_store_test.go b/internal/autoresearch/bounded_store_test.go index 03f4a95cc0..ba06dfc83f 100644 --- a/internal/autoresearch/bounded_store_test.go +++ b/internal/autoresearch/bounded_store_test.go @@ -1,239 +1,100 @@ package autoresearch import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "strings" "testing" "time" ) -// TestDirectionFingerprintDistinguishesLongPrefixes: two directions sharing a -// >56-char slug prefix used to collapse to one fingerprint, wrongly counting -// the second as a repeat and inflating StaleCount toward a forced pivot. -func TestDirectionFingerprintDistinguishesLongPrefixes(t *testing.T) { +// TestFindingsTailEquivalence: newest-N reads match a full scan followed by a +// reverse/limit without writing through a production API. +func TestFindingsTailEquivalence(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Long prefix fingerprints", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - prefix := "Benchmark the desktop frontend markdown rendering pipeline for " - - progress, err := store.RecordDirection(task.ID, Direction{ - Summary: prefix + "large tables", - AcceptedEvidenceIDs: []string{"f1"}, - Now: time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection first: %v", err) - } - if progress.StaleCount != 0 { - t.Fatalf("first direction stale = %d, want 0", progress.StaleCount) - } - - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: prefix + "code blocks", - AcceptedEvidenceIDs: []string{"f2"}, - Now: time.Date(2026, 7, 7, 10, 1, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection second: %v", err) - } - // A genuinely different direction with accepted evidence must not be - // counted as a repeat. - if progress.StaleCount != 0 { - t.Fatalf("distinct long-prefix direction treated as repeat: stale = %d, want 0", progress.StaleCount) - } - - // An exact repeat must still be detected. - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: prefix + "code blocks", - AcceptedEvidenceIDs: []string{"f3"}, - Now: time.Date(2026, 7, 7, 10, 2, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection third: %v", err) - } - if progress.StaleCount != 1 { - t.Fatalf("exact repeat not detected: stale = %d, want 1", progress.StaleCount) + taskID := "findings-tail" + taskRoot := writeArchiveFixture(t, root, taskID, "Findings tail equivalence", nil) + for i := 1; i <= 40; i++ { + appendFindingLine(t, taskRoot, Finding{ + ID: fmt.Sprintf("f%d", i), + Kind: "manual", + Summary: fmt.Sprintf("finding %d", i), + Source: FindingSourceManual, + Accepted: true, + CreatedAt: time.Date(2026, 6, 30, 10, 0, i, 0, time.UTC), + }) } -} - -// TestDirectionFingerprintDistinguishesCJK: directions differing only in CJK -// text slugify to the same string ("task") and used to collide. -func TestDirectionFingerprintDistinguishesCJK(t *testing.T) { - a := directionFingerprint("评测甲方案的渲染性能") - b := directionFingerprint("评测乙方案的渲染性能") - if a == b { - t.Fatalf("CJK-only-diff directions share a fingerprint: %q", a) - } - if directionFingerprint("评测甲方案的渲染性能") != a { - t.Fatal("fingerprint is not deterministic") - } -} - -// TestDirectionFingerprintBackwardCompatShortASCII: short ASCII summaries keep -// the bare slug so fingerprints recorded by older versions still match. -func TestDirectionFingerprintBackwardCompatShortASCII(t *testing.T) { - got := directionFingerprint("Profile markdown rendering") - want := slugify("Profile markdown rendering") - if got != want { - t.Fatalf("short ASCII fingerprint = %q, want legacy slug %q", got, want) - } -} - -// TestRecordDirectionMigratesLegacyFingerprint: a directions_tried.json entry -// written by an older version (bare truncated slug) must still match its own -// summary on repeat, not be double-counted as a new direction. -func TestRecordDirectionMigratesLegacyFingerprint(t *testing.T) { - root := t.TempDir() store := NewStore(root) - task, err := store.CreateTask("Legacy fingerprint migration", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables" - - // Simulate a legacy entry: fingerprint from the old bare slugify. - dirPath := filepath.Join(task.Root, "state", "directions_tried.json") - legacy := []DirectionTried{{ - Fingerprint: slugify(summary), - Summary: summary, - FirstSeenIteration: 1, - LastSeenIteration: 1, - Count: 1, - }} - data, err := json.Marshal(legacy) + all, err := store.Findings(taskID, 0) if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dirPath, data, 0o644); err != nil { - t.Fatal(err) + t.Fatalf("Findings full: %v", err) } - - progress, err := store.RecordDirection(task.ID, Direction{ - Summary: summary, - Now: time.Date(2026, 7, 7, 11, 0, 0, 0, time.UTC), - }) + tail, err := store.Findings(taskID, 5) if err != nil { - t.Fatalf("RecordDirection: %v", err) - } - // Repeat of the legacy direction: stale should increment (repeat + no - // accepted evidence), and the entry should be migrated, not duplicated. - if progress.StaleCount != 1 { - t.Fatalf("legacy repeat not detected: stale = %d, want 1", progress.StaleCount) - } - raw, err := os.ReadFile(dirPath) - if err != nil { - t.Fatal(err) - } - var directions []DirectionTried - if err := json.Unmarshal(raw, &directions); err != nil { - t.Fatal(err) + t.Fatalf("Findings tail: %v", err) } - if len(directions) != 1 { - t.Fatalf("directions = %+v, want single migrated entry", directions) + if len(tail) != 5 { + t.Fatalf("tail len = %d", len(tail)) } - if directions[0].Count != 2 { - t.Fatalf("migrated count = %d, want 2", directions[0].Count) - } - if directions[0].Fingerprint != directionFingerprint(summary) { - t.Fatalf("entry not migrated to new fingerprint: %q", directions[0].Fingerprint) + // Newest-first order: first of tail is the latest full entry. + if tail[0].ID != all[0].ID || tail[4].ID != all[4].ID { + t.Fatalf("tail=%+v all[:5]=%+v", tail, all[:5]) } } -// TestTailJSONLLinesMatchesFullScan: the tail reader must return exactly the -// same entries as a full scan for every limit, including limits larger than -// the file and files bigger than one read chunk. -func TestTailJSONLLinesMatchesFullScan(t *testing.T) { +func TestHeartbeatTailEquivalence(t *testing.T) { root := t.TempDir() + taskID := "heartbeat-tail" + taskRoot := writeArchiveFixture(t, root, taskID, "Tail read equivalence", nil) + for i := 1; i <= 30; i++ { + appendHeartbeatLine(t, taskRoot, Heartbeat{ + Status: HeartbeatTurnDone, + Iteration: i, + CreatedAt: time.Date(2026, 6, 30, 10, 0, i, 0, time.UTC), + }) + } store := NewStore(root) - task, err := store.CreateTask("Tail read equivalence", CreateOptions{}) + all, err := store.Heartbeats(taskID, 0) if err != nil { - t.Fatalf("CreateTask: %v", err) - } - // Write enough heartbeats that the log exceeds one 64KiB chunk. - long := strings.Repeat("x", 700) - for i := range 150 { - if err := store.AppendHeartbeat(task.ID, Heartbeat{ - Status: HeartbeatTurnDone, - Iteration: i + 1, - Message: fmt.Sprintf("turn-%03d %s", i, long), - CreatedAt: time.Date(2026, 7, 7, 12, 0, i%60, 0, time.UTC), - }); err != nil { - t.Fatalf("AppendHeartbeat %d: %v", i, err) - } + t.Fatalf("Heartbeats full: %v", err) } - all, err := store.Heartbeats(task.ID, 0) + tail, err := store.Heartbeats(taskID, 3) if err != nil { - t.Fatalf("Heartbeats(0): %v", err) + t.Fatalf("Heartbeats tail: %v", err) } - if len(all) != 150 { - t.Fatalf("full scan = %d heartbeats, want 150", len(all)) + if len(tail) != 3 { + t.Fatalf("tail len = %d", len(tail)) } - for _, limit := range []int{1, 3, 149, 150, 500} { - got, err := store.Heartbeats(task.ID, limit) - if err != nil { - t.Fatalf("Heartbeats(%d): %v", limit, err) - } - want := all - if limit < len(all) { - want = all[len(all)-limit:] - } - if len(got) != len(want) { - t.Fatalf("Heartbeats(%d) = %d entries, want %d", limit, len(got), len(want)) + want := all[len(all)-3:] + for i := range want { + if tail[i].Iteration != want[i].Iteration { + t.Fatalf("tail[%d]=%+v want %+v", i, tail[i], want[i]) } - for i := range got { - if got[i].Iteration != want[i].Iteration { - t.Fatalf("Heartbeats(%d)[%d].Iteration = %d, want %d", limit, i, got[i].Iteration, want[i].Iteration) - } - } - } - // LastHeartbeat must be the newest entry. - last, ok, err := store.LastHeartbeat(task.ID) - if err != nil || !ok { - t.Fatalf("LastHeartbeat: ok=%v err=%v", ok, err) - } - if last.Iteration != 150 { - t.Fatalf("LastHeartbeat iteration = %d, want 150", last.Iteration) } } -// TestFindingsBoundedTailMatchesFullScan mirrors the heartbeat check for the -// findings log, which desktop views read with a limit. -func TestFindingsBoundedTailMatchesFullScan(t *testing.T) { +func TestDirectionsFileIsReadableThroughSummary(t *testing.T) { root := t.TempDir() + taskID := "directions-readable" + taskRoot := writeArchiveFixture(t, root, taskID, "Legacy fingerprint migration", nil) + summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables" + writeDirections(t, taskRoot, []DirectionTried{{ + Fingerprint: "legacy-slug", + Summary: summary, + FirstSeenIteration: 1, + LastSeenIteration: 1, + Count: 1, + }}) + writeProgress(t, taskRoot, Progress{ + Status: StatusRunning, + Iteration: 1, + CurrentDirection: summary, + UpdatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), + }) store := NewStore(root) - task, err := store.CreateTask("Findings tail equivalence", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - for i := range 25 { - if err := store.AppendFinding(task.ID, Finding{ - ID: fmt.Sprintf("f%03d", i), - Kind: FindingKindManual, - Summary: fmt.Sprintf("finding %03d", i), - Accepted: i%2 == 0, - CreatedAt: time.Date(2026, 7, 7, 13, 0, i%60, 0, time.UTC), - }); err != nil { - t.Fatalf("AppendFinding %d: %v", i, err) - } - } - all, err := store.Findings(task.ID, 0) - if err != nil { - t.Fatalf("Findings(0): %v", err) - } - if len(all) != 25 || all[0].ID != "f024" { - t.Fatalf("full scan = %d findings first %q, want 25 / f024 (newest first)", len(all), all[0].ID) - } - got, err := store.Findings(task.ID, 5) + got, err := store.Summary(taskID) if err != nil { - t.Fatalf("Findings(5): %v", err) + t.Fatalf("Summary: %v", err) } - if len(got) != 5 || got[0].ID != "f024" || got[4].ID != "f020" { - t.Fatalf("Findings(5) = %+v, want newest five f024..f020", got) + if got.CurrentDirection != summary || got.Iteration != 1 { + t.Fatalf("summary = %+v", got) } } diff --git a/internal/autoresearch/fixture_test.go b/internal/autoresearch/fixture_test.go new file mode 100644 index 0000000000..e95b941182 --- /dev/null +++ b/internal/autoresearch/fixture_test.go @@ -0,0 +1,158 @@ +package autoresearch + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// writeArchiveFixture materializes a historical task directory for read-only +// tests. Production code never creates archives. +func writeArchiveFixture(t *testing.T, workspaceRoot, taskID, goal string, criteria []SuccessCriterion) string { + t.Helper() + if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil { + workspaceRoot = resolved + } + root := filepath.Join(workspaceRoot, ".reasonix", "autoresearch", taskID) + state := filepath.Join(root, "state") + logs := filepath.Join(root, "logs") + if err := os.MkdirAll(state, 0o755); err != nil { + t.Fatalf("mkdir state: %v", err) + } + if err := os.MkdirAll(logs, 0o755); err != nil { + t.Fatalf("mkdir logs: %v", err) + } + if criteria == nil { + criteria = []SuccessCriterion{} + } + spec := TaskSpec{ + TaskID: taskID, + Goal: goal, + AllowedOperations: AllowedOperations{ + Write: true, + }, + SuccessCriteria: criteria, + } + writeJSON(t, filepath.Join(state, "task_spec.json"), spec) + writeJSON(t, filepath.Join(state, "progress.json"), Progress{ + Status: StatusRunning, + UpdatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), + }) + if err := os.WriteFile(filepath.Join(state, "directions_tried.json"), []byte("[]\n"), 0o644); err != nil { + t.Fatalf("write directions: %v", err) + } + if err := os.WriteFile(filepath.Join(state, "findings.jsonl"), nil, 0o644); err != nil { + t.Fatalf("write findings: %v", err) + } + if err := os.WriteFile(filepath.Join(state, "iteration_log.jsonl"), nil, 0o644); err != nil { + t.Fatalf("write iteration log: %v", err) + } + if err := os.WriteFile(filepath.Join(logs, "heartbeat.jsonl"), nil, 0o644); err != nil { + t.Fatalf("write heartbeat: %v", err) + } + return root +} + +func writeJSON(t *testing.T, path string, v any) { + t.Helper() + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("marshal %s: %v", path, err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func appendFindingLine(t *testing.T, taskRoot string, f Finding) { + t.Helper() + data, err := json.Marshal(f) + if err != nil { + t.Fatalf("marshal finding: %v", err) + } + path := filepath.Join(taskRoot, "state", "findings.jsonl") + fhandle, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatalf("open findings: %v", err) + } + defer fhandle.Close() + if _, err := fhandle.Write(append(data, '\n')); err != nil { + t.Fatalf("append finding: %v", err) + } +} + +func writeProgress(t *testing.T, taskRoot string, progress Progress) { + t.Helper() + writeJSON(t, filepath.Join(taskRoot, "state", "progress.json"), progress) +} + +func writeDirections(t *testing.T, taskRoot string, directions []DirectionTried) { + t.Helper() + writeJSON(t, filepath.Join(taskRoot, "state", "directions_tried.json"), directions) +} + +func appendHeartbeatLine(t *testing.T, taskRoot string, h Heartbeat) { + t.Helper() + data, err := json.Marshal(h) + if err != nil { + t.Fatalf("marshal heartbeat: %v", err) + } + path := filepath.Join(taskRoot, "logs", "heartbeat.jsonl") + fhandle, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatalf("open heartbeat: %v", err) + } + defer fhandle.Close() + if _, err := fhandle.Write(append(data, '\n')); err != nil { + t.Fatalf("append heartbeat: %v", err) + } +} + +func hashTree(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + out[rel] = string(data) + return nil + }) + if err != nil { + t.Fatalf("hash tree: %v", err) + } + return out +} + +func modTimes(t *testing.T, root string) map[string]time.Time { + t.Helper() + out := map[string]time.Time{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + out[rel] = info.ModTime() + return nil + }) + if err != nil { + t.Fatalf("stat tree: %v", err) + } + return out +} diff --git a/internal/autoresearch/readiness.go b/internal/autoresearch/readiness.go deleted file mode 100644 index d580fe5bab..0000000000 --- a/internal/autoresearch/readiness.go +++ /dev/null @@ -1,72 +0,0 @@ -package autoresearch - -import "path/filepath" - -func (s *Store) Readiness(taskID string) (*ReadinessReport, error) { - report := &ReadinessReport{} - validation, err := s.ValidateTask(taskID) - if err != nil { - return nil, err - } - if !validation.Valid { - for _, validationErr := range validation.Errors { - report.Errors = append(report.Errors, validationErr.File+":"+validationErr.Field+": "+validationErr.Error) - } - return report, nil - } - task, err := s.LoadTask(taskID) - if err != nil { - return nil, err - } - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return nil, err - } - defer storeRoot.Close() - var progress Progress - if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil { - return nil, err - } - if progress.Status == StatusBlocked { - report.BlockedReason = progress.BlockedReason - if report.BlockedReason == "" { - report.BlockedReason = "task is blocked" - } - return report, nil - } - findings, err := s.Findings(taskID, 0) - if err != nil { - return nil, err - } - accepted := acceptedFindingIDs(findings) - for _, criterion := range task.Spec.SuccessCriteria { - if !criterion.Required { - continue - } - if countAcceptedEvidence(criterion.EvidenceIDs, accepted) == 0 { - report.MissingCriteria = append(report.MissingCriteria, criterion.ID) - } - } - report.Ready = len(report.MissingCriteria) == 0 && report.BlockedReason == "" && len(report.Errors) == 0 - return report, nil -} - -func acceptedFindingIDs(findings []Finding) map[string]bool { - accepted := make(map[string]bool, len(findings)) - for _, finding := range findings { - if finding.Accepted { - accepted[finding.ID] = true - } - } - return accepted -} - -func countAcceptedEvidence(ids []string, accepted map[string]bool) int { - count := 0 - for _, id := range ids { - if accepted[id] { - count++ - } - } - return count -} diff --git a/internal/autoresearch/store.go b/internal/autoresearch/store.go index 6016ce998c..d1c3505b70 100644 --- a/internal/autoresearch/store.go +++ b/internal/autoresearch/store.go @@ -1,41 +1,32 @@ +// Package autoresearch is a read-only compatibility reader for historical +// `.reasonix/autoresearch//` archives. New Goal runs never create or +// mutate these directories. package autoresearch import ( "bufio" - "crypto/rand" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" - "hash/fnv" - "io/fs" + "io" "os" "path/filepath" "regexp" - "slices" "sort" "strings" - "sync" - "time" "unicode" fileencoding "reasonix/internal/fileutil/encoding" ) var safeTaskID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) -var explicitTaskPath = regexp.MustCompile(`\.reasonix/autoresearch/([A-Za-z0-9][A-Za-z0-9._-]*)/?`) -var safeCreateToken = regexp.MustCompile(`^[a-f0-9]{32}$`) -// createTokenFile is written immediately after an atomic task-directory -// reservation so rollback can prove ownership before RemoveAll. -const createTokenFile = ".create_token" +const explicitTaskPathPrefix = ".reasonix/autoresearch/" +// Store is a fail-closed reader over a workspace's legacy AutoResearch root. type Store struct { workspaceRoot string root string - mu sync.Mutex - taskLocks map[string]*sync.Mutex } func NewStore(workspaceRoot string) *Store { @@ -45,195 +36,34 @@ func NewStore(workspaceRoot string) *Store { return &Store{ workspaceRoot: workspaceRoot, root: filepath.Join(workspaceRoot, ".reasonix", "autoresearch"), - taskLocks: map[string]*sync.Mutex{}, } } -func (s *Store) lockTask(taskID string) func() { - s.mu.Lock() - lock := s.taskLocks[taskID] - if lock == nil { - lock = &sync.Mutex{} - s.taskLocks[taskID] = lock - } - s.mu.Unlock() - lock.Lock() - return lock.Unlock -} - -func (s *Store) CreateTask(goal string, opts CreateOptions) (*Task, error) { - goal = strings.TrimSpace(goal) - if goal == "" { - return nil, errors.New("autoresearch: goal is required") - } - now := time.Now().UTC() - if opts.Now != nil { - now = opts.Now().UTC() - } - id, createToken, err := s.reserveTaskID(now, goal, opts.CreateToken) - if err != nil { - return nil, err - } - storeRoot, err := os.OpenRoot(s.root) - if err != nil { - _ = s.RemoveTask(id, createToken) - return nil, fmt.Errorf("autoresearch: open root dir: %w", err) - } - defer storeRoot.Close() - taskRel, err := s.taskRel(id) - if err != nil { - _ = s.RemoveTask(id, createToken) - return nil, err - } - - cleanup := func() { - _ = s.RemoveTask(id, createToken) - } - - if err := storeRoot.MkdirAll(filepath.Join(taskRel, "state"), 0o755); err != nil { - cleanup() - return nil, fmt.Errorf("autoresearch: create state dir: %w", err) - } - if err := storeRoot.MkdirAll(filepath.Join(taskRel, "logs"), 0o755); err != nil { - cleanup() - return nil, fmt.Errorf("autoresearch: create logs dir: %w", err) - } - - spec := TaskSpec{ - TaskID: id, - Goal: goal, - Scope: append([]string(nil), opts.Scope...), - NonGoals: append([]string(nil), opts.NonGoals...), - AllowedOperations: opts.AllowedOperations, - SuccessCriteria: cloneCriteria(opts.SuccessCriteria), - } - progress := Progress{ - Status: StatusRunning, - UpdatedAt: now, - } - - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), spec); err != nil { - cleanup() - return nil, err - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil { - cleanup() - return nil, err - } - for _, path := range []string{ - filepath.Join(taskRel, "state", "directions_tried.json"), - filepath.Join(taskRel, "state", "findings.jsonl"), - filepath.Join(taskRel, "state", "iteration_log.jsonl"), - filepath.Join(taskRel, "logs", "heartbeat.jsonl"), - } { - if err := storeRoot.WriteFile(path, nil, 0o644); err != nil { - cleanup() - return nil, fmt.Errorf("autoresearch: initialize %s: %w", path, err) - } - } - return &Task{ID: id, Root: s.taskRoot(id), Spec: spec, CreateToken: createToken}, nil +// Root returns the absolute archive root under the workspace. +func (s *Store) Root() string { + return s.root } -// RemoveTask deletes a task directory within the store only when createToken -// matches the ownership token written during reservation. It is intended for -// rolling back a task this process created as part of a larger transaction. -func (s *Store) RemoveTask(taskID, createToken string) error { - if err := validateTaskID(taskID); err != nil { - return err - } - createToken = strings.TrimSpace(createToken) - if createToken == "" { - return errors.New("autoresearch: create token is required to remove a task") - } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, err := os.OpenRoot(s.root) +func (s *Store) ListSummaries() ([]Summary, error) { + storeRoot, err := s.openArchiveRoot() if err != nil { if os.IsNotExist(err) { - return nil + return []Summary{}, nil } - return fmt.Errorf("autoresearch: open root dir: %w", err) + return nil, fmt.Errorf("autoresearch: list tasks: %w", err) } defer storeRoot.Close() - taskRel, err := s.taskRel(taskID) + dir, err := storeRoot.Open(".") if err != nil { - return err + return nil, fmt.Errorf("autoresearch: open task list: %w", err) } - tokenPath := filepath.Join(taskRel, createTokenFile) - stored, err := storeRoot.ReadFile(tokenPath) + entries, err := dir.ReadDir(-1) + closeErr := dir.Close() if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("autoresearch: refuse to remove task %s without matching create token", taskID) - } - return fmt.Errorf("autoresearch: read create token for %s: %w", taskID, err) - } - if strings.TrimSpace(string(stored)) != createToken { - return fmt.Errorf("autoresearch: refuse to remove task %s: create token mismatch", taskID) - } - if err := storeRoot.RemoveAll(taskRel); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("autoresearch: remove task %s: %w", taskID, err) - } - return nil -} - -// RemoveTaskByCreateToken removes the unique task owned by createToken. Parent -// transactions use it after a crash, when the token was durable before task -// creation but the task ID may not have been returned to the caller. -func (s *Store) RemoveTaskByCreateToken(createToken string) error { - createToken = strings.TrimSpace(createToken) - if err := validateCreateToken(createToken); err != nil { - return err - } - storeRoot, err := os.OpenRoot(s.root) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("autoresearch: open root dir: %w", err) + return nil, fmt.Errorf("autoresearch: read task list: %w", err) } - defer storeRoot.Close() - entries, err := fs.ReadDir(storeRoot.FS(), ".") - if err != nil { - return fmt.Errorf("autoresearch: list tasks for create token: %w", err) - } - matches := make([]string, 0, 1) - marker := createTokenTaskIDMarker(createToken) - for _, entry := range entries { - if !entry.IsDir() { - continue - } - taskID := entry.Name() - if validateTaskID(taskID) != nil { - continue - } - if strings.Contains(taskID, marker) { - // Transaction-owned task IDs carry a hash of the token so recovery - // still owns a directory if the process died between Mkdir and the - // create-token file write. - matches = append(matches, taskID) - } - } - if len(matches) > 1 { - return fmt.Errorf("autoresearch: create token unexpectedly owns %d tasks", len(matches)) - } - if len(matches) == 0 { - return nil - } - unlock := s.lockTask(matches[0]) - defer unlock() - if err := storeRoot.RemoveAll(matches[0]); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("autoresearch: remove transaction-owned task %s: %w", matches[0], err) - } - return nil -} - -func (s *Store) ListSummaries() ([]Summary, error) { - entries, err := os.ReadDir(s.root) - if err != nil { - if os.IsNotExist(err) { - return []Summary{}, nil - } - return nil, fmt.Errorf("autoresearch: list tasks: %w", err) + if closeErr != nil { + return nil, fmt.Errorf("autoresearch: close task list: %w", closeErr) } ids := make([]string, 0, len(entries)) for _, entry := range entries { @@ -264,109 +94,50 @@ func (s *Store) LoadTask(taskID string) (*Task, error) { return nil, err } defer storeRoot.Close() - info, err := storeRoot.Lstat(taskRel) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("autoresearch: task %s not found", taskID) - } - return nil, fmt.Errorf("autoresearch: stat task %s: %w", taskID, err) - } - if info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("autoresearch: task %s is a symlink", taskID) - } - if !info.IsDir() { - return nil, fmt.Errorf("autoresearch: task %s is not a directory", taskID) - } - var spec TaskSpec - if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil { - return nil, err + spec, report := validateTaskRoot(storeRoot, taskRel, taskID) + if !report.Valid { + return nil, fmt.Errorf("autoresearch: task %s is invalid: %v", taskID, report.Errors) } return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil } +// ResumeFromGoalText loads an archive only when goal text names an explicit +// `.reasonix/autoresearch//` path. ok is true when a path was found; +// err is non-nil when that path is missing, corrupt, a symlink, or invalid. func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) { - match := explicitTaskPath.FindStringSubmatch(goal) - if len(match) < 2 { - return nil, false, nil + taskID, found, err := ExplicitTaskID(goal) + if !found || err != nil { + return nil, found, err } - task, err := s.LoadTask(match[1]) + task, err := s.LoadTask(taskID) if err != nil { return nil, true, err } - if report, err := s.ValidateTask(task.ID); err != nil { - return nil, true, err - } else if !report.Valid { - return nil, true, fmt.Errorf("autoresearch: task %s is invalid: %v", task.ID, report.Errors) - } return task, true, nil } -func (s *Store) AppendFinding(taskID string, f Finding) error { - if err := validateTaskID(taskID); err != nil { - return err +// ExplicitTaskID extracts one complete legacy archive path token from goal +// text. Once the prefix is present, malformed IDs and additional path +// components are errors rather than ordinary goal text. +func ExplicitTaskID(goal string) (string, bool, error) { + _, tail, found := strings.Cut(goal, explicitTaskPathPrefix) + if !found { + return "", false, nil } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return err + if end := strings.IndexFunc(tail, unicode.IsSpace); end >= 0 { + tail = tail[:end] } - defer storeRoot.Close() - if err := validateFinding(f); err != nil { - return err + taskID := strings.TrimSuffix(tail, "/") + if taskID == "" { + return "", true, errors.New("autoresearch: explicit task path is missing a task id") } - data, err := json.Marshal(f) - if err != nil { - return fmt.Errorf("autoresearch: marshal finding: %w", err) + if strings.ContainsAny(taskID, `/\`) { + return "", true, fmt.Errorf("autoresearch: explicit task path has extra components: %q", tail) } - return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data) -} - -func (s *Store) RecordEvidence(taskID, criterionID string, f Finding) error { if err := validateTaskID(taskID); err != nil { - return err - } - criterionID = strings.TrimSpace(criterionID) - if criterionID == "" { - return errors.New("autoresearch: criterion id is required") - } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return err - } - defer storeRoot.Close() - if err := validateFinding(f); err != nil { - return err - } - specPath := filepath.Join(taskRel, "state", "task_spec.json") - var spec TaskSpec - if err := readJSONFile(storeRoot, specPath, &spec); err != nil { - return err + return "", true, err } - found := false - for i := range spec.SuccessCriteria { - if spec.SuccessCriteria[i].ID != criterionID { - continue - } - found = true - if !stringSliceContains(spec.SuccessCriteria[i].EvidenceIDs, f.ID) { - spec.SuccessCriteria[i].EvidenceIDs = append(spec.SuccessCriteria[i].EvidenceIDs, f.ID) - } - break - } - if !found { - return fmt.Errorf("autoresearch: criterion %q not found", criterionID) - } - if err := writeJSONFile(storeRoot, specPath, spec); err != nil { - return err - } - data, err := json.Marshal(f) - if err != nil { - return fmt.Errorf("autoresearch: marshal finding: %w", err) - } - return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data) + return taskID, true, nil } func (s *Store) Findings(taskID string, limit int) ([]Finding, error) { @@ -388,6 +159,7 @@ func (s *Store) Findings(taskID string, limit int) ([]Finding, error) { if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &f); err != nil { return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err) } + // Kind is fully opaque: unknown historical values pass through. findings = append(findings, f) } for i, j := 0, len(findings)-1; i < j; i, j = i+1, j-1 { @@ -399,27 +171,6 @@ func (s *Store) Findings(taskID string, limit int) ([]Finding, error) { return findings, nil } -func (s *Store) AppendHeartbeat(taskID string, h Heartbeat) error { - if err := validateTaskID(taskID); err != nil { - return err - } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return err - } - defer storeRoot.Close() - if err := validateHeartbeat(h); err != nil { - return err - } - data, err := json.Marshal(h) - if err != nil { - return fmt.Errorf("autoresearch: marshal heartbeat: %w", err) - } - return appendJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), data) -} - func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) { storeRoot, taskRel, err := s.openTaskRoot(taskID) if err != nil { @@ -427,9 +178,6 @@ func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) { } defer storeRoot.Close() path := filepath.Join(taskRel, "logs", "heartbeat.jsonl") - // Bounded requests only need the file tail; heartbeat logs grow one line - // per turn for the life of a task, so a full scan per read is a per-turn - // cost that keeps rising. lines, err := tailJSONLLines(storeRoot, path, limit) if err != nil { return nil, err @@ -459,137 +207,48 @@ func (s *Store) LastHeartbeat(taskID string) (Heartbeat, bool, error) { return heartbeats[0], true, nil } -func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error) { - if err := validateTaskID(taskID); err != nil { - return nil, err - } - unlock := s.lockTask(taskID) - defer unlock() +func (s *Store) Progress(taskID string) (*Progress, error) { storeRoot, taskRel, err := s.openTaskRoot(taskID) if err != nil { return nil, err } defer storeRoot.Close() - d.Summary = strings.TrimSpace(d.Summary) - if d.Summary == "" { - return nil, errors.New("autoresearch: direction summary is required") - } - now := d.Now.UTC() - if now.IsZero() { - now = time.Now().UTC() - } var progress Progress if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil { return nil, err } - progress.Iteration++ - progress.CurrentDirection = d.Summary - progress.UpdatedAt = now - - directions, err := s.loadDirections(storeRoot, taskRel) - if err != nil { - return nil, err - } - fp := directionFingerprint(d.Summary) - repeated := false - for i := range directions { - // Legacy entries carry pre-hash fingerprints; recompute from the - // stored summary so repeats recorded by older versions still match, - // then migrate the entry in place. - if directions[i].Fingerprint != fp && directionFingerprint(directions[i].Summary) != fp { - continue - } - repeated = true - directions[i].Fingerprint = fp - directions[i].Count++ - directions[i].LastSeenIteration = progress.Iteration - break - } - if !repeated { - directions = append(directions, DirectionTried{ - Fingerprint: fp, - Summary: d.Summary, - FirstSeenIteration: progress.Iteration, - LastSeenIteration: progress.Iteration, - Count: 1, - }) - } - if repeated || len(d.AcceptedEvidenceIDs) == 0 { - before := progress.StaleCount - progress.StaleCount++ - if before < 2 && progress.StaleCount >= 2 { - progress.PivotCount++ - } - } else { - progress.StaleCount = 0 - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "directions_tried.json"), directions); err != nil { - return nil, err - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil { - return nil, err - } return &progress, nil } -func (s *Store) UpdateProgress(taskID string, patch ProgressPatch) (*Progress, error) { - if err := validateTaskID(taskID); err != nil { - return nil, err - } - unlock := s.lockTask(taskID) - defer unlock() +func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) { storeRoot, taskRel, err := s.openTaskRoot(taskID) if err != nil { return nil, err } defer storeRoot.Close() - var progress Progress - if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil { - return nil, err - } - if patch.Status != nil { - progress.Status = strings.TrimSpace(*patch.Status) - } - if patch.CurrentDirection != nil { - progress.CurrentDirection = strings.TrimSpace(*patch.CurrentDirection) - } - if patch.BlockedReason != nil { - progress.BlockedReason = strings.TrimSpace(*patch.BlockedReason) - } - progress.UpdatedAt = time.Now().UTC() - report := &ValidationReport{Valid: true} - validateProgress(report, progress) - if len(report.Errors) > 0 { - return nil, fmt.Errorf("autoresearch: invalid progress patch: %v", report.Errors) - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil { - return nil, err - } - return &progress, nil + _, report := validateTaskRoot(storeRoot, taskRel, taskID) + return report, nil } -func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) { - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return nil, err - } - defer storeRoot.Close() +// validateTaskRoot reads and validates a task through one already-open root. +// The task directory cannot be swapped between validation and goal extraction. +func validateTaskRoot(storeRoot *os.Root, taskRel, taskID string) (TaskSpec, *ValidationReport) { report := &ValidationReport{Valid: true} info, err := storeRoot.Lstat(taskRel) if err != nil { report.add("task", "", err.Error()) report.Valid = false - return report, nil + return TaskSpec{}, report } if info.Mode()&os.ModeSymlink != 0 { report.add("task", "", "task directory must not be a symlink") report.Valid = false - return report, nil + return TaskSpec{}, report } if !info.IsDir() { report.add("task", "", "task path is not a directory") report.Valid = false - return report, nil + return TaskSpec{}, report } var spec TaskSpec if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil { @@ -603,18 +262,63 @@ func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) { } else { validateProgress(report, progress) } - for _, rel := range []string{ - "state/directions_tried.json", - "state/findings.jsonl", - "state/iteration_log.jsonl", - "logs/heartbeat.jsonl", - } { - if _, err := storeRoot.Stat(filepath.Join(taskRel, rel)); err != nil { + validateDirections := func() error { + path := filepath.Join(taskRel, "state", "directions_tried.json") + data, err := readArchiveFile(storeRoot, path) + if err != nil { + return err + } + data = fileencoding.DecodeToUTF8(data) + if strings.TrimSpace(string(data)) == "" { + return nil + } + var directions []DirectionTried + if err := json.Unmarshal(data, &directions); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + return nil + } + if err := validateDirections(); err != nil { + report.add("directions_tried.json", "", err.Error()) + } + validateJSONL := func(rel string, each func([]byte) error) { + path := filepath.Join(taskRel, rel) + if err := readJSONL(storeRoot, path, each); err != nil { report.add(filepath.Base(rel), "", err.Error()) } } + validateJSONL("state/findings.jsonl", func(data []byte) error { + var finding Finding + if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &finding); err != nil { + return err + } + return validateFinding(finding) + }) + validateJSONL("state/iteration_log.jsonl", func(data []byte) error { + var entry json.RawMessage + if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &entry); err != nil { + return err + } + return nil + }) + validateJSONL("logs/heartbeat.jsonl", func(data []byte) error { + var heartbeat Heartbeat + if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &heartbeat); err != nil { + return err + } + if strings.TrimSpace(heartbeat.Status) == "" { + return errors.New("heartbeat status is required") + } + if heartbeat.Iteration < 0 { + return errors.New("heartbeat iteration must not be negative") + } + if heartbeat.CreatedAt.IsZero() { + return errors.New("heartbeat created_at is required") + } + return nil + }) report.Valid = len(report.Errors) == 0 - return report, nil + return spec, report } func (s *Store) taskRoot(taskID string) string { @@ -638,86 +342,109 @@ func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) { if err != nil { return nil, "", err } - storeRoot, err := os.OpenRoot(s.root) + storeRoot, err := s.openArchiveRoot() if err != nil { if os.IsNotExist(err) { return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID) } return nil, "", fmt.Errorf("autoresearch: open root dir: %w", err) } - return storeRoot, taskRel, nil -} - -// reserveTaskID atomically claims a task directory with non-recursive Mkdir -// and writes a create-token ownership marker. Concurrent creators sharing the -// same workspace therefore never adopt the same ID: EEXIST advances the -// candidate, and only the Mkdir winner may later roll the directory back. -func (s *Store) reserveTaskID(now time.Time, goal, requestedCreateToken string) (id, createToken string, err error) { - if err := os.MkdirAll(s.root, 0o755); err != nil { - return "", "", fmt.Errorf("autoresearch: create root dir: %w", err) + info, err := storeRoot.Lstat(taskRel) + if err != nil { + storeRoot.Close() + if os.IsNotExist(err) { + return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID) + } + return nil, "", fmt.Errorf("autoresearch: stat task %s: %w", taskID, err) } - storeRoot, err := os.OpenRoot(s.root) + if info.Mode()&os.ModeSymlink != 0 { + storeRoot.Close() + return nil, "", fmt.Errorf("autoresearch: task %s is a symlink", taskID) + } + if !info.IsDir() { + storeRoot.Close() + return nil, "", fmt.Errorf("autoresearch: task %s is not a directory", taskID) + } + taskRoot, err := storeRoot.OpenRoot(taskRel) if err != nil { - return "", "", fmt.Errorf("autoresearch: open root dir: %w", err) + storeRoot.Close() + return nil, "", fmt.Errorf("autoresearch: open task %s: %w", taskID, err) } - defer storeRoot.Close() - token := strings.TrimSpace(requestedCreateToken) - callerSuppliedToken := token != "" - if token == "" { - token, err = newCreateToken() + opened, err := taskRoot.Stat(".") + if err != nil || !os.SameFile(info, opened) { + taskRoot.Close() + storeRoot.Close() if err != nil { - return "", "", err + return nil, "", fmt.Errorf("autoresearch: verify task %s: %w", taskID, err) } - } else if err := validateCreateToken(token); err != nil { - return "", "", err + return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID) } - base := now.Format("20060102-150405") + "-" + slugify(goal) - if base == now.Format("20060102-150405")+"-" { - base += "task" + current, err := storeRoot.Lstat(taskRel) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, current) { + taskRoot.Close() + storeRoot.Close() + if err != nil { + return nil, "", fmt.Errorf("autoresearch: recheck task %s: %w", taskID, err) + } + return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID) + } + if err := storeRoot.Close(); err != nil { + taskRoot.Close() + return nil, "", fmt.Errorf("autoresearch: close archive root: %w", err) } - if callerSuppliedToken { - base += createTokenTaskIDMarker(token) + return taskRoot, ".", nil +} + +// openArchiveRoot anchors every archive read to the resolved workspace root. +// os.Root prevents a concurrent symlink swap from escaping the workspace; the +// explicit Lstat/SameFile checks additionally reject symlinked archive roots. +func (s *Store) openArchiveRoot() (*os.Root, error) { + workspace, err := os.OpenRoot(s.workspaceRoot) + if err != nil { + return nil, fmt.Errorf("autoresearch: open workspace root: %w", err) } - id = base - for i := 2; ; i++ { - taskRel, err := s.taskRel(id) + defer workspace.Close() + + archiveRel := filepath.Join(".reasonix", "autoresearch") + rels := []string{".reasonix", archiveRel} + infos := make([]os.FileInfo, len(rels)) + for i, rel := range rels { + info, err := workspace.Lstat(rel) if err != nil { - return "", "", err + return nil, fmt.Errorf("autoresearch: stat archive path %s: %w", rel, err) } - if err := storeRoot.Mkdir(taskRel, 0o755); err != nil { - if os.IsExist(err) { - id = fmt.Sprintf("%s-%d", base, i) - continue - } - return "", "", fmt.Errorf("autoresearch: reserve task id %s: %w", id, err) + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", rel) } - tokenPath := filepath.Join(taskRel, createTokenFile) - if err := storeRoot.WriteFile(tokenPath, []byte(token+"\n"), 0o600); err != nil { - _ = storeRoot.RemoveAll(taskRel) - return "", "", fmt.Errorf("autoresearch: write create token for %s: %w", id, err) + if !info.IsDir() { + return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", rel) } - return id, token, nil + infos[i] = info } -} -func newCreateToken() (string, error) { - var buf [16]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", fmt.Errorf("autoresearch: generate create token: %w", err) + archive, err := workspace.OpenRoot(archiveRel) + if err != nil { + return nil, fmt.Errorf("autoresearch: open archive root: %w", err) } - return hex.EncodeToString(buf[:]), nil -} - -func validateCreateToken(token string) error { - if !safeCreateToken.MatchString(token) { - return errors.New("autoresearch: create token must be 32 lowercase hexadecimal characters") + opened, err := archive.Stat(".") + if err != nil || !os.SameFile(infos[len(infos)-1], opened) { + archive.Close() + if err != nil { + return nil, fmt.Errorf("autoresearch: verify archive root: %w", err) + } + return nil, errors.New("autoresearch: archive root changed while opening") } - return nil -} - -func createTokenTaskIDMarker(token string) string { - sum := sha256.Sum256([]byte(token)) - return "-txn-" + hex.EncodeToString(sum[:16]) + for i, rel := range rels { + current, err := workspace.Lstat(rel) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], current) { + archive.Close() + if err != nil { + return nil, fmt.Errorf("autoresearch: recheck archive path %s: %w", rel, err) + } + return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", rel) + } + } + return archive, nil } func validateTaskID(id string) error { @@ -731,22 +458,27 @@ func validateTaskID(id string) error { return nil } -func writeJSONFile(root *os.Root, path string, v any) error { - data, err := json.MarshalIndent(v, "", " ") - if err != nil { - return fmt.Errorf("autoresearch: marshal %s: %w", path, err) +// validateFinding checks the base schema fields of a historical finding. +// Kind is intentionally unconstrained so unknown historical values remain +// readable. This helper exists for archive integrity checks and tests only; +// the reader never writes findings. +func validateFinding(f Finding) error { + if strings.TrimSpace(f.ID) == "" { + return errors.New("autoresearch: finding id is required") } - data = append(data, '\n') - if err := root.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("autoresearch: write %s: %w", path, err) + if strings.TrimSpace(f.Summary) == "" { + return errors.New("autoresearch: finding summary is required") + } + if f.CreatedAt.IsZero() { + return errors.New("autoresearch: finding created_at is required") } return nil } func readJSONFile(root *os.Root, path string, out any) error { - data, err := root.ReadFile(path) + data, err := readArchiveFile(root, path) if err != nil { - return fmt.Errorf("read %s: %w", path, err) + return err } data = fileencoding.DecodeToUTF8(data) if err := json.Unmarshal(data, out); err != nil { @@ -755,28 +487,15 @@ func readJSONFile(root *os.Root, path string, out any) error { return nil } -func appendJSONL(root *os.Root, path string, data []byte) error { - if err := root.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("autoresearch: create jsonl dir: %w", err) - } - f, err := root.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return fmt.Errorf("autoresearch: open %s: %w", path, err) - } - defer f.Close() - if _, err := f.Write(append(data, '\n')); err != nil { - return fmt.Errorf("autoresearch: append %s: %w", path, err) - } - return nil -} - func readJSONL(root *os.Root, path string, each func([]byte) error) error { - f, err := root.Open(path) + f, err := openArchiveFile(root, path) if err != nil { return fmt.Errorf("autoresearch: open %s: %w", path, err) } defer f.Close() scanner := bufio.NewScanner(f) + // Historical findings can be long; raise the scanner buffer for safety. + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { @@ -809,7 +528,7 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) { } return lines, nil } - f, err := root.Open(path) + f, err := openArchiveFile(root, path) if err != nil { return nil, fmt.Errorf("autoresearch: open %s: %w", path, err) } @@ -831,9 +550,6 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) { return nil, fmt.Errorf("autoresearch: read %s: %w", path, err) } buf = append(chunk, buf...) - // Stop once the buffered tail holds enough complete lines. Count - // newline-separated non-empty segments after the first newline (the - // first segment may be a partial line unless we reached offset 0). if countCompleteTailLines(buf, off == 0) > limit { break } @@ -856,161 +572,88 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) { return lines, nil } -func countCompleteTailLines(buf []byte, atStart bool) int { - segments := strings.Split(string(buf), "\n") - if !atStart && len(segments) > 0 { - segments = segments[1:] - } - count := 0 - for _, seg := range segments { - if strings.TrimSpace(seg) != "" { - count++ - } - } - return count -} - -func (s *Store) loadDirections(root *os.Root, taskRel string) ([]DirectionTried, error) { - path := filepath.Join(taskRel, "state", "directions_tried.json") - data, err := root.ReadFile(path) +func readArchiveFile(root *os.Root, path string) ([]byte, error) { + f, err := openArchiveFile(root, path) if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - data = fileencoding.DecodeToUTF8(data) - if strings.TrimSpace(string(data)) == "" { - return nil, nil - } - var directions []DirectionTried - if err := json.Unmarshal(data, &directions); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) - } - return directions, nil -} - -func validateFinding(f Finding) error { - if strings.TrimSpace(f.ID) == "" { - return errors.New("autoresearch: finding id is required") - } - switch f.Kind { - case FindingKindCommand, FindingKindFile, FindingKindTest, FindingKindBenchmark, FindingKindManual, FindingKindReview: - default: - return fmt.Errorf("autoresearch: finding kind %q is invalid", f.Kind) - } - if strings.TrimSpace(f.Summary) == "" { - return errors.New("autoresearch: finding summary is required") - } - if f.CreatedAt.IsZero() { - return errors.New("autoresearch: finding created_at is required") - } - return nil -} - -func validateHeartbeat(h Heartbeat) error { - switch h.Status { - case HeartbeatStartingTurn, HeartbeatTurnDone, HeartbeatWarning: - default: - return fmt.Errorf("autoresearch: heartbeat status %q is invalid", h.Status) - } - if h.Iteration < 0 { - return errors.New("autoresearch: heartbeat iteration must not be negative") + return nil, err } - if h.CreatedAt.IsZero() { - return errors.New("autoresearch: heartbeat created_at is required") + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("autoresearch: read %s: %w", path, err) } - return nil + return data, nil } -func stringSliceContains(values []string, want string) bool { - return slices.Contains(values, want) -} - -func cloneCriteria(in []SuccessCriterion) []SuccessCriterion { - out := make([]SuccessCriterion, len(in)) - for i, c := range in { - out[i] = c - out[i].EvidenceIDs = append([]string(nil), c.EvidenceIDs...) +// openArchiveFile rejects symlinks and non-regular files at every path +// component, then binds parsing to the verified file descriptor. The second +// identity check closes the Lstat/open replacement window without holding a +// process-global directory or changing the archive. +func openArchiveFile(root *os.Root, path string) (*os.File, error) { + path = filepath.Clean(path) + if !filepath.IsLocal(path) || path == "." { + return nil, fmt.Errorf("autoresearch: unsafe archive file path %q", path) } - return out -} - -func slugify(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - var b strings.Builder - lastDash := false - for _, r := range s { - switch { - case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)): - b.WriteRune(r) - lastDash = false - default: - if !lastDash && b.Len() > 0 { - b.WriteByte('-') - lastDash = true + parts := strings.Split(path, string(filepath.Separator)) + infos := make([]os.FileInfo, len(parts)) + current := "" + for i, part := range parts { + current = filepath.Join(current, part) + info, err := root.Lstat(current) + if err != nil { + return nil, fmt.Errorf("autoresearch: stat %s: %w", current, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", current) + } + if i < len(parts)-1 { + if !info.IsDir() { + return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", current) } + } else if !info.Mode().IsRegular() { + return nil, fmt.Errorf("autoresearch: archive path %s is not a regular file", current) } + infos[i] = info } - slug := strings.Trim(b.String(), "-") - const maxSlugLen = 56 - if len(slug) > maxSlugLen { - slug = strings.Trim(slug[:maxSlugLen], "-") + + f, err := root.Open(path) + if err != nil { + return nil, fmt.Errorf("autoresearch: open %s: %w", path, err) } - if slug == "" { - return "task" + opened, err := f.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(infos[len(infos)-1], opened) { + f.Close() + if err != nil { + return nil, fmt.Errorf("autoresearch: verify %s: %w", path, err) + } + return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", path) } - return slug -} - -// directionFingerprint identifies a direction for repeat detection. slugify -// alone truncates to 56 chars and drops non-ASCII runes, so two different -// directions sharing a long ASCII prefix (or differing only in CJK text) -// collapsed to one fingerprint and wrongly inflated StaleCount/PivotCount on -// long tasks. Append a hash of the full normalized text when slugify lost -// distinguishing content (truncation or non-ASCII letters/digits); keep the -// bare slug otherwise so fingerprints recorded by older versions still match, -// and punctuation-only differences stay fuzzy-matched as before. -func directionFingerprint(summary string) string { - slug := slugify(summary) - if slug == slugifyUnbounded(summary) && !containsNonASCIIWord(summary) { - return slug - } - normalized := strings.Join(strings.Fields(strings.ToLower(summary)), " ") - h := fnv.New32a() - _, _ = h.Write([]byte(normalized)) - return fmt.Sprintf("%s-%08x", slug, h.Sum32()) -} -// slugifyUnbounded matches slugify without the 56-char cap, used to detect -// whether truncation dropped content. -func slugifyUnbounded(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - var b strings.Builder - lastDash := false - for _, r := range s { - switch { - case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)): - b.WriteRune(r) - lastDash = false - default: - if !lastDash && b.Len() > 0 { - b.WriteByte('-') - lastDash = true + current = "" + for i, part := range parts { + current = filepath.Join(current, part) + info, err := root.Lstat(current) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], info) { + f.Close() + if err != nil { + return nil, fmt.Errorf("autoresearch: recheck %s: %w", current, err) } + return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", current) } } - slug := strings.Trim(b.String(), "-") - if slug == "" { - return "task" - } - return slug + return f, nil } -// containsNonASCIIWord reports whether s carries letters/digits that slugify -// discards entirely (e.g. CJK), meaning distinct summaries could share a slug. -func containsNonASCIIWord(s string) bool { - for _, r := range s { - if r > unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) { - return true +func countCompleteTailLines(buf []byte, atStart bool) int { + segments := strings.Split(string(buf), "\n") + if !atStart && len(segments) > 0 { + segments = segments[1:] + } + count := 0 + for _, seg := range segments { + if strings.TrimSpace(seg) != "" { + count++ } } - return false + return count } diff --git a/internal/autoresearch/store_test.go b/internal/autoresearch/store_test.go index d156415db2..85e7aad63b 100644 --- a/internal/autoresearch/store_test.go +++ b/internal/autoresearch/store_test.go @@ -1,666 +1,393 @@ package autoresearch import ( - "encoding/json" "os" "path/filepath" - "runtime" - "slices" - "strings" - "sync" "testing" "time" ) -func TestCreateTaskCreatesHostOwnedLayoutAndInitialState(t *testing.T) { +func TestLoadTaskReadsHostOwnedLayout(t *testing.T) { root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - store := NewStore(root) - - task, err := store.CreateTask("Find the root cause of UI lag", CreateOptions{ - Now: func() time.Time { - return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC) - }, - Scope: []string{"desktop/frontend", "desktop"}, - NonGoals: []string{"publish a release"}, - AllowedOperations: AllowedOperations{ - Write: true, - Network: false, - Publish: false, - }, - SuccessCriteria: []SuccessCriterion{ - {ID: "root_cause", Description: "A reproducible root cause is identified", Required: true}, - }, + taskID := "20260630-100000-ui-lag" + taskRoot := writeArchiveFixture(t, root, taskID, "Find the root cause of UI lag", []SuccessCriterion{ + {ID: "objective_evidence", Description: "Direct evidence", Required: true}, }) + store := NewStore(root) + task, err := store.LoadTask(taskID) if err != nil { - t.Fatalf("CreateTask returned error: %v", err) + t.Fatalf("LoadTask: %v", err) } - - if task.ID != "20260629-153000-find-the-root-cause-of-ui-lag" { + if task.ID != taskID { t.Fatalf("task id = %q", task.ID) } - wantRoot := filepath.Join(root, ".reasonix", "autoresearch", task.ID) - if task.Root != wantRoot { - t.Fatalf("task root = %q, want %q", task.Root, wantRoot) - } - for _, rel := range []string{ - "state/task_spec.json", - "state/progress.json", - "state/directions_tried.json", - "state/findings.jsonl", - "state/iteration_log.jsonl", - "logs/heartbeat.jsonl", - } { - if _, err := os.Stat(filepath.Join(wantRoot, rel)); err != nil { - t.Fatalf("expected %s to exist: %v", rel, err) - } - } - - var spec TaskSpec - readJSON(t, filepath.Join(wantRoot, "state/task_spec.json"), &spec) - if spec.TaskID != task.ID || spec.Goal != "Find the root cause of UI lag" { - t.Fatalf("spec identity = (%q, %q), want (%q, goal)", spec.TaskID, spec.Goal, task.ID) - } - if len(spec.SuccessCriteria) != 1 || spec.SuccessCriteria[0].ID != "root_cause" || !spec.SuccessCriteria[0].Required { - t.Fatalf("success criteria not persisted correctly: %+v", spec.SuccessCriteria) + if task.Root != taskRoot { + t.Fatalf("task root = %q, want %q", task.Root, taskRoot) } - if !spec.AllowedOperations.Write || spec.AllowedOperations.Network || spec.AllowedOperations.Publish { - t.Fatalf("allowed operations = %+v", spec.AllowedOperations) + if task.Spec.Goal != "Find the root cause of UI lag" { + t.Fatalf("goal = %q", task.Spec.Goal) } - - var progress Progress - readJSON(t, filepath.Join(wantRoot, "state/progress.json"), &progress) - if progress.Status != StatusRunning || progress.Iteration != 0 || progress.StaleCount != 0 || progress.PivotCount != 0 { - t.Fatalf("initial progress = %+v", progress) - } - if progress.UpdatedAt.IsZero() { - t.Fatalf("initial progress updated_at was zero") - } - - report, err := store.ValidateTask(task.ID) + report, err := store.ValidateTask(taskID) if err != nil { - t.Fatalf("ValidateTask returned error: %v", err) + t.Fatalf("ValidateTask: %v", err) } - if !report.Valid || len(report.Errors) != 0 { - t.Fatalf("validation report = %+v, want valid", report) + if !report.Valid { + t.Fatalf("validation errors: %+v", report.Errors) } } -func TestCreateTaskAvoidsIDCollisions(t *testing.T) { +func TestLoadTaskRejectsSymlinkAndUnsafeIDs(t *testing.T) { root := t.TempDir() - store := NewStore(root) - now := func() time.Time { return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC) } - - first, err := store.CreateTask("Investigate cache churn", CreateOptions{Now: now}) - if err != nil { - t.Fatalf("first CreateTask: %v", err) - } - second, err := store.CreateTask("Investigate cache churn", CreateOptions{Now: now}) - if err != nil { - t.Fatalf("second CreateTask: %v", err) - } - - if first.ID != "20260629-153000-investigate-cache-churn" { - t.Fatalf("first id = %q", first.ID) - } - if second.ID != "20260629-153000-investigate-cache-churn-2" { - t.Fatalf("second id = %q", second.ID) - } - if first.CreateToken == "" || second.CreateToken == "" || first.CreateToken == second.CreateToken { - t.Fatalf("create tokens must be unique non-empty ownership proofs: %q vs %q", first.CreateToken, second.CreateToken) + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved } -} - -func TestCreateTaskReservesIDsAtomicallyAcrossStores(t *testing.T) { - root := t.TempDir() - now := func() time.Time { return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC) } - const workers = 8 - type result struct { - task *Task - err error - } - results := make(chan result, workers) - start := make(chan struct{}) - var ready sync.WaitGroup - ready.Add(workers) - for range workers { - go func() { - store := NewStore(root) - ready.Done() - <-start - task, err := store.CreateTask("Concurrent goal reservation", CreateOptions{Now: now}) - results <- result{task: task, err: err} - }() - } - ready.Wait() - close(start) - - ids := map[string]string{} - for range workers { - res := <-results - if res.err != nil { - t.Fatalf("CreateTask worker failed: %v", res.err) - } - if res.task.CreateToken == "" { - t.Fatalf("CreateTask returned empty create token for %s", res.task.ID) - } - if prev, ok := ids[res.task.ID]; ok { - t.Fatalf("duplicate task id %q reserved by tokens %q and %q", res.task.ID, prev, res.task.CreateToken) - } - ids[res.task.ID] = res.task.CreateToken - if _, err := os.Stat(res.task.Root); err != nil { - t.Fatalf("reserved task root missing for %s: %v", res.task.ID, err) - } + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch"), 0o755); err != nil { + t.Fatalf("create autoresearch root: %v", err) } - if len(ids) != workers { - t.Fatalf("got %d unique task ids, want %d", len(ids), workers) + taskID := "symlink-task" + if err := os.Symlink(outside, filepath.Join(root, ".reasonix", "autoresearch", taskID)); err != nil { + t.Fatalf("symlink: %v", err) } -} - -func TestRemoveTaskRequiresMatchingCreateToken(t *testing.T) { - root := t.TempDir() store := NewStore(root) - other := NewStore(root) - task, err := store.CreateTask("Owned rollback only", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - if err := other.RemoveTask(task.ID, "not-the-owner"); err == nil { - t.Fatal("RemoveTask with wrong token succeeded") - } - if _, err := os.Stat(task.Root); err != nil { - t.Fatalf("task directory removed despite token mismatch: %v", err) + if _, err := store.LoadTask(taskID); err == nil { + t.Fatal("LoadTask accepted symlink task") } - if err := store.RemoveTask(task.ID, ""); err == nil { - t.Fatal("RemoveTask without token succeeded") + if _, err := store.LoadTask("../escape"); err == nil { + t.Fatal("LoadTask accepted path traversal id") } - if err := store.RemoveTask(task.ID, task.CreateToken); err != nil { - t.Fatalf("RemoveTask with owner token: %v", err) - } - if _, err := os.Stat(task.Root); !os.IsNotExist(err) { - t.Fatalf("task directory still present after owned remove: %v", err) + if _, err := store.LoadTask("has/slash"); err == nil { + t.Fatal("LoadTask accepted slash id") } } -func TestRemoveTaskByCallerSuppliedCreateToken(t *testing.T) { +func TestLoadTaskRejectsSymlinkedArchiveRoot(t *testing.T) { root := t.TempDir() - store := NewStore(root) - const createToken = "0123456789abcdef0123456789abcdef" - task, err := store.CreateTask("Crash recoverable ownership", CreateOptions{CreateToken: createToken}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - if task.CreateToken != createToken { - t.Fatalf("CreateTask token = %q, want %q", task.CreateToken, createToken) - } - if !strings.Contains(task.ID, createTokenTaskIDMarker(createToken)) { - t.Fatalf("transaction-owned task id %q has no create-token marker", task.ID) - } - if err := store.RemoveTaskByCreateToken(createToken); err != nil { - t.Fatalf("RemoveTaskByCreateToken: %v", err) + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved } - if _, err := os.Stat(task.Root); !os.IsNotExist(err) { - t.Fatalf("task directory still present after token recovery: %v", err) + outside := t.TempDir() + if resolved, err := filepath.EvalSymlinks(outside); err == nil { + outside = resolved } - if err := store.RemoveTaskByCreateToken(createToken); err != nil { - t.Fatalf("repeated RemoveTaskByCreateToken: %v", err) + const taskID = "outside-task" + writeArchiveFixture(t, outside, taskID, "outside workspace goal", nil) + if err := os.MkdirAll(filepath.Join(root, ".reasonix"), 0o755); err != nil { + t.Fatal(err) } -} - -func TestRemoveTaskByCreateTokenRemovesIncompleteReservation(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - const createToken = "fedcba9876543210fedcba9876543210" - taskID := "20260728-120000-incomplete" + createTokenTaskIDMarker(createToken) - taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) - if err := os.MkdirAll(taskRoot, 0o755); err != nil { + outsideRoot := filepath.Join(outside, ".reasonix", "autoresearch") + if err := os.Symlink(outsideRoot, filepath.Join(root, ".reasonix", "autoresearch")); err != nil { t.Fatal(err) } - if err := store.RemoveTaskByCreateToken(createToken); err != nil { - t.Fatalf("RemoveTaskByCreateToken: %v", err) + store := NewStore(root) + if _, err := store.LoadTask(taskID); err == nil { + t.Fatal("LoadTask accepted a symlinked archive root outside the workspace") } - if _, err := os.Stat(taskRoot); !os.IsNotExist(err) { - t.Fatalf("incomplete reservation still present after recovery: %v", err) + if _, err := store.ListSummaries(); err == nil { + t.Fatal("ListSummaries accepted a symlinked archive root outside the workspace") } } -func TestCreateTaskRejectsInvalidCallerSuppliedCreateToken(t *testing.T) { - store := NewStore(t.TempDir()) - if _, err := store.CreateTask("Invalid ownership", CreateOptions{CreateToken: "not-a-token"}); err == nil { - t.Fatal("CreateTask accepted an invalid caller-supplied create token") - } -} +func TestArchiveReaderRejectsSymlinkedTaskContent(t *testing.T) { + t.Run("state directory", func(t *testing.T) { + root := t.TempDir() + writeArchiveFixture(t, root, "source-task", "source goal", nil) + victimRoot := writeArchiveFixture(t, root, "victim-task", "victim goal", nil) + if err := os.RemoveAll(filepath.Join(victimRoot, "state")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "source-task", "state"), filepath.Join(victimRoot, "state")); err != nil { + t.Fatal(err) + } + if _, err := NewStore(root).LoadTask("victim-task"); err == nil { + t.Fatal("LoadTask followed a state-directory symlink into another task") + } + }) -func TestLoadTaskRejectsUnsafeOrMissingID(t *testing.T) { - root := t.TempDir() - store := NewStore(root) + t.Run("task spec file", func(t *testing.T) { + root := t.TempDir() + taskRoot := writeArchiveFixture(t, root, "file-link-task", "linked goal", nil) + specPath := filepath.Join(taskRoot, "state", "task_spec.json") + if err := os.Rename(specPath, filepath.Join(taskRoot, "state", "task_spec.real.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("task_spec.real.json", specPath); err != nil { + t.Fatal(err) + } + if _, err := NewStore(root).LoadTask("file-link-task"); err == nil { + t.Fatal("LoadTask followed a task_spec symlink") + } + }) - for _, id := range []string{"", "../escape", "bad/id", ".hidden"} { - if _, err := store.LoadTask(id); err == nil { - t.Fatalf("LoadTask(%q) succeeded, want error", id) + t.Run("validation file", func(t *testing.T) { + root := t.TempDir() + taskRoot := writeArchiveFixture(t, root, "progress-link-task", "linked progress", nil) + progressPath := filepath.Join(taskRoot, "state", "progress.json") + if err := os.Rename(progressPath, filepath.Join(taskRoot, "state", "progress.real.json")); err != nil { + t.Fatal(err) } - } - if _, err := store.LoadTask("20260629-153000-missing"); err == nil || !strings.Contains(err.Error(), "not found") { - t.Fatalf("LoadTask missing error = %v, want not found", err) - } + if err := os.Symlink("progress.real.json", progressPath); err != nil { + t.Fatal(err) + } + report, err := NewStore(root).ValidateTask("progress-link-task") + if err != nil { + t.Fatalf("ValidateTask: %v", err) + } + if report.Valid { + t.Fatal("ValidateTask accepted a symlinked progress file") + } + }) } -func TestLoadTaskRejectsSymlinkTaskDirectory(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("creating directory symlinks requires elevated privileges on many Windows hosts") - } - root := t.TempDir() - store := NewStore(root) - taskID := "20260630-120000-symlink-task" - outside := t.TempDir() - if err := os.MkdirAll(filepath.Join(outside, "state"), 0o755); err != nil { - t.Fatalf("create outside state: %v", err) - } - if err := os.WriteFile(filepath.Join(outside, "state", "task_spec.json"), []byte(`{"task_id":"20260630-120000-symlink-task","goal":"escape","success_criteria":[]}`), 0o644); err != nil { - t.Fatalf("write outside spec: %v", err) +func TestLoadTaskRejectsUnreadableArchiveFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can bypass archive file permissions") } - if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch"), 0o755); err != nil { - t.Fatalf("create autoresearch root: %v", err) - } - if err := os.Symlink(outside, filepath.Join(root, ".reasonix", "autoresearch", taskID)); err != nil { - t.Fatalf("create symlink task: %v", err) - } - - if _, err := store.LoadTask(taskID); err == nil || !strings.Contains(err.Error(), "symlink") { - t.Fatalf("LoadTask symlink error = %v, want symlink rejection", err) - } -} - -func TestValidateTaskReportsSchemaErrors(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Validate schema errors", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - specPath := filepath.Join(task.Root, "state/task_spec.json") - var spec TaskSpec - readJSON(t, specPath, &spec) - spec.Goal = "" - writeJSON(t, specPath, spec) - - report, err := store.ValidateTask(task.ID) - if err != nil { - t.Fatalf("ValidateTask returned error: %v", err) - } - if report.Valid { - t.Fatalf("ValidateTask reported valid for missing goal") + taskRoot := writeArchiveFixture(t, root, "permission-task", "permission goal", nil) + specPath := filepath.Join(taskRoot, "state", "task_spec.json") + if err := os.Chmod(specPath, 0); err != nil { + t.Fatal(err) } - if !containsValidationError(report.Errors, "task_spec.json", "goal") { - t.Fatalf("validation errors = %+v, want task_spec.json goal error", report.Errors) + t.Cleanup(func() { _ = os.Chmod(specPath, 0o644) }) + if _, err := NewStore(root).LoadTask("permission-task"); err == nil { + t.Fatal("LoadTask accepted an unreadable task_spec.json") } } -func TestAppendFindingRecordsAcceptedEvidenceForReadiness(t *testing.T) { +func TestFindingsPreserveVerificationAndUnknownKinds(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Verify accepted findings", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "verified", Description: "Verification evidence exists", Required: true, EvidenceIDs: []string{"f1"}}, - }, + taskID := "findings-kinds" + taskRoot := writeArchiveFixture(t, root, taskID, "Read evidence kinds", []SuccessCriterion{ + {ID: "verified", Description: "Verified", Required: true, EvidenceIDs: []string{"f-verify"}}, }) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - err = store.AppendFinding(task.ID, Finding{ - ID: "f1", - Kind: FindingKindTest, - Summary: "go test ./internal/autoresearch passed", + appendFindingLine(t, taskRoot, Finding{ + ID: "f-verify", + Kind: "verification", + Summary: "targeted test passed", Source: FindingSourceCommand, Command: "go test ./internal/autoresearch", Accepted: true, - CreatedAt: time.Date(2026, 6, 29, 16, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), }) - if err != nil { - t.Fatalf("AppendFinding: %v", err) - } - - readiness, err := store.Readiness(task.ID) - if err != nil { - t.Fatalf("Readiness: %v", err) - } - if !readiness.Ready || len(readiness.MissingCriteria) != 0 { - t.Fatalf("readiness = %+v, want ready", readiness) - } - - findings, err := store.Findings(task.ID, 10) + appendFindingLine(t, taskRoot, Finding{ + ID: "f-future", + Kind: "future_kind", + Summary: "preserve unknown evidence", + Source: FindingSourceManual, + Accepted: true, + CreatedAt: time.Date(2026, 6, 30, 10, 1, 0, 0, time.UTC), + }) + store := NewStore(root) + findings, err := store.Findings(taskID, 0) if err != nil { t.Fatalf("Findings: %v", err) } - if len(findings) != 1 || findings[0].ID != "f1" || !findings[0].Accepted { + if len(findings) != 2 { t.Fatalf("findings = %+v", findings) } -} - -func TestRecordEvidenceLinksFindingToCriterionAndSatisfiesReadiness(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Record structured evidence", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "verified", Description: "Verification evidence exists", Required: true}, - }, - }) - if err != nil { - t.Fatalf("CreateTask: %v", err) + if findings[0].Kind != "future_kind" || findings[1].Kind != "verification" { + t.Fatalf("kinds = %+v, want newest-first future then verification", findings) } - - err = store.RecordEvidence(task.ID, "verified", Finding{ - ID: "f1", - Kind: FindingKindTest, - Summary: "targeted test passed", - Source: FindingSourceCommand, - Command: "go test ./internal/autoresearch", - Accepted: true, - CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordEvidence: %v", err) + if err := validateFinding(findings[0]); err != nil { + t.Fatalf("validateFinding unknown kind: %v", err) } - - var spec TaskSpec - readJSON(t, filepath.Join(task.Root, "state/task_spec.json"), &spec) - if len(spec.SuccessCriteria) != 1 || len(spec.SuccessCriteria[0].EvidenceIDs) != 1 || spec.SuccessCriteria[0].EvidenceIDs[0] != "f1" { - t.Fatalf("criterion evidence ids = %+v, want f1", spec.SuccessCriteria) + if err := validateFinding(Finding{ID: "", Kind: "anything", Summary: "x", CreatedAt: time.Now()}); err == nil { + t.Fatal("validateFinding accepted empty id") } - readiness, err := store.Readiness(task.ID) + summary, err := store.Summary(taskID) if err != nil { - t.Fatalf("Readiness: %v", err) + t.Fatalf("Summary: %v", err) } - if !readiness.Ready { - t.Fatalf("readiness = %+v, want ready after linked accepted evidence", readiness) + if len(summary.OpenCriteria) != 0 { + t.Fatalf("summary = %+v, want verification evidence to satisfy the legacy criterion", summary) } } -func TestRecordEvidenceRejectsUnknownCriterion(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Reject unknown criterion", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "known", Description: "Known criterion", Required: true}, - }, - }) - if err != nil { - t.Fatalf("CreateTask: %v", err) +func TestValidateFindingDoesNotEnumerateKind(t *testing.T) { + now := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC) + if err := validateFinding(Finding{ID: "f1", Kind: "totally-unknown", Summary: "ok", CreatedAt: now}); err != nil { + t.Fatalf("unknown kind rejected: %v", err) } - - err = store.RecordEvidence(task.ID, "missing", Finding{ - ID: "f1", - Kind: FindingKindTest, - Summary: "targeted test passed", - Source: FindingSourceCommand, - Accepted: true, - CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), - }) - if err == nil || !strings.Contains(err.Error(), "criterion") { - t.Fatalf("RecordEvidence unknown criterion error = %v", err) + if err := validateFinding(Finding{ID: "f1", Kind: "verification", Summary: "ok", CreatedAt: now}); err != nil { + t.Fatalf("verification rejected: %v", err) + } + if err := validateFinding(Finding{ID: "f1", Kind: "", Summary: "ok", CreatedAt: now}); err != nil { + t.Fatalf("empty kind should still pass base validation: %v", err) } } -func TestAppendFindingRejectsInvalidEntry(t *testing.T) { +func TestSummaryReportsMissingCriteria(t *testing.T) { root := t.TempDir() + taskID := "missing-criteria" + writeArchiveFixture(t, root, taskID, "Block incomplete completion", []SuccessCriterion{ + {ID: "objective_evidence", Description: "Direct evidence", Required: true}, + {ID: "verification", Description: "Verification", Required: true}, + }) store := NewStore(root) - task, err := store.CreateTask("Reject invalid findings", CreateOptions{}) + summary, err := store.Summary(taskID) if err != nil { - t.Fatalf("CreateTask: %v", err) + t.Fatalf("Summary: %v", err) } - - err = store.AppendFinding(task.ID, Finding{ID: "", Kind: FindingKindTest, Summary: "missing id", Accepted: true, CreatedAt: time.Now()}) - if err == nil || !strings.Contains(err.Error(), "id") { - t.Fatalf("AppendFinding invalid error = %v, want id error", err) + if len(summary.OpenCriteria) != 2 { + t.Fatalf("summary = %+v, want missing both criteria", summary) } } -func TestRecordDirectionIncrementsStaleAndRequiresPivot(t *testing.T) { +func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) { root := t.TempDir() + taskID := "20260630-resume-path" + writeArchiveFixture(t, root, taskID, "Resume explicit path", nil) store := NewStore(root) - task, err := store.CreateTask("Detect repeated directions", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) + resumed, ok, err := store.ResumeFromGoalText("继续 .reasonix/autoresearch/" + taskID + "/ 这个任务") + if err != nil || !ok { + t.Fatalf("ResumeFromGoalText: ok=%v err=%v", ok, err) } - - progress, err := store.RecordDirection(task.ID, Direction{ - Summary: "Profile markdown rendering", - AcceptedEvidenceIDs: []string{"f1"}, - Now: time.Date(2026, 6, 29, 16, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection first: %v", err) + if resumed.ID != taskID || resumed.Spec.Goal != "Resume explicit path" { + t.Fatalf("resumed = %+v", resumed) } - if progress.Iteration != 1 || progress.StaleCount != 0 || progress.CurrentDirection != "Profile markdown rendering" { - t.Fatalf("first progress = %+v", progress) + if _, ok, err := store.ResumeFromGoalText("ordinary goal text"); err != nil || ok { + t.Fatalf("ordinary text matched archive: ok=%v err=%v", ok, err) } - - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: "Profile markdown rendering", - Now: time.Date(2026, 6, 29, 16, 1, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection second: %v", err) + if _, ok, err := store.ResumeFromGoalText("resume .reasonix/autoresearch/missing-task/"); !ok || err == nil { + t.Fatalf("missing task should fail closed: ok=%v err=%v", ok, err) } - if progress.Iteration != 2 || progress.StaleCount != 1 { - t.Fatalf("second progress = %+v, want iteration 2 stale 1", progress) - } - - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: "Profile markdown rendering", - Now: time.Date(2026, 6, 29, 16, 2, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection third: %v", err) - } - if progress.StaleCount != 2 || progress.PivotCount != 1 { - t.Fatalf("third progress = %+v, want stale 2 pivot 1", progress) - } - - summary, err := store.Summary(task.ID) - if err != nil { - t.Fatalf("Summary: %v", err) - } - if !summary.PivotRequired || summary.NextRequiredAction == "" { - t.Fatalf("summary = %+v, want pivot required with next action", summary) + for _, input := range []string{ + "resume .reasonix/autoresearch/../escape", + "resume .reasonix/autoresearch/" + taskID + "/../../escape", + "resume .reasonix/autoresearch/" + taskID + "/extra", + "resume .reasonix/autoresearch/" + taskID + `\extra`, + "resume .reasonix/autoresearch/", + } { + if _, ok, err := store.ResumeFromGoalText(input); !ok || err == nil { + t.Errorf("unsafe explicit path %q did not fail closed: ok=%v err=%v", input, ok, err) + } } } -func TestConcurrentDirectionWritesAreSerializedPerTask(t *testing.T) { +func TestListSummariesAndSummaryAreReadOnly(t *testing.T) { root := t.TempDir() + firstID := "20260630-first" + secondID := "20260630-second" + firstRoot := writeArchiveFixture(t, root, firstID, "First research task", nil) + writeArchiveFixture(t, root, secondID, "Second research task", nil) + writeProgress(t, firstRoot, Progress{ + Status: StatusRunning, + Iteration: 3, + CurrentDirection: "inspect logs", + StaleCount: 2, + PivotCount: 1, + UpdatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC), + }) + appendHeartbeatLine(t, firstRoot, Heartbeat{ + Status: HeartbeatTurnDone, + Iteration: 3, + CreatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC), + }) + before := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch")) + beforeModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch")) store := NewStore(root) - task, err := store.CreateTask("Serialize concurrent directions", CreateOptions{}) + list, err := store.ListSummaries() if err != nil { - t.Fatalf("CreateTask: %v", err) + t.Fatalf("ListSummaries: %v", err) } - - const writers = 20 - var wg sync.WaitGroup - errs := make(chan error, writers) - for i := range writers { - wg.Add(1) - go func(i int) { - defer wg.Done() - _, err := store.RecordDirection(task.ID, Direction{ - Summary: "direction", - Now: time.Date(2026, 6, 30, 8, 0, i, 0, time.UTC), - }) - errs <- err - }(i) - } - wg.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatalf("RecordDirection concurrent error: %v", err) - } + if len(list) != 2 { + t.Fatalf("list = %+v", list) } - - summary, err := store.Summary(task.ID) + summary, err := store.Summary(firstID) if err != nil { t.Fatalf("Summary: %v", err) } - if summary.Iteration != writers || summary.StaleCount != writers { - t.Fatalf("summary = %+v, want %d serialized iterations", summary, writers) + if summary.Iteration != 3 || !summary.PivotRequired || summary.NextRequiredAction == "" { + t.Fatalf("summary = %+v", summary) } -} - -func TestReadinessBlocksMissingEvidenceAndBlockedStatus(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Block incomplete completion", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "root_cause", Description: "Root cause identified", Required: true}, - }, - }) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - readiness, err := store.Readiness(task.ID) - if err != nil { - t.Fatalf("Readiness missing: %v", err) + after := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch")) + if len(before) != len(after) { + t.Fatalf("archive file count changed: before=%d after=%d", len(before), len(after)) } - if readiness.Ready || !containsString(readiness.MissingCriteria, "root_cause") { - t.Fatalf("readiness missing = %+v, want root_cause missing", readiness) - } - - _, err = store.UpdateProgress(task.ID, ProgressPatch{Status: ptrString(StatusBlocked), BlockedReason: ptrString("needs user input")}) - if err != nil { - t.Fatalf("UpdateProgress: %v", err) - } - readiness, err = store.Readiness(task.ID) - if err != nil { - t.Fatalf("Readiness blocked: %v", err) + for path, content := range before { + if after[path] != content { + t.Fatalf("archive mutated at %s", path) + } } - if readiness.Ready || !strings.Contains(readiness.BlockedReason, "needs user input") { - t.Fatalf("blocked readiness = %+v", readiness) + afterModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch")) + for path, modTime := range beforeModTimes { + if !afterModTimes[path].Equal(modTime) { + t.Fatalf("archive modification time changed at %s", path) + } } } -func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) { +func TestValidateTaskRejectsCorruptJSON(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Resume explicit path", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) + taskID := "corrupt-json" + taskRoot := writeArchiveFixture(t, root, taskID, "Validate schema errors", nil) + if err := os.WriteFile(filepath.Join(taskRoot, "state", "progress.json"), []byte("{not-json"), 0o644); err != nil { + t.Fatal(err) } - - resumed, ok, err := store.ResumeFromGoalText("继续 .reasonix/autoresearch/" + task.ID + "/ 这个任务") + store := NewStore(root) + report, err := store.ValidateTask(taskID) if err != nil { - t.Fatalf("ResumeFromGoalText: %v", err) + t.Fatalf("ValidateTask: %v", err) } - if !ok || resumed.ID != task.ID { - t.Fatalf("resumed = %+v ok=%v, want %s", resumed, ok, task.ID) + if report.Valid { + t.Fatal("corrupt progress reported valid") } } -func TestListSummariesReturnsWorkspaceTasks(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - first, err := store.CreateTask("First research task", CreateOptions{ - Now: func() time.Time { return time.Date(2026, 6, 29, 10, 0, 0, 0, time.UTC) }, - }) - if err != nil { - t.Fatalf("CreateTask first: %v", err) - } - second, err := store.CreateTask("Second research task", CreateOptions{ - Now: func() time.Time { return time.Date(2026, 6, 29, 11, 0, 0, 0, time.UTC) }, +func TestValidateTaskRejectsCorruptArchiveLogsButAcceptsUnknownFindingKinds(t *testing.T) { + t.Run("corrupt finding JSON", func(t *testing.T) { + root := t.TempDir() + taskID := "corrupt-finding-json" + taskRoot := writeArchiveFixture(t, root, taskID, "Validate finding JSON", nil) + if err := os.WriteFile(filepath.Join(taskRoot, "state", "findings.jsonl"), []byte("{not-json\n"), 0o644); err != nil { + t.Fatal(err) + } + report, err := NewStore(root).ValidateTask(taskID) + if err != nil { + t.Fatal(err) + } + if report.Valid { + t.Fatal("corrupt finding JSON reported valid") + } }) - if err != nil { - t.Fatalf("CreateTask second: %v", err) - } - summaries, err := store.ListSummaries() - if err != nil { - t.Fatalf("ListSummaries: %v", err) - } - if len(summaries) != 2 { - t.Fatalf("summaries = %+v, want two tasks", summaries) - } - if summaries[0].TaskID != second.ID || summaries[1].TaskID != first.ID { - t.Fatalf("summaries order = %+v, want newest task id first", summaries) - } - if summaries[0].Goal != "Second research task" || summaries[1].Goal != "First research task" { - t.Fatalf("summaries goals = %+v", summaries) - } + t.Run("unknown finding kind", func(t *testing.T) { + root := t.TempDir() + taskID := "unknown-finding-kind" + taskRoot := writeArchiveFixture(t, root, taskID, "Accept future finding kind", nil) + appendFindingLine(t, taskRoot, Finding{ + ID: "future", Kind: "future-kind", Summary: "preserve me", Accepted: true, + CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), + }) + report, err := NewStore(root).ValidateTask(taskID) + if err != nil { + t.Fatal(err) + } + if !report.Valid { + t.Fatalf("unknown finding kind rejected: %+v", report.Errors) + } + }) } -func TestAppendHeartbeatRecordsDurableTurnStatus(t *testing.T) { +func TestHeartbeatsTailRead(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Record heartbeats", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - err = store.AppendHeartbeat(task.ID, Heartbeat{ - Status: HeartbeatStartingTurn, - Iteration: 1, - Message: "starting", - CreatedAt: time.Date(2026, 6, 29, 17, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("AppendHeartbeat: %v", err) + taskID := "heartbeats" + taskRoot := writeArchiveFixture(t, root, taskID, "Record heartbeats", nil) + for i := 1; i <= 5; i++ { + appendHeartbeatLine(t, taskRoot, Heartbeat{ + Status: HeartbeatTurnDone, + Iteration: i, + CreatedAt: time.Date(2026, 6, 30, 10, i, 0, 0, time.UTC), + }) } - - heartbeats, err := store.Heartbeats(task.ID, 10) + store := NewStore(root) + heartbeats, err := store.Heartbeats(taskID, 2) if err != nil { t.Fatalf("Heartbeats: %v", err) } - if len(heartbeats) != 1 || heartbeats[0].Status != HeartbeatStartingTurn || heartbeats[0].Iteration != 1 { + if len(heartbeats) != 2 || heartbeats[0].Iteration != 4 || heartbeats[1].Iteration != 5 { t.Fatalf("heartbeats = %+v", heartbeats) } - summary, err := store.Summary(task.ID) - if err != nil { - t.Fatalf("Summary: %v", err) - } - if !summary.LastHeartbeatAt.Equal(heartbeats[0].CreatedAt) { - t.Fatalf("summary last heartbeat = %s, want %s", summary.LastHeartbeatAt, heartbeats[0].CreatedAt) - } -} - -func readJSON(t *testing.T, path string, out any) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile(%s): %v", path, err) - } - if err := json.Unmarshal(data, out); err != nil { - t.Fatalf("Unmarshal(%s): %v", path, err) - } -} - -func writeJSON(t *testing.T, path string, v any) { - t.Helper() - data, err := json.MarshalIndent(v, "", " ") - if err != nil { - t.Fatalf("MarshalIndent: %v", err) - } - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatalf("WriteFile(%s): %v", path, err) - } -} - -func containsValidationError(errors []ValidationError, file, field string) bool { - for _, err := range errors { - if err.File == file && err.Field == field { - return true - } - } - return false -} - -func containsString(values []string, want string) bool { - return slices.Contains(values, want) -} - -func ptrString(s string) *string { - return &s } diff --git a/internal/autoresearch/summary.go b/internal/autoresearch/summary.go index 8c967cf1a9..a9ba8f0d08 100644 --- a/internal/autoresearch/summary.go +++ b/internal/autoresearch/summary.go @@ -73,3 +73,23 @@ func nextRequiredAction(progress Progress) string { } return "continue with the next evidence-producing step" } + +func acceptedFindingIDs(findings []Finding) map[string]bool { + accepted := make(map[string]bool, len(findings)) + for _, finding := range findings { + if finding.Accepted { + accepted[finding.ID] = true + } + } + return accepted +} + +func countAcceptedEvidence(ids []string, accepted map[string]bool) int { + count := 0 + for _, id := range ids { + if accepted[id] { + count++ + } + } + return count +} diff --git a/internal/autoresearch/task.go b/internal/autoresearch/task.go index a0251c2b4c..4575850ea2 100644 --- a/internal/autoresearch/task.go +++ b/internal/autoresearch/task.go @@ -10,26 +10,11 @@ const ( StatusInvalid = "invalid" ) +// Task is a read-only view of a historical AutoResearch archive. type Task struct { ID string Root string Spec TaskSpec - // CreateToken is an opaque ownership proof for the directory reserved by - // CreateTask. Rollback helpers must pass it to RemoveTask so a failed - // transaction cannot delete a task directory another creator reserved. - CreateToken string -} - -type CreateOptions struct { - Now func() time.Time - // CreateToken optionally supplies the ownership proof written during task - // reservation. Durable parent transactions persist it before calling - // CreateTask so crash recovery can find and remove an uncommitted task. - CreateToken string - Scope []string - NonGoals []string - AllowedOperations AllowedOperations - SuccessCriteria []SuccessCriterion } type AllowedOperations struct { @@ -64,21 +49,13 @@ type Progress struct { UpdatedAt time.Time `json:"updated_at"` } -const ( - FindingKindCommand = "command" - FindingKindFile = "file" - FindingKindTest = "test" - FindingKindBenchmark = "benchmark" - FindingKindManual = "manual" - FindingKindReview = "review" -) - const ( FindingSourceCommand = "command" FindingSourceFile = "file" FindingSourceManual = "manual" ) +// Finding.Kind is an opaque string. Unknown historical values must round-trip. type Finding struct { ID string `json:"id"` Kind string `json:"kind"` @@ -103,12 +80,6 @@ type Heartbeat struct { CreatedAt time.Time `json:"created_at"` } -type Direction struct { - Summary string - AcceptedEvidenceIDs []string - Now time.Time -} - type DirectionTried struct { Fingerprint string `json:"fingerprint"` Summary string `json:"summary"` @@ -117,12 +88,6 @@ type DirectionTried struct { Count int `json:"count"` } -type ProgressPatch struct { - Status *string - CurrentDirection *string - BlockedReason *string -} - type CriterionSummary struct { ID string `json:"id"` Description string `json:"description"` @@ -148,13 +113,6 @@ type Summary struct { NextRequiredAction string `json:"next_required_action"` } -type ReadinessReport struct { - Ready bool `json:"ready"` - MissingCriteria []string `json:"missing_criteria"` - BlockedReason string `json:"blocked_reason"` - Errors []string `json:"errors"` -} - type ValidationError struct { File string `json:"file"` Field string `json:"field"` diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index 94fdd5bad4..f783a4b244 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -2049,83 +2049,6 @@ model = "x" } } -func TestBootToolContractMatchesProviderVisibleSurface(t *testing.T) { - for _, tc := range []struct { - name string - tokenMode string - }{ - {name: "default", tokenMode: ""}, - {name: "economy", tokenMode: TokenModeEconomy}, - } { - t.Run(tc.name, func(t *testing.T) { - isolateConfigHome(t) - dir := robustTempDir(t) - t.Chdir(dir) - writeFile(t, dir, "reasonix.toml", ` -default_model = "test-model" - -[agent] -system_prompt = "BASE" - -[[providers]] -name = "test-model" -kind = "boot-token-profile-test" -model = "x" -`) - - req, entries := captureTokenProfileSurface(t, tc.tokenMode) - wantNames := defaultFullBootToolNames() - if tc.tokenMode == TokenModeEconomy { - wantNames = economyBootToolNames() - } - if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) { - t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames) - } - if len(entries) != len(req.Tools) { - t.Fatalf("contract entries = %d, provider tools = %d\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools)) - } - for i, e := range entries { - s := req.Tools[i] - if e.Name != s.Name { - t.Fatalf("tool[%d] name = %q, want %q\ncontract=%v\nprovider=%v", i, e.Name, s.Name, contractEntryNames(entries), toolSchemaNames(req.Tools)) - } - if e.Description != strings.TrimSpace(s.Description) { - t.Fatalf("%s description drift\ncontract=%q\nprovider=%q", e.Name, e.Description, s.Description) - } - if !json.Valid(e.Schema) { - t.Fatalf("%s contract schema is invalid JSON: %s", e.Name, e.Schema) - } - if got := string(provider.CanonicalizeSchema(e.Schema)); got != string(e.Schema) { - t.Fatalf("%s contract schema is not canonical", e.Name) - } - if string(e.Schema) != string(s.Parameters) { - t.Fatalf("%s schema drift\ncontract=%s\nprovider=%s", e.Name, e.Schema, s.Parameters) - } - } - readOnly := map[string]bool{} - for _, e := range entries { - readOnly[e.Name] = e.ReadOnly - } - for name, want := range map[string]bool{ - "bash": false, - "read_file": true, - "connect_tool_source": tc.tokenMode == TokenModeEconomy, - } { - got, ok := readOnly[name] - if !ok { - if name == "connect_tool_source" && tc.tokenMode != TokenModeEconomy { - continue - } - t.Fatalf("contract missing %s; tools=%v", name, contractEntryNames(entries)) - } - if got != want { - t.Fatalf("%s ReadOnly = %v, want %v", name, got, want) - } - } - }) - } -} - func TestToolContractDocCoversDefaultBootSurfaces(t *testing.T) { pkgDir, err := os.Getwd() if err != nil { diff --git a/internal/boot/tool_contract_surface_test.go b/internal/boot/tool_contract_surface_test.go new file mode 100644 index 0000000000..779ae1b9a9 --- /dev/null +++ b/internal/boot/tool_contract_surface_test.go @@ -0,0 +1,87 @@ +package boot + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "reasonix/internal/provider" +) + +func TestBootToolContractMatchesProviderVisibleSurface(t *testing.T) { + for _, tc := range []struct { + name string + tokenMode string + }{ + {name: "default", tokenMode: ""}, + {name: "economy", tokenMode: TokenModeEconomy}, + } { + t.Run(tc.name, func(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-token-profile-test" +model = "x" +`) + + req, entries := captureTokenProfileSurface(t, tc.tokenMode) + wantNames := defaultFullBootToolNames() + if tc.tokenMode == TokenModeEconomy { + wantNames = economyBootToolNames() + } + if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) { + t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames) + } + if len(entries) != len(req.Tools) { + t.Fatalf("contract entries = %d, provider tools = %d\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools)) + } + for i, e := range entries { + s := req.Tools[i] + if e.Name != s.Name { + t.Fatalf("tool[%d] name = %q, want %q\ncontract=%v\nprovider=%v", i, e.Name, s.Name, contractEntryNames(entries), toolSchemaNames(req.Tools)) + } + if e.Description != strings.TrimSpace(s.Description) { + t.Fatalf("%s description drift\ncontract=%q\nprovider=%q", e.Name, e.Description, s.Description) + } + if !json.Valid(e.Schema) { + t.Fatalf("%s contract schema is invalid JSON: %s", e.Name, e.Schema) + } + if got := string(provider.CanonicalizeSchema(e.Schema)); got != string(e.Schema) { + t.Fatalf("%s contract schema is not canonical", e.Name) + } + if string(e.Schema) != string(s.Parameters) { + t.Fatalf("%s schema drift\ncontract=%s\nprovider=%s", e.Name, e.Schema, s.Parameters) + } + } + readOnly := map[string]bool{} + for _, e := range entries { + readOnly[e.Name] = e.ReadOnly + } + for name, want := range map[string]bool{ + "bash": false, + "read_file": true, + "connect_tool_source": tc.tokenMode == TokenModeEconomy, + } { + got, ok := readOnly[name] + if !ok { + if name == "connect_tool_source" && tc.tokenMode != TokenModeEconomy { + continue + } + t.Fatalf("contract missing %s; tools=%v", name, contractEntryNames(entries)) + } + if got != want { + t.Fatalf("%s ReadOnly = %v, want %v", name, got, want) + } + } + }) + } +} diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 4d37314bf3..9a118d74dc 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -4834,14 +4834,9 @@ func (m *chatTUI) runGoalSubcommand(input string) tea.Cmd { m.notice(i18n.M.GoalEmpty) return nil } - switch cmd.Action { + switch m.noticeDeprecatedGoalBudget(cmd); cmd.Action { case control.GoalCommandSet: - m.planMode = false - m.ctrl.SetPlanMode(false) - m.ctrl.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode) - m.ctrl.GoalStrict(cmd.Strict) - m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(cmd.Text))) - return m.startTurn("Start pursuing the active goal now.", input, input) + return m.setGoalCommand(cmd, input) case control.GoalCommandClear: m.echoLocalCommand(input) m.ctrl.ClearGoal() diff --git a/internal/cli/chat_tui_goal.go b/internal/cli/chat_tui_goal.go new file mode 100644 index 0000000000..b6081cc380 --- /dev/null +++ b/internal/cli/chat_tui_goal.go @@ -0,0 +1,29 @@ +package cli + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" + + "reasonix/internal/control" + "reasonix/internal/i18n" +) + +func (m *chatTUI) noticeDeprecatedGoalBudget(cmd control.GoalCommand) { + if cmd.DeprecatedBudgetFlag { + m.notice(control.GoalBudgetFlagDeprecatedNotice) + } +} + +func (m *chatTUI) setGoalCommand(cmd control.GoalCommand, input string) tea.Cmd { + m.planMode = false + m.ctrl.SetPlanMode(false) + m.ctrl.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode) + m.ctrl.GoalStrict(cmd.Strict) + if m.ctrl.GoalStatus() != control.GoalStatusRunning { + m.echoLocalCommand(input) + return nil + } + m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(m.ctrl.Goal()))) + return m.startTurn("Start pursuing the active goal now.", input, input) +} diff --git a/internal/cli/chat_tui_goal_test.go b/internal/cli/chat_tui_goal_test.go new file mode 100644 index 0000000000..692f2e6a91 --- /dev/null +++ b/internal/cli/chat_tui_goal_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "reasonix/internal/control" +) + +func TestGoalLegacyBudgetFlagNoticesExactlyOnce(t *testing.T) { + m := newTestChatTUI() + m.ctrl = control.New(control.Options{}) + t.Cleanup(m.ctrl.Close) + + m.runGoalSubcommand("/goal --research investigate the failure") + + joined := ansi.Strip(strings.Join(*m.pendingCommit, "\n")) + if got := strings.Count(joined, control.GoalBudgetFlagDeprecatedNotice); got != 1 { + t.Fatalf("deprecated budget notices = %d, want 1:\n%s", got, joined) + } +} + +func TestMissingLegacyGoalCommandDoesNotStartTUITurn(t *testing.T) { + m := newTestChatTUI() + m.ctrl = control.New(control.Options{WorkspaceRoot: t.TempDir()}) + t.Cleanup(m.ctrl.Close) + + if cmd := m.runGoalSubcommand("/goal resume .reasonix/autoresearch/missing-task/"); cmd != nil { + t.Fatal("missing legacy archive returned a provider turn command") + } + if got := m.ctrl.GoalStatus(); got != control.GoalStatusBlocked { + t.Fatalf("GoalStatus() = %q, want blocked", got) + } +} diff --git a/internal/control/autoresearch_manager.go b/internal/control/autoresearch_manager.go index fd84afbc1a..9c2ba87c5e 100644 --- a/internal/control/autoresearch_manager.go +++ b/internal/control/autoresearch_manager.go @@ -1,405 +1,211 @@ package control -// AutoResearch task lifecycle: creating/resuming tasks for research-mode -// goals, recording per-turn evidence and direction, and reporting readiness. -// autoResearchManager is a strict leaf over the workspace autoresearch.Store — -// it never touches Controller state; the Controller wrappers below resolve the -// active task from the goal machine and own notices. +// legacyResearchArchive is a read-only compatibility boundary for Goal +// sidecars and prompts that still reference an old .reasonix/autoresearch +// task. New Goal runs never create, update, list, or expose those archives. import ( - "encoding/json" - "errors" - "fmt" "log/slog" - "sort" "strings" - "time" - "reasonix/internal/agent" "reasonix/internal/autoresearch" + "reasonix/internal/evidence" ) -type autoResearchSetup struct { +type legacyResearchSetup struct { + // goal is the original objective recovered from task_spec.json when the + // user named an explicit archive path. Empty when no archive was referenced. + goal string taskID string - createToken string blockReason string notice string - created bool + explicit bool } -// autoResearchManager wraps the optional workspace autoresearch.Store. The -// zero value (no store) disables the subsystem; every method is nil-safe. -type autoResearchManager struct { +type legacyResearchArchive struct { store *autoresearch.Store } -func (m autoResearchManager) enabled() bool { - return m.store != nil -} - -// prepare resumes the task matching goal text or creates a fresh one. The -// caller has already decided AutoResearch applies and that no running goal -// owns the task. -func (m autoResearchManager) prepare(goal, createToken string) autoResearchSetup { - if m.store == nil { - return autoResearchSetup{} - } - if task, ok, err := m.store.ResumeFromGoalText(goal); err != nil { - slog.Warn("controller: resume autoresearch task", "err", err) - if ok { - return autoResearchSetup{blockReason: err.Error()} - } - } else if ok { - return autoResearchSetup{taskID: task.ID, notice: "autoresearch task resumed: " + task.ID} +// prepare reads an explicitly referenced legacy task. It has no create path +// and never mutates the archive, even when validation fails. +func (m legacyResearchArchive) prepare(goal string) legacyResearchSetup { + taskID, found, parseErr := autoresearch.ExplicitTaskID(goal) + if !found { + return legacyResearchSetup{} } - task, err := m.store.CreateTask(goal, autoresearch.CreateOptions{ - CreateToken: createToken, - AllowedOperations: autoresearch.AllowedOperations{ - Write: true, - Network: false, - Publish: false, - }, - SuccessCriteria: defaultAutoResearchSuccessCriteria(), - }) - if err != nil { - slog.Warn("controller: create autoresearch task", "err", err) - return autoResearchSetup{} - } - return autoResearchSetup{ - taskID: task.ID, - createToken: task.CreateToken, - notice: "autoresearch task created: " + task.ID, - created: true, + if parseErr != nil { + return legacyResearchSetup{explicit: true, blockReason: parseErr.Error()} } -} - -func (m autoResearchManager) removeTask(taskID, createToken string) error { if m.store == nil { - return nil - } - return m.store.RemoveTask(taskID, createToken) -} - -func (m autoResearchManager) heartbeat(taskID, status, message string) { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return - } - iteration := 0 - if summary, err := m.store.Summary(taskID); err == nil { - iteration = summary.Iteration - } - if err := m.store.AppendHeartbeat(taskID, autoresearch.Heartbeat{ - Status: status, - Iteration: iteration, - Message: message, - CreatedAt: time.Now().UTC(), - }); err != nil { - slog.Warn("controller: append autoresearch heartbeat", "task_id", taskID, "status", status, "err", err) - } -} - -func (m autoResearchManager) acceptedEvidenceIDs(taskID string) map[string]bool { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return nil - } - findings, err := m.store.Findings(taskID, 0) - if err != nil { - slog.Warn("controller: read autoresearch findings", "task_id", taskID, "err", err) - return nil - } - accepted := make(map[string]bool, len(findings)) - for _, finding := range findings { - if finding.Accepted { - accepted[finding.ID] = true - } - } - return accepted -} - -// recordTurnProgress records the turn's direction: which evidence IDs became -// accepted since acceptedBefore, summarized from the assistant's final text. -func (m autoResearchManager) recordTurnProgress(taskID string, acceptedBefore map[string]bool, assistantText string) { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return - } - acceptedAfter := m.acceptedEvidenceIDs(taskID) - newAccepted := make([]string, 0) - for id := range acceptedAfter { - if acceptedBefore == nil || !acceptedBefore[id] { - newAccepted = append(newAccepted, id) - } - } - sort.Strings(newAccepted) - if _, err := m.store.RecordDirection(taskID, autoresearch.Direction{ - Summary: autoResearchDirectionSummary(assistantText), - AcceptedEvidenceIDs: newAccepted, - Now: time.Now().UTC(), - }); err != nil { - slog.Warn("controller: record autoresearch direction", "task_id", taskID, "err", err) - } -} - -func (m autoResearchManager) recordEvidenceFromAssistant(taskID, text string) { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return - } - for _, item := range parseAutoResearchEvidenceBlocks(text) { - if err := m.recordEvidence(taskID, item.CriterionID, AutoResearchEvidenceInput{ - ID: item.ID, - Kind: item.Kind, - Summary: item.Summary, - Source: item.Source, - Command: item.Command, - Paths: append([]string(nil), item.Paths...), - Accepted: item.Accepted, - }); err != nil { - slog.Warn("controller: record autoresearch evidence block", "task_id", taskID, "criterion_id", item.CriterionID, "err", err) + return legacyResearchSetup{ + explicit: true, + taskID: taskID, + blockReason: "legacy research archive is unavailable for this workspace", } } -} - -func (m autoResearchManager) recordEvidence(taskID, criterionID string, input AutoResearchEvidenceInput) error { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return errors.New("autoresearch: no active task") - } - id := strings.TrimSpace(input.ID) - if id == "" { - id = m.nextFindingID(taskID) - } - kind := strings.TrimSpace(input.Kind) - if kind == "" { - kind = autoresearch.FindingKindManual - } - source := strings.TrimSpace(input.Source) - if source == "" { - source = autoresearch.FindingSourceManual - } - finding := autoresearch.Finding{ - ID: id, - Kind: kind, - Summary: strings.TrimSpace(input.Summary), - Source: source, - Command: strings.TrimSpace(input.Command), - Paths: append([]string(nil), input.Paths...), - Accepted: input.Accepted, - CreatedAt: time.Now().UTC(), - } - return m.store.RecordEvidence(taskID, criterionID, finding) -} - -func (m autoResearchManager) nextFindingID(taskID string) string { - findings, err := m.store.Findings(taskID, 0) + original, err := m.loadGoalText(taskID) if err != nil { - return fmt.Sprintf("f%d", time.Now().UTC().UnixNano()) - } - used := make(map[string]bool, len(findings)) - for _, finding := range findings { - used[finding.ID] = true + slog.Warn("controller: resume legacy autoresearch task", "err", err) + return legacyResearchSetup{explicit: true, taskID: taskID, blockReason: err.Error()} } - for i := 1; ; i++ { - id := fmt.Sprintf("f%d", len(findings)+i) - if !used[id] { - return id - } + return legacyResearchSetup{ + goal: original, + taskID: taskID, + notice: "legacy research archive loaded: " + taskID, + explicit: true, } } -func (m autoResearchManager) readinessFailure(taskID string) string { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return "" +// loadGoalText returns the original objective stored in a historical archive. +func (m legacyResearchArchive) loadGoalText(taskID string) (string, error) { + if m.store == nil { + return "", errLegacyArchiveUnavailable } - report, err := m.store.Readiness(taskID) + task, err := m.store.LoadTask(taskID) if err != nil { - return "AutoResearch readiness check failed: " + err.Error() - } - if report.Ready { - return "" - } - var parts []string - if len(report.MissingCriteria) > 0 { - parts = append(parts, "missing criteria: "+strings.Join(report.MissingCriteria, ", ")) - } - if report.BlockedReason != "" { - parts = append(parts, "blocked: "+report.BlockedReason) - } - if len(report.Errors) > 0 { - parts = append(parts, "state errors: "+strings.Join(report.Errors, "; ")) + return "", err } - if len(parts) == 0 { - parts = append(parts, "task is not ready") + goal := strings.TrimSpace(task.Spec.Goal) + if goal == "" { + return "", errLegacyArchiveMissingGoal } - return "AutoResearch readiness check failed: " + strings.Join(parts, "; ") + return goal, nil } -func (m autoResearchManager) summary(taskID string) (*autoresearch.Summary, error) { - if m.store == nil { - return nil, errors.New("autoresearch: disabled") - } - return m.store.Summary(taskID) -} - -func (m autoResearchManager) listSummaries() ([]autoresearch.Summary, error) { - if m.store == nil { - return nil, errors.New("autoresearch: disabled") - } - return m.store.ListSummaries() -} - -func (m autoResearchManager) findings(taskID string, limit int) ([]autoresearch.Finding, error) { - if m.store == nil { - return nil, errors.New("autoresearch: disabled") - } - return m.store.Findings(taskID, limit) -} +var ( + errLegacyArchiveUnavailable = errString("legacy research archive is unavailable for this workspace") + errLegacyArchiveMissingGoal = errString("legacy research archive is missing goal text") +) -func (m autoResearchManager) updateProgress(taskID string, patch autoresearch.ProgressPatch) error { - if m.store == nil { - return nil - } - _, err := m.store.UpdateProgress(taskID, patch) - return err -} +type errString string -func defaultAutoResearchSuccessCriteria() []autoresearch.SuccessCriterion { - return []autoresearch.SuccessCriterion{ - { - ID: "objective_evidence", - Description: "The goal outcome is supported by direct evidence, such as inspected code, reproduced behavior, source material, or concrete findings.", - Required: true, - }, - { - ID: "verification", - Description: "The result has relevant verification evidence, such as tests, commands, benchmarks, manual checks, or a documented reason why verification is not applicable.", - Required: true, - }, - } -} +func (e errString) Error() string { return string(e) } -type autoResearchEvidenceBlock struct { - CriterionID string `json:"criterion_id"` - ID string `json:"id"` - Kind string `json:"kind"` - Summary string `json:"summary"` - Source string `json:"source"` - Command string `json:"command"` - Paths []string `json:"paths"` - Accepted bool `json:"accepted"` +func (c *Controller) prepareLegacyResearchTask(goal string) legacyResearchSetup { + return c.legacyResearchArchive.prepare(goal) } -const ( - autoResearchEvidenceOpen = "" - autoResearchEvidenceClose = "" -) - -func parseAutoResearchEvidenceBlocks(text string) []autoResearchEvidenceBlock { - var out []autoResearchEvidenceBlock - rest := text - for { - start := strings.Index(rest, autoResearchEvidenceOpen) - if start < 0 { - return out - } - rest = rest[start+len(autoResearchEvidenceOpen):] - end := strings.Index(rest, autoResearchEvidenceClose) - if end < 0 { - return out - } - raw := strings.TrimSpace(rest[:end]) - rest = rest[end+len(autoResearchEvidenceClose):] - if raw == "" { - continue - } - var many []autoResearchEvidenceBlock - if err := json.Unmarshal([]byte(raw), &many); err == nil { - out = append(out, many...) - continue +func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool { + if legacy.taskID == "" { + goal, epoch, ok := c.goals.legacyArchiveBlockedState() + if ok { + setup := c.prepareLegacyResearchTask(goal) + if setup.explicit { + legacy = legacyGoalRestore{taskID: setup.taskID, epoch: epoch, explicit: true} + } } - var one autoResearchEvidenceBlock - if err := json.Unmarshal([]byte(raw), &one); err == nil { - out = append(out, one) + } + // A malformed explicit archive path has no safe task id to load. Keep the + // Controller-owned retry token so ResumeGoal cannot fall through to the + // ordinary Goal resume path and execute the raw path text as an objective. + if legacy.explicit && legacy.taskID == "" { + c.replaceLegacyRestore(legacy) + return true + } + if legacy.taskID == "" || strings.TrimSpace(c.goals.goalText()) != "" { + c.replaceLegacyRestore(legacyGoalRestore{}) + return false + } + c.replaceLegacyRestore(legacy) + restoreTodos := c.goalTodos() + if len(legacy.todos) > 0 { + restoreTodos = append([]evidence.TodoItem(nil), legacy.todos...) + if c.executor != nil { + c.executor.ReplaceTodoState(restoreTodos) } } -} - -func autoResearchDirectionSummary(text string) string { - text = agent.StripAutoResearchEvidenceBlocks(text) - for line := range strings.SplitSeq(text, "\n") { - line = strings.TrimSpace(line) - lower := strings.ToLower(line) - if line == "" || strings.HasPrefix(lower, "[goal:") { - continue + goal, err := c.legacyResearchArchive.loadGoalText(legacy.taskID) + if err != nil { + if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, err.Error()); ok { + _, _ = c.persistGoalStateAtEpoch(epoch, restoreTodos) + c.advanceLegacyRestoreEpoch(legacy.taskID, legacy.epoch, epoch) + c.notice("legacy research archive resume failed: " + err.Error()) + } else { + c.clearLegacyRestore(legacy.taskID, legacy.epoch) } - if len(line) > 160 { - line = line[:160] + return true + } + if strings.TrimSpace(c.goals.goalText()) == "" { + if epoch, ok := c.goals.fillGoalTextIfEmpty(legacy.epoch, goal); ok { + _, persistErr := c.persistGoalStateAtEpoch(epoch, restoreTodos) + if persistErr != nil { + reason := "persist migrated legacy Goal: " + persistErr.Error() + if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(epoch, reason); blocked { + c.replaceLegacyRestore(legacyGoalRestore{taskID: legacy.taskID, todos: restoreTodos, epoch: blockedEpoch}) + c.notice("legacy research archive resume failed: " + reason) + } else { + c.clearLegacyRestore(legacy.taskID, legacy.epoch) + } + } else { + c.clearLegacyRestore(legacy.taskID, legacy.epoch) + } + } else { + c.clearLegacyRestore(legacy.taskID, legacy.epoch) } - return line - } - return "turn completed" -} - -// Controller-side glue: resolve the active task via the goal machine, then -// delegate to the leaf manager. - -func (c *Controller) prepareAutoResearchTask(goal string, researchMode GoalResearchMode, createToken string) autoResearchSetup { - goal = strings.TrimSpace(goal) - if goal == "" || !c.autoResearch.enabled() || !shouldUseAutoResearch(goal, researchMode) { - return autoResearchSetup{} - } - currentGoal, currentStatus, _, currentTaskID := c.goals.snapshot() - if strings.TrimSpace(currentGoal) == goal && currentStatus == GoalStatusRunning && strings.TrimSpace(currentTaskID) != "" { - return autoResearchSetup{taskID: currentTaskID} } - return c.autoResearch.prepare(goal, createToken) -} - -func (c *Controller) autoResearchReadinessFailure() string { - return c.autoResearch.readinessFailure(c.goals.currentAutoResearchTaskID()) + return true } -func (c *Controller) AutoResearchSummary() (*autoresearch.Summary, bool) { - taskID := c.goals.currentAutoResearchTaskID() - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return nil, false +func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) { + goal, epoch, blocked := c.goals.legacyArchiveBlockedState() + if !blocked { + return false, false } - summary, err := c.autoResearch.summary(taskID) - if err != nil { - return &autoresearch.Summary{ - TaskID: taskID, - Status: autoresearch.StatusInvalid, - Blocker: err.Error(), - }, true + legacy, hasLegacy := c.legacyRestoreSnapshot() + if !hasLegacy || legacy.epoch != epoch || legacy.taskID == "" { + // A blocked sidecar without a Controller-owned archive identity is a + // fail-closed migration boundary after restart. Never resume raw text. + return true, false } - return summary, true -} - -func (c *Controller) AutoResearchList() ([]autoresearch.Summary, bool) { - if !c.autoResearch.enabled() { - return nil, false + taskID := legacy.taskID + setup := c.prepareLegacyResearchTask(goal) + resolvedGoal, reason := setup.goal, setup.blockReason + if !setup.explicit { + var err error + resolvedGoal, err = c.legacyResearchArchive.loadGoalText(taskID) + if err != nil { + reason = err.Error() + } + } else if setup.taskID != taskID { + reason = "legacy research archive identity changed during retry" } - summaries, err := c.autoResearch.listSummaries() - if err != nil { - slog.Warn("controller: list autoresearch tasks", "err", err) - return nil, true + if reason != "" || strings.TrimSpace(resolvedGoal) == "" { + if reason == "" { + reason = "legacy research archive could not be recovered" + } + if nextEpoch, applied := c.goals.blockLegacyRestore(epoch, reason); applied { + _, _ = c.persistGoalStateAtEpoch(nextEpoch, c.goalTodos()) + c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: nextEpoch}) + } + c.notice("legacy research archive resume failed: " + reason) + return true, false + } + todos := c.goalTodos() + resumedEpoch, applied := c.goals.resumeLegacyArchive(epoch, resolvedGoal) + if !applied { + c.replaceLegacyRestore(legacyGoalRestore{}) + return true, false + } + persisted, persistErr := c.persistGoalStateAtEpoch(resumedEpoch, todos) + if persistErr != nil { + reason := "persist migrated legacy Goal: " + persistErr.Error() + if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(resumedEpoch, reason); blocked { + c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, todos: todos, epoch: blockedEpoch}) + c.notice("legacy research archive resume failed: " + reason) + } else { + c.replaceLegacyRestore(legacyGoalRestore{}) + } + return true, false } - return summaries, true -} - -func (c *Controller) AutoResearchFindings(limit int) ([]autoresearch.Finding, bool) { - taskID := c.goals.currentAutoResearchTaskID() - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return nil, false + if !persisted { + return true, false } - findings, err := c.autoResearch.findings(taskID, limit) - if err != nil { - return nil, true + c.replaceLegacyRestore(legacyGoalRestore{}) + if setup.notice != "" { + c.notice(setup.notice) } - return findings, true -} - -func (c *Controller) RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error { - taskID := c.goals.currentAutoResearchTaskID() - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return errors.New("autoresearch: no active task") + if c.executor != nil { + c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState()) } - return c.autoResearch.recordEvidence(taskID, criterionID, input) + return true, true } diff --git a/internal/control/controller.go b/internal/control/controller.go index d03e931471..93fda7dc4b 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -210,10 +210,12 @@ type Controller struct { // and its persistence, behind its own mutex so a per-turn goal save never // stalls an approval or status poll on c.mu. See goal.go. goals goalMachine - // autoResearch wraps the workspace autoresearch.Store as a strict-leaf - // collaborator; goal/task resolution stays on Controller. See + // legacyResearchArchive reads explicit pre-unification task paths. It never + // creates or mutates archive state. See // autoresearch_manager.go. - autoResearch autoResearchManager + legacyResearchArchive legacyResearchArchive + legacyRestoreMu sync.Mutex + legacyRestore legacyGoalRestore // workspaceRoot is the workspace root: the base for resolving @-refs and slash // path refs, the working directory for user "!" shell commands and custom @@ -331,16 +333,6 @@ type pendingAsk struct { reply chan []event.AskAnswer } -type AutoResearchEvidenceInput struct { - ID string - Kind string - Summary string - Source string - Command string - Paths []string - Accepted bool -} - type plannerSessionResetter interface { ResetPlannerSession() } @@ -633,7 +625,7 @@ func New(opts Options) *Controller { c.sessionTemp.Retain() if strings.TrimSpace(opts.WorkspaceRoot) != "" { - c.autoResearch = autoResearchManager{store: autoresearch.NewStore(opts.WorkspaceRoot)} + c.legacyResearchArchive = legacyResearchArchive{store: autoresearch.NewStore(opts.WorkspaceRoot)} } if opts.Extensions != nil { c.extensions = opts.Extensions @@ -1554,17 +1546,15 @@ func (c *Controller) applyGoalCommand(input, display string) bool { if !ok { return false } + if cmd.DeprecatedBudgetFlag { + c.notice(GoalBudgetFlagDeprecatedNotice) + } switch cmd.Action { case GoalCommandSet: c.SetPlanMode(false) c.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode) c.GoalStrict(cmd.Strict) - c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(cmd.Text))) - if c.runner != nil { - c.runGuarded(func(ctx context.Context) error { - return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display) - }) - } + c.startGoalCommandTurn(cmd, display) case GoalCommandClear: c.ClearGoal() c.notice(i18n.M.GoalCleared) @@ -2684,22 +2674,29 @@ func (c *Controller) SetGoal(goal string) { } // SetGoalDurable updates the Goal only when its sidecar can be replaced -// atomically. Remote Profile transactions persist autoResearchCreateToken -// before calling this method so crash recovery owns any newly-created task. -func (c *Controller) SetGoalDurable(goal, autoResearchCreateToken string) error { +// atomically. +func (c *Controller) SetGoalDurable(goal string) error { snapshot := c.goals.capture() - setup := c.prepareAutoResearchTask(goal, GoalResearchAuto, autoResearchCreateToken) - path, data, persist := c.goals.set(goal, GoalResearchAuto, setup.taskID, c.goalTodos()) + legacySnapshot, hadLegacySnapshot := c.legacyRestoreSnapshot() + resolved, setup := c.resolveGoalText(goal, GoalResearchAuto) + var path string + var data []byte + var persist bool if setup.blockReason != "" { - path, data, persist = c.goals.stop(GoalStatusBlocked, c.goalTodos()) + path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos()) + c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit}) + } else { + path, data, persist = c.goals.set(resolved, setup.budgetClass, c.goalTodos()) + c.replaceLegacyRestore(legacyGoalRestore{}) } if persist { if err := c.goals.writeStateErr(path, data); err != nil { c.goals.restore(snapshot) - if setup.created && c.autoResearch.enabled() { - if removeErr := c.autoResearch.removeTask(setup.taskID, setup.createToken); removeErr != nil { - slog.Warn("controller: rollback autoresearch task", "task_id", setup.taskID, "err", removeErr) - } + if hadLegacySnapshot { + legacySnapshot.epoch = c.goals.continuationToken() + c.replaceLegacyRestore(legacySnapshot) + } else { + c.replaceLegacyRestore(legacyGoalRestore{}) } return err } @@ -2708,29 +2705,60 @@ func (c *Controller) SetGoalDurable(goal, autoResearchCreateToken string) error c.notice(setup.notice) } if setup.blockReason != "" { - c.notice("autoresearch resume failed: " + setup.blockReason) + c.notice("legacy research archive resume failed: " + setup.blockReason) } return nil } func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) { - setup := c.prepareAutoResearchTask(goal, researchMode, "") + resolved, setup := c.resolveGoalText(goal, researchMode) if setup.notice != "" { c.notice(setup.notice) } - path, data, ok := c.goals.set(goal, researchMode, setup.taskID, c.goalTodos()) - c.persistGoalState(path, data, ok) + var path string + var data []byte + var ok bool if setup.blockReason != "" { - path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos()) - c.persistGoalState(path, data, ok) - c.notice("autoresearch resume failed: " + setup.blockReason) + path, data, ok = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos()) + c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit}) + c.notice("legacy research archive resume failed: " + setup.blockReason) + } else { + path, data, ok = c.goals.set(resolved, setup.budgetClass, c.goalTodos()) + c.replaceLegacyRestore(legacyGoalRestore{}) + } + c.persistGoalState(path, data, ok) +} + +// goalSetSetup is the resolved objective and budget class after archive lookup. +type goalSetSetup struct { + budgetClass string + notice string + blockReason string + legacyTaskID string + explicit bool +} + +func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) { + setup := goalSetSetup{budgetClass: budgetClassForLegacyMode(goal, researchMode)} + legacy := c.prepareLegacyResearchTask(goal) + if !legacy.explicit { + return goal, setup } + setup.notice, setup.blockReason, setup.legacyTaskID, setup.explicit = legacy.notice, legacy.blockReason, legacy.taskID, legacy.explicit + if legacy.blockReason != "" { + return goal, setup + } + setup.budgetClass = budgetClassResearch + return legacy.goal, setup } // ResumeGoal re-enters a recoverable blocked/stopped Goal without resetting its -// delivery evidence scope or AutoResearch identity. A budget-paused Goal gets -// one extra slice of its budget class; accumulated consumption is preserved. +// delivery evidence scope. A budget-paused Goal gets one extra slice of its +// budget class; accumulated consumption is preserved. func (c *Controller) ResumeGoal() bool { + if handled, resumed := c.retryBlockedLegacyGoal(); handled { + return resumed + } path, data, persist, resumed, extended := c.goals.resume(c.goalTodos()) if !resumed { return false @@ -2764,11 +2792,11 @@ func (c *Controller) GoalRuntime() GoalRuntimeView { } // goalEvaluatorEvidence assembles the bounded evaluator's evidence: the goal -// contract, the current assistant final, a todo/readiness summary, the -// AutoResearch success-criteria summary, turn/budget state, and the last +// contract, the current assistant final, a todo/readiness summary, +// turn/budget state, and the last // continuation reason. Every field is treated as untrusted by the evaluator. func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence { - goal, _, mode, taskID := c.goals.snapshot() + goal, _ := c.goals.snapshot() ev := goaleval.GoalEvidence{ GoalContract: goal, LastContinuationReason: c.goals.lastContinuationReasonText(), @@ -2789,27 +2817,10 @@ func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence { } ev.TodoSummary = fmt.Sprintf("todos: %d total, %d incomplete; delivery readiness: %s", len(todos), incomplete, readinessText) } - if c.autoResearch.enabled() && strings.TrimSpace(taskID) != "" { - if summary, err := c.autoResearch.summary(taskID); err == nil { - ev.AutoResearchSummary = fmt.Sprintf("task %s: iteration %d, %d open success criteria, next required action: %s", - summary.TaskID, summary.Iteration, len(summary.OpenCriteria), summary.NextRequiredAction) - } - } - ev.TurnStatus = c.goals.budgetStatusText() + "; research mode: " + goalResearchModeText(mode) + ev.TurnStatus = c.goals.budgetStatusText() return ev } -func goalResearchModeText(mode GoalResearchMode) string { - switch mode { - case GoalResearchOn: - return "on" - case GoalResearchOff: - return "off" - default: - return "auto" - } -} - func (c *Controller) persistGoalDeliveryCheckpoint() { if c.executor == nil { return @@ -3486,10 +3497,8 @@ func (c *Controller) Resume(s *agent.Session, path string) { c.ResetPlannerSession() c.setActiveJobSession(path) c.rebindCheckpoints(path) - if migPath, migData, migrated := c.goals.restoreFromState(path); migrated { - // Persist legacy budget_tokens → running (and tokensLimit=0) so the - // next cold start does not re-enter the removed hard-limit pause. - // restoreFromState never issues a provider request. + migPath, migData, migrated, legacy := c.goals.restoreFromState(path) + if !c.restorePendingLegacyGoal(legacy) && migrated { c.persistGoalState(migPath, migData, true) } if c.executor != nil { diff --git a/internal/control/controller_test.go b/internal/control/controller_test.go index 0b74cb5757..cebd191d89 100644 --- a/internal/control/controller_test.go +++ b/internal/control/controller_test.go @@ -539,7 +539,7 @@ func TestGoalStatePersistsNextToSessionPath(t *testing.T) { if err := json.Unmarshal(data, &state); err != nil { t.Fatal(err) } - if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.ResearchMode != GoalResearchOn || !state.Strict { + if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.BudgetClass != budgetClassResearch || state.ResearchMode != GoalResearchOff || !state.Strict { t.Fatalf("goal state = %+v, want running strict research goal", state) } } @@ -559,7 +559,7 @@ func TestSetGoalDurableRestoresInMemoryStateWhenSidecarWriteFails(t *testing.T) } c.goals.setStatePath(filepath.Join(notDirectory, "goal.json")) - if err := c.SetGoalDurable("replace the goal", ""); err == nil { + if err := c.SetGoalDurable("replace the goal"); err == nil { t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent") } if got := c.Goal(); got != "keep the old goal" { @@ -570,7 +570,7 @@ func TestSetGoalDurableRestoresInMemoryStateWhenSidecarWriteFails(t *testing.T) } } -func TestSetGoalDurableRollsBackAutoResearchTaskAndNotice(t *testing.T) { +func TestSetGoalDurableNeverCreatesLegacyArchive(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "session.jsonl") sink := ¬iceSink{} @@ -592,18 +592,14 @@ func TestSetGoalDurableRollsBackAutoResearchTaskAndNotice(t *testing.T) { c.goals.setStatePath(filepath.Join(notDirectory, "goal.json")) goal := "investigate the root cause and fix the performance regression, then verify with tests" - if err := c.SetGoalDurable(goal, ""); err == nil { + if err := c.SetGoalDurable(goal); err == nil { t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent") } - entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch")) - if err != nil && !os.IsNotExist(err) { - t.Fatalf("read autoresearch dir: %v", err) - } - if len(entries) != 0 { - t.Fatalf("autoresearch task count after rollback = %d, want 0", len(entries)) + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("durable Goal update created legacy archive: %v", err) } for _, notice := range sink.notices() { - if strings.Contains(notice, "autoresearch task created") || strings.Contains(notice, "autoresearch task resumed") { + if strings.Contains(strings.ToLower(notice), "autoresearch task created") || strings.Contains(strings.ToLower(notice), "legacy research archive loaded") { t.Fatalf("durable failure emitted success notice %q", notice) } } @@ -686,7 +682,7 @@ func TestResumeRestoresRunningAutoResearchGoalFromSidecar(t *testing.T) { if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "task_spec.json"), []byte(`{"id":"investigate-runtime-resume","goal":"investigate runtime resume","status":"running","created_at":"2026-06-30T00:00:00Z","updated_at":"2026-06-30T00:00:00Z","success_criteria":[{"id":"criterion-1","description":"resume keeps AutoResearch active","required":true}]}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "task_spec.json"), []byte(`{"task_id":"investigate-runtime-resume","goal":"investigate runtime resume","allowed_operations":{"write":true},"success_criteria":[{"id":"criterion-1","description":"resume keeps Goal active","required":true}]}`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "progress.json"), []byte(`{"task_id":"investigate-runtime-resume","iteration":2,"current_direction":"verify resume","stale_count":1,"pivot_count":0,"updated_at":"2026-06-30T00:00:00Z"}`), 0o644); err != nil { @@ -714,8 +710,11 @@ func TestResumeRestoresRunningAutoResearchGoalFromSidecar(t *testing.T) { t.Fatalf("Goal() after resume = %q, want running goal from sidecar", got) } composed := c.Compose("continue") - if !strings.Contains(composed, "") || !strings.Contains(composed, "task_id: "+taskID) { - t.Fatalf("Compose after resume missing AutoResearch runtime for %q:\n%s", taskID, composed) + if strings.Contains(strings.ToLower(composed), "autoresearch") { + t.Fatalf("Compose after resume exposed removed AutoResearch protocol:\n%s", composed) + } + if got := c.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("resumed legacy Goal budget = %d, want 40", got) } } diff --git a/internal/control/goal.go b/internal/control/goal.go index f73a580d76..c2b3853e59 100644 --- a/internal/control/goal.go +++ b/internal/control/goal.go @@ -6,14 +6,12 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "strings" "sync" "time" "reasonix/internal/agent" "reasonix/internal/evidence" - "reasonix/internal/fileutil" fileencoding "reasonix/internal/fileutil/encoding" "reasonix/internal/goaleval" "reasonix/internal/store" @@ -30,70 +28,56 @@ const ( defaultNoProgressLimit = 4 ) -// Budget classes select turn quotas only. They never gate permissions, writes, -// or provider request admission. Token usage is still accumulated for display. +// Budget class aliases; classification and quotas live in taskintent. const ( - budgetClassSimple = "simple" - budgetClassWrite = "write" - budgetClassResearch = "research" + budgetClassSimple = taskintent.BudgetClassSimple + budgetClassWrite = taskintent.BudgetClassWrite + budgetClassResearch = taskintent.BudgetClassResearch ) -// Stop causes distinguish a safe pause (blocked + stopCause) from a genuine -// task block (blocked with empty stopCause). Old clients see blocked either -// way and never fail open. -// -// stopCauseBudgetTokens is retained only so old sidecars that paused on the -// removed token hard-limit can be recognized and auto-resumed on load. +// Stop causes distinguish a safe pause from a genuine block. Old clients see +// blocked either way. stopCauseBudgetTokens is only for recognizing and +// auto-resuming old token-limit pauses. const ( - stopCauseBudgetTurns = "budget_turns" - stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime - stopCauseNoProgress = "no_progress" - stopCauseEvaluator = "evaluator_unavailable" - stopCauseManual = "manual" + stopCauseBudgetTurns = "budget_turns" + stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime + stopCauseNoProgress = "no_progress" + stopCauseEvaluator = "evaluator_unavailable" + stopCauseLegacyArchive = "legacy_archive" + stopCauseManual = "manual" ) // budgetQuota returns the default turn quota for a budget class. Token hard // limits were removed; callers no longer receive a token ceiling. func budgetQuota(class string) (turns int) { - switch class { - case budgetClassResearch: - return 40 - case budgetClassWrite: - return 20 - default: - return 10 - } + return taskintent.BudgetTurns(class) } -// budgetClassFor derives a goal's budget class: AutoResearch always means -// research; otherwise Goal-specific write classification decides whether the -// objective is a write turn budget (including bare fault statements) or simple. -// Ordinary Delivery consultation/diagnosis classification is unchanged. -func budgetClassFor(goal string, researchMode GoalResearchMode) string { - if shouldUseAutoResearch(goal, researchMode) { +// budgetClassForLegacyMode translates old sidecars and deprecated CLI flags at +// the compatibility boundary. The active Goal runtime stores only budgetClass. +func budgetClassForLegacyMode(goal string, researchMode GoalResearchMode) string { + switch researchMode { + case GoalResearchOn: return budgetClassResearch + case GoalResearchOff: + if taskintent.GoalNeedsWriteBudget(goal) { + return budgetClassWrite + } + return budgetClassSimple + default: + return taskintent.ClassifyGoalBudget(goal) } - if taskintent.GoalNeedsWriteBudget(goal) { - return budgetClassWrite - } - return budgetClassSimple } -// goalMachine owns the active goal's finite-state machine and its persistence. -// It is a strict leaf: its methods take only the machine's own locks and never -// call back into the Controller, so the controller may hold c.mu while invoking -// a getter without risking lock inversion. The FSM is pure — advance() takes -// already-gathered inputs (the update_goal report, readiness, evaluator verdict, -// budget/progress state) and returns what to persist plus a notice, so no disk -// or executor work happens under mu. +// goalMachine owns the active goal FSM and its persistence. It is a strict +// leaf: methods take only machine locks and never call back into Controller. +// advance() takes already-gathered inputs so no disk/executor work holds mu. type goalMachine struct { // mu guards the FSM fields below; every critical section under it is short // and non-blocking (no disk I/O, no executor calls). mu sync.Mutex goal string status string - researchMode GoalResearchMode - autoResearchTaskID string scopeID string deliveryCheckpoint evidence.DeliveryCheckpoint block string @@ -151,19 +135,6 @@ type goalState struct { BudgetExtensions int `json:"budgetExtensions,omitempty"` } -// goalMachineSnapshot is an in-memory rollback point for durable Goal updates. -// Persistence paths and mutexes are deliberately excluded. -type goalMachineSnapshot struct { - goal string - status string - researchMode GoalResearchMode - autoResearchTaskID string - scopeID string - deliveryCheckpoint evidence.DeliveryCheckpoint - block string - strict bool -} - // goalAdvanceInput carries everything the FSM needs for one continuation step, // gathered by the caller off the machine's lock. The FSM is the exclusive // decision point: it applies readiness, budget, and no-progress gates and @@ -204,10 +175,8 @@ type goalAdvanceResult struct { // state admitted for its synthetic turn. The orchestrator uses these captured // fields throughout the turn instead of re-reading a possibly replaced Goal. type goalContinuationSnapshot struct { - goal string - researchMode GoalResearchMode - autoResearchTaskID string - scopeID string + goal string + scopeID string } // goalStatePath derives a session's persisted goal-state sidecar. @@ -221,32 +190,11 @@ func (g *goalMachine) setStatePath(path string) { g.mu.Unlock() } -func (g *goalMachine) capture() goalMachineSnapshot { - g.mu.Lock() - defer g.mu.Unlock() - return goalMachineSnapshot{ - goal: g.goal, status: g.status, researchMode: g.researchMode, - autoResearchTaskID: g.autoResearchTaskID, scopeID: g.scopeID, - deliveryCheckpoint: g.deliveryCheckpoint, block: g.block, - strict: g.strict, - } -} - -func (g *goalMachine) restore(snapshot goalMachineSnapshot) { - g.mu.Lock() - g.goal, g.status, g.researchMode = snapshot.goal, snapshot.status, snapshot.researchMode - g.autoResearchTaskID, g.scopeID = snapshot.autoResearchTaskID, snapshot.scopeID - g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block - g.strict = snapshot.strict - g.continuationEpoch++ - g.mu.Unlock() -} - // snapshot returns the fields Compose injects into outgoing turns. -func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode, autoResearchTaskID string) { +func (g *goalMachine) snapshot() (goal, status string) { g.mu.Lock() defer g.mu.Unlock() - return g.goal, g.status, g.researchMode, g.autoResearchTaskID + return g.goal, g.status } func (g *goalMachine) goalText() string { @@ -255,15 +203,6 @@ func (g *goalMachine) goalText() string { return g.goal } -func (g *goalMachine) currentAutoResearchTaskID() string { - g.mu.Lock() - defer g.mu.Unlock() - if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning { - return "" - } - return g.autoResearchTaskID -} - // continuationToken captures the Goal lifecycle that owns an outgoing turn. // The matching assistant output may advance the FSM only while this epoch is // still current. @@ -333,13 +272,40 @@ func (g *goalMachine) budgetExhausted() bool { // the per-goal budget/runtime counters, and returns the state to persist. ok is // false (no persistence) when the goal is unchanged or no state path is // configured. -func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID string, todos []evidence.TodoItem) (string, []byte, bool) { +func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.TodoItem) (string, []byte, bool) { goal = strings.TrimSpace(goal) + if goal != "" && preferredBudgetClass == "" { + preferredBudgetClass = taskintent.ClassifyGoalBudget(goal) + } g.mu.Lock() defer g.mu.Unlock() - if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode && g.autoResearchTaskID == autoResearchTaskID { + if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass { return "", nil, false } + g.installGoalLocked(goal, preferredBudgetClass) + return g.buildStateLocked(todos) +} + +// setLegacyArchiveBlocked atomically installs and blocks an explicit legacy +// archive goal. A concurrent Goal replacement cannot be blocked between two +// separate FSM mutations. +func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason string, todos []evidence.TodoItem) (string, []byte, bool) { + goal = strings.TrimSpace(goal) + if goal != "" && preferredBudgetClass == "" { + preferredBudgetClass = taskintent.ClassifyGoalBudget(goal) + } + g.mu.Lock() + defer g.mu.Unlock() + g.installGoalLocked(goal, preferredBudgetClass) + if goal != "" { + g.status = GoalStatusBlocked + } + g.stopCause = stopCauseLegacyArchive + g.block = clipGoalReason(reason) + return g.buildStateLocked(todos) +} + +func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) { g.continuationEpoch++ g.turnsUsed, g.tokensUsed, g.noProgressTurns = 0, 0, 0 g.block = "" @@ -347,19 +313,19 @@ func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID g.stopCause = "" g.budgetExtensions = 0 if goal == "" { - g.goal, g.status, g.researchMode, g.autoResearchTaskID = "", GoalStatusStopped, GoalResearchAuto, "" + g.goal, g.status = "", GoalStatusStopped + g.budgetClass = "" g.scopeID = "" g.deliveryCheckpoint = evidence.DeliveryCheckpoint{} } else { - g.goal, g.status, g.researchMode, g.autoResearchTaskID = goal, GoalStatusRunning, mode, autoResearchTaskID + g.goal, g.status = goal, GoalStatusRunning g.scopeID = newGoalScopeID() g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID} - g.budgetClass = budgetClassFor(goal, mode) + g.budgetClass = preferredBudgetClass g.turnsLimit = budgetQuota(g.budgetClass) g.tokensLimit = 0 // no token hard limit g.noProgressLimit = defaultNoProgressLimit } - return g.buildStateLocked(todos) } func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string, []byte, bool) { @@ -400,15 +366,17 @@ func (g *goalMachine) pauseFor(stopCause, reason string, todos []evidence.TodoIt return g.buildStateLocked(todos) } -// resume re-enters a recoverable blocked/stopped goal without resetting its -// delivery evidence scope, AutoResearch identity, or runtime history. Turn -// budget pauses (and any pause whose original turn quota is already spent) -// append one turn slice of the current budget class; no-progress counting -// resets but accumulated token usage and budget_extensions are preserved. -// Token hard limits no longer exist, so resume never extends a token ceiling. +// resume re-enters a recoverable blocked/stopped goal without resetting scope +// or runtime history. Budget pauses append one turn slice of the current class; +// token hard limits no longer exist. func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byte, persist, resumed, extended bool) { g.mu.Lock() defer g.mu.Unlock() + if g.stopCause == stopCauseLegacyArchive { + // A legacy archive block is recoverable only through the read-only + // archive boundary; never reinterpret it as an ordinary Goal resume. + return "", nil, false, false, false + } if strings.TrimSpace(g.goal) == "" || g.status == GoalStatusComplete { return "", nil, false, false, false } @@ -429,7 +397,7 @@ func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byt } if extend { if g.budgetClass == "" { - g.budgetClass = budgetClassFor(g.goal, g.researchMode) + g.budgetClass = taskintent.ClassifyGoalBudget(g.goal) } g.turnsLimit += budgetQuota(g.budgetClass) g.budgetExtensions++ @@ -486,10 +454,8 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation g.scopeID = newGoalScopeID() } return goalContinuationSnapshot{ - goal: g.goal, - researchMode: g.researchMode, - autoResearchTaskID: g.autoResearchTaskID, - scopeID: g.scopeID, + goal: g.goal, + scopeID: g.scopeID, }, true } @@ -501,12 +467,11 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation // 1. complete + readiness ready (report or evaluator) → complete // 2. blocked (report or evaluator) → blocked immediately (no triple confirm) // 3. evaluator failed/uncertain → safe pause (fail closed, never default to continue) -// 4. evaluator failed/uncertain → safe pause (fail closed, never default to continue) -// 5. budget exhausted → safe pause (also vetoes complete claims rejected by +// 4. budget exhausted → safe pause (also vetoes complete claims rejected by // readiness: those would continue, and continuation past the budget is a // pause) -// 6. no-progress limit reached → safe pause -// 7. otherwise continue, carrying the missing requirements (complete rejected +// 5. no-progress limit reached → safe pause +// 6. otherwise continue, carrying the missing requirements (complete rejected // by readiness, or no report with an explicit missing list) or the report's // next_action as the next turn's prompt. func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult { @@ -662,8 +627,6 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string, state := goalState{ Goal: g.goal, Status: g.status, - ResearchMode: g.researchMode, - AutoResearchTaskID: g.autoResearchTaskID, ScopeID: g.scopeID, DeliveryCheckpoint: g.deliveryCheckpoint, Turns: g.turnsUsed, @@ -682,6 +645,11 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string, StopCause: g.stopCause, BudgetExtensions: g.budgetExtensions, } + // GoalResearchOff is a downgrade fence: old readers must not infer or inject + // the removed AutoResearch runtime. Legacy task identity is decode-only and + // remains in the Controller-owned recovery boundary; it is never written into + // a new sidecar. + state.ResearchMode = GoalResearchOff b, err := json.Marshal(state) if err != nil { slog.Warn("controller: marshal goal state", "err", err) @@ -690,21 +658,6 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string, return g.statePath, b, true } -// writeStateErr persists pre-marshaled goal-state bytes to disk, OFF mu and -// serialized by writeMu so concurrent saves don't interleave or land out of -// order. Atomic replacement keeps the prior state intact when a write fails. -func (g *goalMachine) writeStateErr(path string, data []byte) error { - if path == "" || data == nil { - return nil - } - g.writeMu.Lock() - defer g.writeMu.Unlock() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - return fileutil.AtomicWriteFile(path, data, 0o644) -} - // writeState preserves the existing best-effort behavior for background Goal // progress. Callers that need transactional persistence use writeStateErr. func (g *goalMachine) writeState(path string, data []byte) { @@ -757,19 +710,13 @@ func (g *goalMachine) terminalTodosFromState(sessionPath string) ([]evidence.Tod return append([]evidence.TodoItem(nil), state.Todos...), true } -// restoreFromState reloads Goal state from the persisted sidecar during resume. -// The sidecar is authoritative when present: a stale tab profile must not turn -// a blocked or stopped Goal back into a running one during a controller rebuild. -// Recoverable terminal states retain their scope for an explicit ResumeGoal. -// Old sidecars missing the budget fields get their defaults re-derived: the -// existing Turns count carries into the new turn budget, tokens start from 0, -// and the budget class is recomputed from the goal text. -// -// When a legacy budget_tokens pause is cleared, migrated is true and path/data -// carry the rewritten state for immediate atomic persistence (no provider call). -func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool) { +// restoreFromState reloads Goal state from the sidecar. The sidecar is +// authoritative; missing budget fields are re-derived. migrated means path/data +// need an immediate rewrite (no provider call). legacyTaskID is returned only +// so Controller can fill missing goal text from a historical archive. +func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool, legacy legacyGoalRestore) { if strings.TrimSpace(sessionPath) == "" { - return "", nil, false + return "", nil, false, legacyGoalRestore{} } // Ensure write path is bound even when the controller rebuilds. if g.statePath == "" { @@ -780,12 +727,12 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data [] if !os.IsNotExist(err) { slog.Warn("controller: read goal state", "err", err) } - return "", nil, false + return "", nil, false, legacyGoalRestore{} } var state goalState if err := json.Unmarshal(raw, &state); err != nil { slog.Warn("controller: parse goal state", "err", err) - return "", nil, false + return "", nil, false, legacyGoalRestore{} } g.mu.Lock() defer g.mu.Unlock() @@ -794,9 +741,21 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data [] if g.status == "" { g.status = GoalStatusStopped } - g.researchMode = state.ResearchMode - g.autoResearchTaskID = strings.TrimSpace(state.AutoResearchTaskID) + // Legacy task identity is decode-only compatibility data. It is returned to + // the Controller's migration boundary and never enters active Goal memory. + legacy = legacyGoalRestore{ + taskID: strings.TrimSpace(state.AutoResearchTaskID), + todos: append([]evidence.TodoItem(nil), state.Todos...), + } + if legacy.taskID != "" && g.goal != "" { + // Sidecars that already carry the Goal objective do not depend on the + // historical archive. Complete the migration immediately. + migrated = true + } g.scopeID = strings.TrimSpace(state.ScopeID) + if g.scopeID == "" { + g.scopeID = strings.TrimSpace(state.DeliveryCheckpoint.ScopeID) + } if g.goal != "" && g.scopeID == "" { g.scopeID = newGoalScopeID() } @@ -821,26 +780,29 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data [] g.turnsUsed = state.Turns } g.tokensUsed = state.TokensUsed + g.budgetClass = normalizeBudgetClass(g.goal, state.BudgetClass, state.ResearchMode) + g.turnsLimit = state.TurnsLimit + g.noProgressTurns = state.NoProgressTurns + g.noProgressLimit = state.NoProgressLimit // Token hard limits are gone: keep the field at 0. Old non-zero sidecar // values are read and ignored so downgrade/upgrade never loses other state. g.tokensLimit = 0 - migrated = false + if goalStateNeedsMigration(state, g.budgetClass) { + migrated = true + } if g.goal != "" { - g.budgetClass = state.BudgetClass if g.budgetClass == "" { - g.budgetClass = budgetClassFor(g.goal, g.researchMode) + g.budgetClass = budgetClassForLegacyMode(g.goal, state.ResearchMode) } - if state.TurnsLimit > 0 { - g.turnsLimit = state.TurnsLimit - } else { + if legacy.taskID != "" { + g.budgetClass = budgetClassResearch + } + if g.turnsLimit == 0 { g.turnsLimit = budgetQuota(g.budgetClass) } - if state.NoProgressLimit > 0 { - g.noProgressLimit = state.NoProgressLimit - } else { + if g.noProgressLimit == 0 { g.noProgressLimit = defaultNoProgressLimit } - g.noProgressTurns = state.NoProgressTurns // Auto-clear legacy token-budget pauses so the next user turn can // continue without a manual resume. Loading itself never calls a // provider. @@ -854,20 +816,19 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data [] } // Also rewrite sidecars that still store a non-zero tokensLimit so the // next load does not re-surface the deprecated hard ceiling in status. - if state.TokensLimit != 0 { - migrated = true - } } g.continuationEpoch++ - if migrated { + legacy.epoch = g.continuationEpoch + pendingLegacyGoal := legacy.taskID != "" && g.goal == "" + if migrated && !pendingLegacyGoal { // Migration rewrites only the removed budget state. Preserve the todo // snapshot carried by the authoritative sidecar instead of clearing it. path, data, ok := g.buildStateLocked(state.Todos) if ok { - return path, data, true + return path, data, true, legacy } } - return "", nil, false + return "", nil, false, legacy } // formatIncompleteTodos renders the reminder shown when a complete claim @@ -946,6 +907,14 @@ func (c *Controller) persistGoalState(path string, data []byte, ok bool) { c.goals.writeState(path, data) } +func (c *Controller) persistGoalStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) { + applied, err := c.goals.writeStateAtEpoch(epoch, todos) + if err != nil { + slog.Warn("controller: write goal state", "err", err) + } + return applied, err +} + func (c *Controller) restoreTerminalGoalTodos(sessionPath string) { if c.executor == nil { return diff --git a/internal/control/goal_command.go b/internal/control/goal_command.go new file mode 100644 index 0000000000..9fb93e2854 --- /dev/null +++ b/internal/control/goal_command.go @@ -0,0 +1,20 @@ +package control + +import ( + "context" + "fmt" + + "reasonix/internal/i18n" +) + +func (c *Controller) startGoalCommandTurn(cmd GoalCommand, display string) { + if !c.goals.active() { + return + } + c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(c.Goal()))) + if c.runner != nil { + c.runGuarded(func(ctx context.Context) error { + return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display) + }) + } +} diff --git a/internal/control/goal_durable.go b/internal/control/goal_durable.go new file mode 100644 index 0000000000..48fac7a62f --- /dev/null +++ b/internal/control/goal_durable.go @@ -0,0 +1,98 @@ +package control + +import ( + "os" + "path/filepath" + + "reasonix/internal/evidence" + "reasonix/internal/fileutil" +) + +// goalMachineSnapshot is an in-memory rollback point for durable Goal updates. +// Persistence paths and mutexes are deliberately excluded. +type goalMachineSnapshot struct { + goal string + status string + scopeID string + deliveryCheckpoint evidence.DeliveryCheckpoint + block string + strict bool + budgetClass string + turnsUsed int + turnsLimit int + tokensUsed int + tokensLimit int + noProgressTurns int + noProgressLimit int + lastContinuationReason string + lastEvaluatorReason string + stopCause string + budgetExtensions int +} + +func (g *goalMachine) capture() goalMachineSnapshot { + g.mu.Lock() + defer g.mu.Unlock() + return goalMachineSnapshot{ + goal: g.goal, status: g.status, + scopeID: g.scopeID, deliveryCheckpoint: g.deliveryCheckpoint, + block: g.block, strict: g.strict, + budgetClass: g.budgetClass, turnsUsed: g.turnsUsed, + turnsLimit: g.turnsLimit, tokensUsed: g.tokensUsed, + tokensLimit: g.tokensLimit, noProgressTurns: g.noProgressTurns, + noProgressLimit: g.noProgressLimit, + lastContinuationReason: g.lastContinuationReason, + lastEvaluatorReason: g.lastEvaluatorReason, + stopCause: g.stopCause, budgetExtensions: g.budgetExtensions, + } +} + +func (g *goalMachine) restore(snapshot goalMachineSnapshot) { + g.mu.Lock() + g.goal, g.status = snapshot.goal, snapshot.status + g.scopeID = snapshot.scopeID + g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block + g.strict = snapshot.strict + g.budgetClass = snapshot.budgetClass + g.turnsUsed, g.turnsLimit = snapshot.turnsUsed, snapshot.turnsLimit + g.tokensUsed, g.tokensLimit = snapshot.tokensUsed, snapshot.tokensLimit + g.noProgressTurns, g.noProgressLimit = snapshot.noProgressTurns, snapshot.noProgressLimit + g.lastContinuationReason = snapshot.lastContinuationReason + g.lastEvaluatorReason = snapshot.lastEvaluatorReason + g.stopCause = snapshot.stopCause + g.budgetExtensions = snapshot.budgetExtensions + g.continuationEpoch++ + g.mu.Unlock() +} + +func (g *goalMachine) writeStateErr(path string, data []byte) error { + if path == "" || data == nil { + return nil + } + g.writeMu.Lock() + defer g.writeMu.Unlock() + return writeGoalStateData(path, data) +} + +func (g *goalMachine) writeStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) { + g.writeMu.Lock() + defer g.writeMu.Unlock() + g.mu.Lock() + if g.continuationEpoch != epoch { + g.mu.Unlock() + return false, nil + } + path, data, ok := g.buildStateLocked(todos) + g.mu.Unlock() + if !ok { + return true, nil + } + return true, writeGoalStateData(path, data) +} + +func writeGoalStateData(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return fileutil.AtomicWriteFile(path, data, 0o644) +} diff --git a/internal/control/goal_durable_test.go b/internal/control/goal_durable_test.go new file mode 100644 index 0000000000..75c3c33fab --- /dev/null +++ b/internal/control/goal_durable_test.go @@ -0,0 +1,38 @@ +package control + +import ( + "os" + "path/filepath" + "testing" + + "reasonix/internal/agent" + "reasonix/internal/event" +) + +func TestSetGoalDurableRollsBackAllRuntimeStateOnWriteFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.jsonl") + exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) + c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"}) + c.SetGoal("keep the old goal") + c.goals.mu.Lock() + c.goals.turnsUsed = 7 + c.goals.tokensUsed = 4321 + c.goals.noProgressTurns = 2 + c.goals.lastContinuationReason = "preserve this reason" + c.goals.budgetExtensions = 1 + c.goals.mu.Unlock() + want := c.GoalRuntime() + + notDirectory := filepath.Join(dir, "not-a-directory") + if err := os.WriteFile(notDirectory, []byte("block nested writes"), 0o600); err != nil { + t.Fatal(err) + } + c.goals.setStatePath(filepath.Join(notDirectory, "goal.json")) + if err := c.SetGoalDurable("replace the goal"); err == nil { + t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent") + } + if got := c.GoalRuntime(); got != want { + t.Fatalf("GoalRuntime() after failed durable write = %+v, want %+v", got, want) + } +} diff --git a/internal/control/goal_legacy.go b/internal/control/goal_legacy.go new file mode 100644 index 0000000000..03b211281c --- /dev/null +++ b/internal/control/goal_legacy.go @@ -0,0 +1,170 @@ +package control + +import ( + "strings" + + "reasonix/internal/evidence" +) + +type legacyGoalRestore struct { + taskID string + todos []evidence.TodoItem + epoch uint64 + explicit bool +} + +func normalizeBudgetClass(goal, class string, legacyMode GoalResearchMode) string { + switch class { + case budgetClassSimple, budgetClassWrite, budgetClassResearch: + return class + default: + if strings.TrimSpace(goal) == "" && legacyMode != GoalResearchOn { + return "" + } + return budgetClassForLegacyMode(goal, legacyMode) + } +} + +func goalStateNeedsMigration(state goalState, normalizedBudgetClass string) bool { + expectedMode := GoalResearchOff + if strings.TrimSpace(state.AutoResearchTaskID) != "" { + expectedMode = GoalResearchOn + } + return state.TokensLimit != 0 || state.ResearchMode != expectedMode || + (state.BudgetClass != "" && state.BudgetClass != normalizedBudgetClass) +} + +// blockLegacyRestore fails closed only while the decoded sidecar still owns the +// active Goal epoch. The archive identity is held by Controller's legacy-only +// recovery boundary, never by the Goal FSM. +func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, reason string) (uint64, bool) { + g.mu.Lock() + defer g.mu.Unlock() + if g.continuationEpoch != expectedEpoch { + return 0, false + } + g.status = GoalStatusBlocked + g.stopCause = stopCauseLegacyArchive + g.block = clipGoalReason(reason) + g.continuationEpoch++ + return g.continuationEpoch, true +} + +// failLegacyRestorePersistence keeps a recovered archive retryable when the +// sidecar replacement fails. The recovered Goal text may remain in memory, but +// the Goal stays fail-closed while Controller retains the legacy identity until +// a later resume commits the migration durably. +func (g *goalMachine) failLegacyRestorePersistence(expectedEpoch uint64, reason string) (uint64, bool) { + g.mu.Lock() + defer g.mu.Unlock() + if g.continuationEpoch != expectedEpoch { + return 0, false + } + g.status = GoalStatusBlocked + g.stopCause = stopCauseLegacyArchive + g.block = clipGoalReason(reason) + g.continuationEpoch++ + return g.continuationEpoch, true +} + +func (g *goalMachine) legacyArchiveBlockedState() (goal string, epoch uint64, ok bool) { + g.mu.Lock() + defer g.mu.Unlock() + if g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive { + return "", 0, false + } + return g.goal, g.continuationEpoch, true +} + +func (c *Controller) replaceLegacyRestore(legacy legacyGoalRestore) { + c.legacyRestoreMu.Lock() + c.legacyRestore = legacy + c.legacyRestoreMu.Unlock() +} + +func (c *Controller) legacyRestoreSnapshot() (legacyGoalRestore, bool) { + c.legacyRestoreMu.Lock() + defer c.legacyRestoreMu.Unlock() + legacy := c.legacyRestore + return legacy, legacy.explicit || strings.TrimSpace(legacy.taskID) != "" +} + +func (c *Controller) advanceLegacyRestoreEpoch(taskID string, from, to uint64) { + c.legacyRestoreMu.Lock() + defer c.legacyRestoreMu.Unlock() + if c.legacyRestore.taskID == taskID && c.legacyRestore.epoch == from { + c.legacyRestore.epoch = to + } +} + +func (c *Controller) clearLegacyRestore(taskID string, epoch uint64) { + c.legacyRestoreMu.Lock() + defer c.legacyRestoreMu.Unlock() + if c.legacyRestore.taskID == taskID && c.legacyRestore.epoch == epoch { + c.legacyRestore = legacyGoalRestore{} + } +} + +// fillGoalTextIfEmpty installs archive-recovered goal text without resetting counters. +func (g *goalMachine) fillGoalTextIfEmpty(expectedEpoch uint64, goal string) (uint64, bool) { + goal = strings.TrimSpace(goal) + if goal == "" { + return 0, false + } + g.mu.Lock() + defer g.mu.Unlock() + if g.continuationEpoch != expectedEpoch || strings.TrimSpace(g.goal) != "" { + return 0, false + } + g.goal = goal + if g.status == "" || g.stopCause == stopCauseLegacyArchive { + g.status = GoalStatusRunning + } + if g.stopCause == stopCauseLegacyArchive { + g.stopCause, g.block = "", "" + } + g.budgetClass = budgetClassResearch + if g.turnsLimit < budgetQuota(g.budgetClass) { + g.turnsLimit = budgetQuota(g.budgetClass) + } + if g.noProgressLimit == 0 { + g.noProgressLimit = defaultNoProgressLimit + } + if g.scopeID == "" { + g.scopeID = newGoalScopeID() + g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID} + } + g.continuationEpoch++ + return g.continuationEpoch, true +} + +// resumeLegacyArchive applies an archive recovery only while the same blocked +// Goal lifecycle is still current. Archive reads happen off-lock, so the epoch +// check prevents a stale recovery from replacing a concurrently installed Goal. +func (g *goalMachine) resumeLegacyArchive(expectedEpoch uint64, goal string) (uint64, bool) { + goal = strings.TrimSpace(goal) + if goal == "" { + return 0, false + } + g.mu.Lock() + defer g.mu.Unlock() + if g.continuationEpoch != expectedEpoch || g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive { + return 0, false + } + g.goal = goal + g.status = GoalStatusRunning + g.stopCause, g.block = "", "" + g.budgetClass = budgetClassResearch + if g.turnsLimit < budgetQuota(g.budgetClass) { + g.turnsLimit = budgetQuota(g.budgetClass) + } + if g.noProgressLimit == 0 { + g.noProgressLimit = defaultNoProgressLimit + } + if g.scopeID == "" { + g.scopeID = newGoalScopeID() + g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID} + } + g.continuationEpoch++ + return g.continuationEpoch, true +} diff --git a/internal/control/goal_legacy_restore_test.go b/internal/control/goal_legacy_restore_test.go new file mode 100644 index 0000000000..adc3fe7ddd --- /dev/null +++ b/internal/control/goal_legacy_restore_test.go @@ -0,0 +1,649 @@ +package control + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/agent" + "reasonix/internal/event" + "reasonix/internal/evidence" +) + +func writeLegacyGoalArchive(t *testing.T, root, taskID, goal string) string { + t.Helper() + taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) + if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "state/task_spec.json": `{"task_id":"` + taskID + `","goal":"` + goal + `","allowed_operations":{"write":true},"success_criteria":[]}`, + "state/progress.json": `{"status":"running","updated_at":"2026-06-30T10:00:00Z"}`, + "state/directions_tried.json": "[]\n", + "state/findings.jsonl": "", + "state/iteration_log.jsonl": "", + "logs/heartbeat.jsonl": "", + } { + if err := os.WriteFile(filepath.Join(taskRoot, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return taskRoot +} + +func TestUnknownPersistedBudgetClassFallsBackToGoalClassification(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.jsonl") + raw, err := json.Marshal(goalState{Goal: "fix the crash in settings", Status: GoalStatusRunning, BudgetClass: "future-budget-class", TurnsLimit: 99}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goalStatePath(path), raw, 0o644); err != nil { + t.Fatal(err) + } + g := &goalMachine{} + g.setStatePath(goalStatePath(path)) + _, _, migrated, _ := g.restoreFromState(path) + if !migrated || g.budgetClass != budgetClassWrite || g.turnsLimit != 99 { + t.Fatalf("unknown budget restore = migrated:%v class:%q turns:%d", migrated, g.budgetClass, g.turnsLimit) + } +} + +func TestGoalSidecarWriterFencesLegacyAutoResearchForEveryBudget(t *testing.T) { + tests := []struct { + name string + goal string + class string + }{ + {name: "simple", goal: "summarize the current status", class: budgetClassSimple}, + {name: "write", goal: "fix the settings crash", class: budgetClassWrite}, + {name: "research", goal: "investigate the latency regression thoroughly", class: budgetClassResearch}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.jsonl") + g := &goalMachine{statePath: goalStatePath(sessionPath)} + path, raw, ok := g.set(tt.goal, tt.class, nil) + if !ok { + t.Fatal("set did not produce sidecar data") + } + var state goalState + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if state.ResearchMode != GoalResearchOff || state.AutoResearchTaskID != "" { + t.Fatalf("legacy reader fence missing: %+v", state) + } + if state.BudgetClass != tt.class || state.TurnsLimit != budgetQuota(tt.class) { + t.Fatalf("budget state = %+v, want %s/%d", state, tt.class, budgetQuota(tt.class)) + } + // Frozen previous readers treated any non-Off mode or retained task id + // as an AutoResearch activation signal. + var legacyReader struct { + ResearchMode GoalResearchMode `json:"researchMode"` + AutoResearchTaskID string `json:"autoResearchTaskID"` + } + if err := json.Unmarshal(raw, &legacyReader); err != nil { + t.Fatal(err) + } + if legacyReader.ResearchMode != GoalResearchOff || strings.TrimSpace(legacyReader.AutoResearchTaskID) != "" { + t.Fatal("frozen previous reader would reactivate AutoResearch") + } + if err := g.writeStateErr(path, raw); err != nil { + t.Fatal(err) + } + reloaded := &goalMachine{} + reloaded.restoreFromState(sessionPath) + if reloaded.budgetClass != tt.class || reloaded.turnsLimit != budgetQuota(tt.class) { + t.Fatalf("reloaded budget = %q/%d, want %q/%d", reloaded.budgetClass, reloaded.turnsLimit, tt.class, budgetQuota(tt.class)) + } + }) + } +} + +func TestEmptyGoalSidecarStillFencesLegacyAutoResearch(t *testing.T) { + g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")} + _, raw, ok := g.set("", "", nil) + if !ok { + t.Fatal("empty Goal did not produce stopped sidecar state") + } + var state goalState + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if state.ResearchMode != GoalResearchOff || state.AutoResearchTaskID != "" || state.BudgetClass != "" { + t.Fatalf("empty Goal downgrade fence = %+v", state) + } +} + +func TestGoalSetIdempotencyUsesEffectiveBudgetClass(t *testing.T) { + g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")} + if _, _, ok := g.set("same goal", budgetClassSimple, nil); !ok { + t.Fatal("initial set did not persist") + } + if _, _, ok := g.set("same goal", budgetClassSimple, nil); ok { + t.Fatal("same Goal and budget class was not idempotent") + } + if _, _, ok := g.set("same goal", budgetClassResearch, nil); !ok { + t.Fatal("budget class change was incorrectly treated as idempotent") + } + if g.budgetClass != budgetClassResearch || g.turnsLimit != budgetQuota(budgetClassResearch) { + t.Fatalf("budget upgrade = class:%q turns:%d", g.budgetClass, g.turnsLimit) + } +} + +func TestLegacySidecarArchiveFailureBlocksWithoutPersistingTaskID(t *testing.T) { + root := t.TempDir() + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + sessionPath := filepath.Join(root, "sessions", "s.jsonl") + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) + } + const ( + taskID = "retry-legacy-archive" + scopeID = "legacy-goal-scope" + ) + wantTodo := evidence.TodoItem{Content: "preserve legacy verification", Status: "in_progress"} + wantCheckpoint := evidence.DeliveryCheckpoint{ScopeID: scopeID, CriteriaEstablished: true, WorkObserved: true} + legacy := goalState{ + Status: GoalStatusRunning, ResearchMode: GoalResearchOn, AutoResearchTaskID: taskID, + ScopeID: scopeID, DeliveryCheckpoint: wantCheckpoint, Todos: []evidence.TodoItem{wantTodo}, + BudgetClass: budgetClassResearch, TurnsUsed: 3, TurnsLimit: 40, TokensUsed: 1234, + NoProgressTurns: 2, NoProgressLimit: defaultNoProgressLimit, BudgetExtensions: 1, + LastContinuationReason: "continue verification", + } + raw, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil { + t.Fatal(err) + } + + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) + defer c.Close() + if got := c.GoalStatus(); got != GoalStatusBlocked { + t.Fatalf("failed legacy restore status = %q, want blocked", got) + } + failedRaw, err := os.ReadFile(goalStatePath(sessionPath)) + if err != nil { + t.Fatal(err) + } + var failed goalState + if err := json.Unmarshal(failedRaw, &failed); err != nil { + t.Fatal(err) + } + if failed.Status != GoalStatusBlocked || failed.ResearchMode != GoalResearchOff || failed.AutoResearchTaskID != "" || failed.StopCause != stopCauseLegacyArchive || failed.Block == "" { + t.Fatalf("failed restore state = %+v, want retryable blocked legacy migration", failed) + } + if failed.ScopeID != scopeID || failed.DeliveryCheckpoint != wantCheckpoint || len(failed.Todos) != 1 || failed.Todos[0] != wantTodo { + t.Fatalf("failed restore lost goal state: %+v", failed) + } + if failed.BudgetClass != budgetClassResearch || failed.TurnsUsed != 3 || failed.TurnsLimit != 40 || failed.TokensUsed != 1234 || failed.NoProgressTurns != 2 || failed.BudgetExtensions != 1 { + t.Fatalf("failed restore lost runtime state: %+v", failed) + } + if got := exec.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo { + t.Fatalf("failed restore todos = %+v, want %+v", got, wantTodo) + } + if runtime := c.GoalRuntime(); runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 { + t.Fatalf("failed restore lost in-memory runtime state: %+v", runtime) + } + taskRoot := writeLegacyGoalArchive(t, root, taskID, "recover after archive repair") + archiveBefore, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) + if err != nil { + t.Fatal(err) + } + + if !c.ResumeGoal() { + t.Fatal("repaired archive did not resume through the in-memory legacy token") + } + if got := c.Goal(); got != "recover after archive repair" { + t.Fatalf("retried Goal() = %q", got) + } + if got := c.GoalStatus(); got != GoalStatusRunning { + t.Fatalf("retried status = %q, want running", got) + } + runtime := c.GoalRuntime() + if runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 || runtime.BudgetExtensions != 1 { + t.Fatalf("retried runtime = %+v, want preserved legacy consumption", runtime) + } + if got := exec.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo { + t.Fatalf("retried todos = %+v, want %+v", got, wantTodo) + } + if got := c.goals.deliveryState(); got != wantCheckpoint { + t.Fatalf("retried delivery checkpoint = %+v, want %+v", got, wantCheckpoint) + } + retriedRaw, err := os.ReadFile(goalStatePath(sessionPath)) + if err != nil { + t.Fatal(err) + } + var retried goalState + if err := json.Unmarshal(retriedRaw, &retried); err != nil { + t.Fatal(err) + } + if retried.AutoResearchTaskID != "" || retried.StopCause != "" || retried.Block != "" { + t.Fatalf("successful retry retained migration-only fields: %+v", retried) + } + archiveAfter, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) + if err != nil { + t.Fatal(err) + } + if string(archiveAfter) != string(archiveBefore) { + t.Fatal("legacy archive changed during retry") + } +} + +func TestLegacySidecarInvalidArchivesRemainRetryableAndReadOnly(t *testing.T) { + tests := []struct { + name string + file string + mutate func(taskID string) string + }{ + {name: "corrupt json", file: "state/progress.json", mutate: func(string) string { return "{not-json" }}, + {name: "invalid schema", file: "state/task_spec.json", mutate: func(string) string { + return `{"task_id":"different-task","goal":"schema mismatch","allowed_operations":{"write":true},"success_criteria":[]}` + }}, + {name: "empty goal", file: "state/task_spec.json", mutate: func(taskID string) string { + return `{"task_id":"` + taskID + `","goal":"","allowed_operations":{"write":true},"success_criteria":[]}` + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + taskID := "invalid-" + strings.ReplaceAll(tt.name, " ", "-") + taskRoot := writeLegacyGoalArchive(t, root, taskID, "recover only from a valid archive") + target := filepath.Join(taskRoot, tt.file) + if err := os.WriteFile(target, []byte(tt.mutate(taskID)), 0o644); err != nil { + t.Fatal(err) + } + archiveBefore, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + sessionPath := filepath.Join(root, "sessions", "s.jsonl") + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(goalState{ + Status: GoalStatusRunning, ResearchMode: GoalResearchOn, + AutoResearchTaskID: taskID, BudgetClass: budgetClassResearch, TurnsLimit: 40, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil { + t.Fatal(err) + } + + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) + defer c.Close() + if c.GoalStatus() != GoalStatusBlocked || c.ResumeGoal() { + t.Fatalf("invalid archive status=%q resumed unexpectedly", c.GoalStatus()) + } + persistedRaw, err := os.ReadFile(goalStatePath(sessionPath)) + if err != nil { + t.Fatal(err) + } + var persisted goalState + if err := json.Unmarshal(persistedRaw, &persisted); err != nil { + t.Fatal(err) + } + if persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff || persisted.StopCause != stopCauseLegacyArchive { + t.Fatalf("retry state = %+v", persisted) + } + archiveAfter, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(archiveAfter) != string(archiveBefore) { + t.Fatal("invalid legacy archive changed during failed restore") + } + }) + } +} + +func TestLegacySidecarArchiveCanRetryInSameController(t *testing.T) { + root := t.TempDir() + sessionPath := filepath.Join(root, "sessions", "s.jsonl") + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) + } + const taskID = "same-controller-retry" + legacy := goalState{ + Status: GoalStatusRunning, AutoResearchTaskID: taskID, ResearchMode: GoalResearchOn, + TurnsUsed: 5, TurnsLimit: 20, + } + raw, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil { + t.Fatal(err) + } + + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) + defer c.Close() + if c.GoalStatus() != GoalStatusBlocked || c.ResumeGoal() { + t.Fatal("missing archive did not remain blocked") + } + + writeLegacyGoalArchive(t, root, taskID, "recover objective in the same controller") + if !c.ResumeGoal() { + t.Fatal("repaired sidecar archive did not resume in the same controller") + } + if got := c.Goal(); got != "recover objective in the same controller" { + t.Fatalf("Goal() = %q, want recovered archive objective", got) + } + if runtime := c.GoalRuntime(); runtime.TurnsUsed != 5 || runtime.TurnsLimit != 40 { + t.Fatalf("runtime = %+v, want preserved use with research quota", runtime) + } + persisted, err := os.ReadFile(goalStatePath(sessionPath)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(persisted), "autoResearchTaskID") { + t.Fatalf("successful retry retained legacy task id: %s", persisted) + } +} + +func TestLegacyArchiveMigrationWriteFailureRemainsBlockedAndRetryable(t *testing.T) { + root := t.TempDir() + const taskID = "write-retry" + writeLegacyGoalArchive(t, root, taskID, "recover after sidecar write repair") + + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + defer c.Close() + + blockedParent := filepath.Join(root, "not-a-directory") + if err := os.WriteFile(blockedParent, []byte("block mkdir"), 0o644); err != nil { + t.Fatal(err) + } + c.goals.setStatePath(filepath.Join(blockedParent, "goal.json")) + rawGoal := "resume .reasonix/autoresearch/" + taskID + "/" + _, _, _ = c.goals.setLegacyArchiveBlocked(rawGoal, budgetClassResearch, "retry migration", nil) + _, epoch, ok := c.goals.legacyArchiveBlockedState() + if !ok { + t.Fatal("legacy archive block state unavailable") + } + c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: epoch, explicit: true}) + + if c.ResumeGoal() { + t.Fatal("migration reported success after its sidecar write failed") + } + goal, retryEpoch, blocked := c.goals.legacyArchiveBlockedState() + legacy, hasLegacy := c.legacyRestoreSnapshot() + if !blocked || !hasLegacy || legacy.taskID != taskID || retryEpoch != legacy.epoch || goal != "recover after sidecar write repair" { + t.Fatalf("failed write lost retry state: goal=%q legacy=%+v blocked=%v", goal, legacy, blocked) + } + if c.GoalStatus() != GoalStatusBlocked { + t.Fatalf("status = %q, want fail-closed blocked", c.GoalStatus()) + } + + statePath := filepath.Join(root, "sessions", "goal.json") + c.goals.setStatePath(statePath) + if !c.ResumeGoal() { + t.Fatal("migration did not retry after sidecar persistence was repaired") + } + raw, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + var persisted goalState + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatal(err) + } + if persisted.Status != GoalStatusRunning || persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff { + t.Fatalf("retried migration state = %+v", persisted) + } +} + +func TestStaleLegacyArchiveRetryCannotReplaceNewGoal(t *testing.T) { + var g goalMachine + g.setLegacyArchiveBlocked("resume .reasonix/autoresearch/old/", budgetClassResearch, "missing", nil) + _, epoch, ok := g.legacyArchiveBlockedState() + if !ok { + t.Fatal("legacy archive block state unavailable") + } + g.set("new goal", budgetClassWrite, nil) + if _, resumed := g.resumeLegacyArchive(epoch, "stale archive goal"); resumed { + t.Fatal("stale archive retry replaced a newer Goal") + } + if got := g.goalText(); got != "new goal" { + t.Fatalf("Goal() = %q, want concurrent replacement", got) + } +} + +func TestStaleInitialLegacyFailureCannotBlockNewGoal(t *testing.T) { + var g goalMachine + g.set("legacy goal", budgetClassResearch, nil) + epoch := g.continuationToken() + g.set("new goal", budgetClassWrite, nil) + + if _, blocked := g.blockLegacyRestore(epoch, "archive disappeared"); blocked { + t.Fatal("stale archive failure blocked a newer Goal") + } + if got := g.goalText(); got != "new goal" || g.statusForDisplay() != GoalStatusRunning { + t.Fatalf("Goal = %q status=%q, want newer running Goal", got, g.statusForDisplay()) + } +} + +func TestStaleLegacyMigrationCannotRewriteNewGoalSidecar(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "goal.json") + g := &goalMachine{statePath: statePath} + g.set("legacy goal", budgetClassResearch, nil) + legacyEpoch := g.continuationToken() + path, data, ok := g.set("new goal", budgetClassWrite, nil) + if !ok { + t.Fatal("new Goal did not build sidecar state") + } + if err := g.writeStateErr(path, data); err != nil { + t.Fatal(err) + } + if applied, err := g.writeStateAtEpoch(legacyEpoch, nil); err != nil || applied { + t.Fatalf("stale migration write = applied:%v err:%v", applied, err) + } + raw, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + var state goalState + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if state.Goal != "new goal" { + t.Fatalf("sidecar Goal = %q, want new goal", state.Goal) + } +} + +func TestLegacySidecarWithGoalMigratesWithoutArchive(t *testing.T) { + root := t.TempDir() + sessionPath := filepath.Join(root, "sessions", "s.jsonl") + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) + } + legacy := goalState{ + Goal: "preserve the original goal", Status: GoalStatusRunning, + AutoResearchTaskID: "missing-archive", ResearchMode: GoalResearchOn, + TurnsUsed: 2, TurnsLimit: 40, + } + raw, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil { + t.Fatal(err) + } + + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) + defer c.Close() + if got := c.Goal(); got != legacy.Goal { + t.Fatalf("Goal() = %q, want %q", got, legacy.Goal) + } + if got := c.GoalStatus(); got != GoalStatusRunning { + t.Fatalf("status = %q, want running", got) + } + if runtime := c.GoalRuntime(); runtime.TurnsUsed != 2 || runtime.TurnsLimit != 40 { + t.Fatalf("runtime = %+v, want preserved research budget", runtime) + } + persistedRaw, err := os.ReadFile(goalStatePath(sessionPath)) + if err != nil { + t.Fatal(err) + } + var persisted goalState + if err := json.Unmarshal(persistedRaw, &persisted); err != nil { + t.Fatal(err) + } + if persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff || persisted.BudgetClass != budgetClassResearch { + t.Fatalf("migrated sidecar = %+v, want Goal-only research state", persisted) + } +} + +func TestExplicitLegacyGoalRetryNeverRunsArchivePathAsGoal(t *testing.T) { + root := t.TempDir() + sessionPath := filepath.Join(root, "sessions", "s.jsonl") + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) + defer c.Close() + + const taskID = "repair-explicit-archive" + rawGoal := "resume .reasonix/autoresearch/" + taskID + "/" + c.SetGoal(rawGoal) + if got := c.GoalStatus(); got != GoalStatusBlocked { + t.Fatalf("initial status = %q, want blocked", got) + } + persistedRaw, err := os.ReadFile(goalStatePath(sessionPath)) + if err != nil { + t.Fatal(err) + } + var blocked goalState + if err := json.Unmarshal(persistedRaw, &blocked); err != nil { + t.Fatal(err) + } + if blocked.Status != GoalStatusBlocked || blocked.StopCause != stopCauseLegacyArchive { + t.Fatalf("blocked sidecar = %+v", blocked) + } + if c.ResumeGoal() { + t.Fatal("resume succeeded while archive was still missing") + } + if got := c.Goal(); got != rawGoal || c.GoalStatus() != GoalStatusBlocked { + t.Fatalf("failed retry changed Goal: goal=%q status=%q", got, c.GoalStatus()) + } + + writeLegacyGoalArchive(t, root, taskID, "recover the original objective") + if !c.ResumeGoal() { + t.Fatal("resume did not recover the repaired archive") + } + if got := c.Goal(); got != "recover the original objective" { + t.Fatalf("Goal() = %q, want archive objective", got) + } + if c.GoalStatus() != GoalStatusRunning || c.GoalRuntime().TurnsLimit != 40 { + t.Fatalf("recovered runtime = status:%q %+v", c.GoalStatus(), c.GoalRuntime()) + } +} + +func TestMalformedLegacyArchivePathCannotResumeAsGoalText(t *testing.T) { + c := New(Options{WorkspaceRoot: t.TempDir()}) + defer c.Close() + + c.SetGoal("resume .reasonix/autoresearch/../escape") + if c.GoalStatus() != GoalStatusBlocked { + t.Fatalf("status = %q, want blocked", c.GoalStatus()) + } + if c.ResumeGoal() { + t.Fatal("malformed archive path resumed as an ordinary Goal") + } + if c.GoalStatus() != GoalStatusBlocked { + t.Fatalf("status after resume = %q, want blocked", c.GoalStatus()) + } +} + +func TestMalformedExplicitLegacyGoalStaysBlockedAfterRestart(t *testing.T) { + root := t.TempDir() + sessionPath := filepath.Join(root, "sessions", "s.jsonl") + rawGoal := "resume .reasonix/autoresearch/bad-task/../../escape" + + exec1 := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) + c1 := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec1}) + c1.Resume(agent.NewSession("sys"), sessionPath) + c1.SetGoal(rawGoal) + if got := c1.GoalStatus(); got != GoalStatusBlocked { + t.Fatalf("initial status = %q, want blocked", got) + } + c1.Close() + + c2 := New(Options{WorkspaceRoot: root, SessionDir: root}) + c2.Resume(agent.NewSession("sys"), sessionPath) + defer c2.Close() + if got := c2.GoalStatus(); got != GoalStatusBlocked { + t.Fatalf("restart status = %q, want blocked", got) + } + if c2.ResumeGoal() { + t.Fatal("malformed explicit archive resumed after restart") + } + if got := c2.Goal(); got != rawGoal { + t.Fatalf("restart retry changed Goal = %q, want %q", got, rawGoal) + } +} + +func TestMissingLegacyGoalCommandDoesNotStartProviderTurn(t *testing.T) { + runner := &gatedTurnRunner{started: make(chan struct{}), release: make(chan struct{})} + c := New(Options{WorkspaceRoot: t.TempDir(), Runner: runner}) + t.Cleanup(c.Close) + + if !c.applyGoalCommand("/goal resume .reasonix/autoresearch/missing-task/", "") { + t.Fatal("legacy Goal command was not parsed") + } + if c.Running() { + t.Fatal("missing legacy archive started a provider turn") + } + if got := c.GoalStatus(); got != GoalStatusBlocked { + t.Fatalf("GoalStatus() = %q, want blocked", got) + } +} + +func TestUnreadableExplicitLegacyArchiveBlocks(t *testing.T) { + root := t.TempDir() + const taskID = "unreadable-explicit-archive" + taskRoot := writeLegacyGoalArchive(t, root, taskID, "never run an unreadable archive") + specPath := filepath.Join(taskRoot, "state", "task_spec.json") + if err := os.Remove(specPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(specPath, 0o755); err != nil { + t.Fatal(err) + } + c := New(Options{WorkspaceRoot: root}) + t.Cleanup(c.Close) + + c.SetGoal("resume .reasonix/autoresearch/" + taskID + "/") + if got := c.GoalStatus(); got != GoalStatusBlocked { + t.Fatalf("GoalStatus() = %q, want blocked", got) + } + if got := c.Goal(); got != "resume .reasonix/autoresearch/"+taskID+"/" { + t.Fatalf("Goal() = %q, archive goal must not be trusted", got) + } +} diff --git a/internal/control/goal_runtime_test.go b/internal/control/goal_runtime_test.go index 299e3d9ef8..33e10aceb0 100644 --- a/internal/control/goal_runtime_test.go +++ b/internal/control/goal_runtime_test.go @@ -364,7 +364,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) { t.Fatal(err) } // The goal is replaced: epoch bumps, scope rotates. - g.set("replacement", GoalResearchAuto, "", nil) + g.set("replacement", "", nil) if got := rec.validReport(rec.epoch); got != nil { t.Fatalf("stale recorder report = %+v, want nil", got) } @@ -372,7 +372,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) { t.Run("late record after replacement rejected", func(t *testing.T) { g, rec := newRec(t) - g.set("replacement", GoalResearchAuto, "", nil) + g.set("replacement", "", nil) if _, err := rec.RecordGoalReport(report(GoalStatusComplete, "")); err == nil { t.Fatal("late record on a replaced goal must be rejected") } @@ -384,7 +384,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) { if g.tokensUsed != 150 { t.Fatalf("tokensUsed = %d, want 150", g.tokensUsed) } - g.set("replacement", GoalResearchAuto, "", nil) + g.set("replacement", "", nil) rec.addUsage(50) if g.tokensUsed != 0 { t.Fatalf("stale usage folded into replacement goal: %d", g.tokensUsed) @@ -436,7 +436,7 @@ func TestGoalUsageTeeAttributesScopedBillableCallsAndExcludesTitle(t *testing.T) func TestBudgetClassForBareFaultIsWrite(t *testing.T) { // User-reported Chinese bare fault → write turn quota (20), no token ceiling. - class := budgetClassFor("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto) + class := budgetClassForLegacyMode("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto) if class != budgetClassWrite { t.Fatalf("budget class = %q, want write", class) } @@ -450,12 +450,12 @@ func TestBudgetClassForBareFaultIsWrite(t *testing.T) { "诊断数据库连接失败原因。", "复现并定位问题,但不要修复。", } { - if got := budgetClassFor(goal, GoalResearchAuto); got != budgetClassSimple { + if got := budgetClassForLegacyMode(goal, GoalResearchAuto); got != budgetClassSimple { t.Errorf("budgetClassFor(%q) = %q, want simple", goal, got) } } // Explicit mutation verbs remain write. - if got := budgetClassFor("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite { + if got := budgetClassForLegacyMode("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite { t.Fatalf("explicit fix class = %q, want write", got) } } @@ -488,7 +488,7 @@ func TestGoalLegacyBudgetTokensSidecarAutoResumes(t *testing.T) { t.Fatal(err) } g := &goalMachine{} - migPath, migData, migrated := g.restoreFromState(path) + migPath, migData, migrated, _ := g.restoreFromState(path) if !migrated { t.Fatal("legacy budget_tokens pause must migrate") } @@ -519,7 +519,7 @@ func TestGoalLegacyBudgetTokensSidecarAutoResumes(t *testing.T) { } // Second load must stay running without re-entering the legacy pause. g2 := &goalMachine{} - if _, _, migrated2 := g2.restoreFromState(path); migrated2 { + if _, _, migrated2, _ := g2.restoreFromState(path); migrated2 { t.Fatal("normalized sidecar migrated a second time") } if g2.status != GoalStatusRunning || g2.stopCause != "" { diff --git a/internal/control/goal_test.go b/internal/control/goal_test.go index db7f38e98b..4ab4a288cf 100644 --- a/internal/control/goal_test.go +++ b/internal/control/goal_test.go @@ -130,7 +130,7 @@ func toolCallChunk(id, name, args string) provider.Chunk { } func TestActiveGoalBlockCarriesTaskContractAndPausePolicy(t *testing.T) { - block := activeGoalBlock("fix the parser", GoalResearchOff) + block := activeGoalBlock("fix the parser") for _, want := range []string{ "Treat the user's goal as a task contract", "Context, Request, Output format, Constraints", @@ -226,530 +226,205 @@ func TestPlainInputWithStrongResearchSignalPreservesRefsWithoutStartingGoal(t *t } } -func TestPlainAutoResearchTaskPathDoesNotResumeGoal(t *testing.T) { +func TestResearchGoalUsesUnifiedGoalBudgetWithoutArchive(t *testing.T) { root := t.TempDir() - prov := &scriptedTurns{turns: [][]provider.Chunk{ - textTurn("Handled as an ordinary turn."), - }} - ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 8) - c := New(Options{ - WorkspaceRoot: root, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - defer c.Close() - c.SetGoalWithResearchMode("seed resumable task", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected seeded AutoResearch task") - } - c.ClearGoal() - - input := "继续 .reasonix/autoresearch/" + taskID + "/ 这个任务" - c.Submit(input) - waitForTurnDone(t, events) - - if prov.call != 1 { - t.Fatalf("provider calls = %d, want 1", prov.call) - } - first := firstUserMessage(ag.Session().Messages) - if !strings.HasSuffix(first, input) { - t.Fatalf("ordinary task path should preserve the original prompt suffix: %q", first) - } - if strings.Contains(first, "") || strings.Contains(first, "AutoResearch protocol") { - t.Fatalf("ordinary task path should not enter Goal or AutoResearch:\n%s", first) - } - if got := c.Goal(); got != "" { - t.Fatalf("ordinary task path should not resume Goal, got %q", got) - } - if got := c.GoalStatus(); got != GoalStatusStopped { - t.Fatalf("GoalStatus() = %q, want stopped", got) - } -} - -func TestResearchGoalCreatesHostManagedAutoResearchTask(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } sessionPath := filepath.Join(root, "sessions", "s.jsonl") - ag := agent.New(&scriptedTurns{}, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag}) - + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn) - - data, err := os.ReadFile(goalStatePath(sessionPath)) - if err != nil { - t.Fatalf("read goal state: %v", err) - } - var state goalState - if err := json.Unmarshal(data, &state); err != nil { - t.Fatalf("unmarshal goal state: %v", err) - } - if state.AutoResearchTaskID == "" { - t.Fatalf("AutoResearchTaskID was empty in persisted goal state: %+v", state) - } - for _, rel := range []string{ - "state/task_spec.json", - "state/progress.json", - "state/findings.jsonl", - "logs/heartbeat.jsonl", - } { - path := filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, rel) - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected autoresearch file %s: %v", rel, err) - } - } - var spec struct { - SuccessCriteria []struct { - ID string `json:"id"` - Required bool `json:"required"` - } `json:"success_criteria"` - } - readJSONFileForTest(t, filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, "state", "task_spec.json"), &spec) - if len(spec.SuccessCriteria) != 2 || spec.SuccessCriteria[0].ID != "objective_evidence" || spec.SuccessCriteria[1].ID != "verification" { - t.Fatalf("default success criteria = %+v, want objective_evidence and verification", spec.SuccessCriteria) + defer c.Close() + if got := c.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("research Goal turns limit = %d, want 40", got) } - for _, criterion := range spec.SuccessCriteria { - if !criterion.Required { - t.Fatalf("default criterion %+v was not required", criterion) - } + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("research Goal created legacy archive: %v", err) } - - composed := c.Compose("continue") - if !strings.Contains(composed, "") || !strings.Contains(composed, "task_id: "+state.AutoResearchTaskID) || !strings.Contains(composed, "objective_evidence") { - t.Fatalf("Compose missing runtime summary for task %q:\n%s", state.AutoResearchTaskID, composed) + if composed := c.Compose("continue"); strings.Contains(composed, "AutoResearch") || strings.Contains(composed, "autoresearch") { + t.Fatalf("Goal prompt exposes removed AutoResearch protocol:\n%s", composed) } } -func TestResearchGoalCreatedEmitsLifecycleNotice(t *testing.T) { +func TestLegacyGoalSidecarMigratesToResearchBudgetWithoutTaskID(t *testing.T) { root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - events := make(chan event.Event, 4) - c := New(Options{ - WorkspaceRoot: root, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - events <- e - } - }), - }) - - c.SetGoalWithResearchMode("investigate lifecycle notice", GoalResearchOn) - - select { - case e := <-events: - if !strings.Contains(e.Text, "autoresearch task created") { - t.Fatalf("notice = %q, want autoresearch task created", e.Text) - } - default: - t.Fatal("expected autoresearch lifecycle notice") + sessionPath := filepath.Join(root, "sessions", "s.jsonl") + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) } -} - -func TestResearchGoalRepeatedSetReusesAutoResearchTask(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + writeLegacyGoalArchive(t, root, "old-task", "archive fallback should not replace sidecar goal") + if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"goal":"investigate runtime","status":"running","researchMode":1,"autoResearchTaskID":"old-task"}`), 0o644); err != nil { + t.Fatal(err) } - events := make(chan event.Event, 8) - c := New(Options{ - WorkspaceRoot: root, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - events <- e - } - }), - }) - - goal := "请持续研究当前项目的 AutoResearch 状态栏展示链路,验证任务创建、状态刷新、右侧 Context 面板展示、状态栏 chip 展示是否一致。不要只看表面现象,需要找到根因、记录 evidence,并在完成前确认所有验证步骤通过。不要修改文件" - c.SetGoalWithResearchMode(goal, GoalResearchOn) - _, _, _, firstTaskID := c.goals.snapshot() - if firstTaskID == "" { - t.Fatal("first AutoResearch task id was empty") + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) + defer c.Close() + if got := c.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("migrated Goal turns limit = %d, want 40", got) } - - c.SetGoalWithResearchMode(goal, GoalResearchOn) - c.SetGoal(goal) - _, _, _, repeatedTaskID := c.goals.snapshot() - if repeatedTaskID != firstTaskID { - t.Fatalf("repeated SetGoal created a new task: got %q, want %q", repeatedTaskID, firstTaskID) + if got := c.Goal(); got != "investigate runtime" { + t.Fatalf("migrated Goal = %q, want sidecar goal", got) } - - entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch")) + raw, err := os.ReadFile(goalStatePath(sessionPath)) if err != nil { - t.Fatalf("read autoresearch dir: %v", err) + t.Fatal(err) } - if len(entries) != 1 { - t.Fatalf("autoresearch task count = %d, want 1", len(entries)) + var state goalState + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) } - - createdNotices := 0 - for { - select { - case e := <-events: - if strings.Contains(e.Text, "autoresearch task created") { - createdNotices++ - } - default: - if createdNotices != 1 { - t.Fatalf("created notices = %d, want 1", createdNotices) - } - return - } + if state.AutoResearchTaskID != "" { + t.Fatalf("migrated sidecar retained old task id: %q", state.AutoResearchTaskID) } } -func TestResearchGoalMissingExplicitTaskBlocksInsteadOfCreatingNewTask(t *testing.T) { +func TestMissingExplicitLegacyTaskBlocksWithoutCreatingArchive(t *testing.T) { root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - }), - }) - + c := New(Options{WorkspaceRoot: root}) + defer c.Close() c.SetGoalWithResearchMode("resume .reasonix/autoresearch/missing-task/", GoalResearchOn) - if got := c.GoalStatus(); got != GoalStatusBlocked { - t.Fatalf("GoalStatus() = %q, want blocked for missing explicit AutoResearch task", got) - } - if got := c.goals.currentAutoResearchTaskID(); got != "" { - t.Fatalf("current AutoResearch task id = %q, want none for missing explicit task", got) + t.Fatalf("GoalStatus = %q, want blocked", got) } - entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch")) - if err != nil && !os.IsNotExist(err) { - t.Fatalf("ReadDir autoresearch root: %v", err) - } - if len(entries) != 0 { - t.Fatalf("created tasks for missing explicit resume: %+v", entries) - } - if !containsNotice(notices, "autoresearch resume failed") || !containsNotice(notices, "missing-task") { - t.Fatalf("notices = %+v, want explicit resume failure", notices) - } -} - -func TestResearchGoalTurnAppendsAutoResearchHeartbeats(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("missing legacy task created archive: %v", err) } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusComplete, "", ""), - goalToolTurn(GoalStatusBlocked, "needs a repro trace", ""), - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 4) - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - - c.Submit("/goal --research fix the typo and add a test") - waitForTurnDone(t, events) - data, err := os.ReadFile(goalStatePath(sessionPath)) - if err != nil { - t.Fatalf("read goal state: %v", err) - } - var state goalState - if err := json.Unmarshal(data, &state); err != nil { - t.Fatalf("unmarshal goal state: %v", err) - } - heartbeats, err := c.autoResearch.store.Heartbeats(state.AutoResearchTaskID, 10) - if err != nil { - t.Fatalf("Heartbeats: %v", err) - } - if len(heartbeats) < 2 { - t.Fatalf("heartbeats = %+v, want at least starting and done", heartbeats) + c.SetGoal("resume .reasonix/autoresearch/missing-task/../../escape") + if got := c.GoalStatus(); got != GoalStatusBlocked { + t.Fatalf("unsafe legacy path status = %q, want blocked", got) } - if heartbeats[0].Status != "starting_turn" || heartbeats[len(heartbeats)-1].Status != "turn_done" { - t.Fatalf("heartbeats = %+v, want starting_turn then turn_done", heartbeats) + if got := c.Goal(); got != "resume .reasonix/autoresearch/missing-task/../../escape" { + t.Fatalf("unsafe legacy path silently resumed a truncated task: %q", got) } } -func TestResearchGoalTurnUpdatesAutoResearchStaleProgress(t *testing.T) { +func TestAssistantEvidenceBlockIsIgnoredByUnifiedGoal(t *testing.T) { root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusRunning, "still investigating", ""), - goalToolTurn(GoalStatusBlocked, "needs a repro trace", ""), - )} + turns := goalToolTurn(GoalStatusComplete, "", "") + const evidenceBlock = `{"id":"legacy-evidence","kind":"verification","summary":"must remain ordinary assistant text"}` + turns[len(turns)-1] = textTurn("worked on the goal\n" + evidenceBlock) + prov := &scriptedTurns{turns: flattenTurns(turns)} ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 8) - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - - c.Submit("/goal --research investigate stale progress") - waitForTurnDone(t, events) - - data, err := os.ReadFile(goalStatePath(sessionPath)) - if err != nil { - t.Fatalf("read goal state: %v", err) - } - var state goalState - if err := json.Unmarshal(data, &state); err != nil { - t.Fatalf("unmarshal goal state: %v", err) + c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag}) + defer c.Close() + c.SetGoalWithResearchMode("verify the fix", GoalResearchOn) + _ = newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start") + if got := c.GoalStatus(); got != GoalStatusComplete { + t.Fatalf("GoalStatus = %q, want complete", got) } - summary, err := c.autoResearch.store.Summary(state.AutoResearchTaskID) - if err != nil { - t.Fatalf("Summary: %v", err) + if got := lastAssistantText(c.History()); !strings.Contains(got, evidenceBlock) { + t.Fatalf("legacy evidence block was interpreted instead of retained as transcript text: %q", got) } - if summary.Iteration < 2 || summary.StaleCount != summary.Iteration || !summary.PivotRequired || summary.PivotCount != 1 { - t.Fatalf("summary = %+v, want stale progress for every no-evidence turn and pivot required", summary) + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("assistant evidence created archive: %v", err) } } -func TestResearchGoalCompletionIsInterceptedWhenReadinessFails(t *testing.T) { +func TestExplicitLegacyTaskPathRestoresOriginalGoal(t *testing.T) { root := t.TempDir() if resolved, err := filepath.EvalSymlinks(root); err == nil { root = resolved } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusComplete, "", ""), - goalToolTurn(GoalStatusBlocked, "missing evidence", ""), - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 8) - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - - c.SetGoalWithResearchMode("identify the root cause", GoalResearchOn) - - if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil { - t.Fatalf("runGoalLoopWithRawDisplay: %v", err) - } - - if got := c.GoalStatus(); got != GoalStatusBlocked { - t.Fatalf("GoalStatus() = %q, want blocked after the readiness intercept and a blocked report", got) - } - if prov.call != 4 { - t.Fatalf("provider calls = %d, want complete-intercepted + blocked turns (2 provider calls each)", prov.call) - } - if !sessionContainsUserText(ag.Session().Messages, "AutoResearch readiness check failed", "objective_evidence", "verification") { - t.Fatalf("transcript missing readiness intercept; last user:\n%s", lastUserMessage(ag.Session().Messages)) + taskID := "20260630-original-goal" + taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) + if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil { + t.Fatal(err) } - if !containsNotice(notices, "Goal is not ready to complete yet; continuing the remaining work.") { - t.Fatalf("notices = %+v, want readiness continuation notice", notices) + if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil { + t.Fatal(err) } -} - -func TestControllerRecordsAutoResearchEvidence(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + spec := `{"task_id":"` + taskID + `","goal":"find the original root cause","allowed_operations":{"write":true},"success_criteria":[]}` + progress := `{"status":"running","iteration":2,"updated_at":"2026-06-30T10:00:00Z"}` + for name, body := range map[string]string{ + "state/task_spec.json": spec, + "state/progress.json": progress, + "state/directions_tried.json": "[]\n", + "state/findings.jsonl": "", + "state/iteration_log.jsonl": "", + "logs/heartbeat.jsonl": "", + } { + if err := os.WriteFile(filepath.Join(taskRoot, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } } - c := New(Options{WorkspaceRoot: root}) - c.SetGoalWithResearchMode("verify the fix", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected autoresearch task id") - } - - err := c.RecordAutoResearchEvidence("objective_evidence", AutoResearchEvidenceInput{ - ID: "f-objective", - Kind: "file", - Summary: "implementation inspected", - Source: "file", - Paths: []string{"internal/control/controller.go"}, - Accepted: true, - }) - if err != nil { - t.Fatalf("RecordAutoResearchEvidence objective_evidence: %v", err) - } - err = c.RecordAutoResearchEvidence("verification", AutoResearchEvidenceInput{ - ID: "f-verification", - Kind: "test", - Summary: "go test passed", - Source: "command", - Command: "go test ./internal/control", - Accepted: true, - }) + before, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) if err != nil { - t.Fatalf("RecordAutoResearchEvidence verification: %v", err) + t.Fatal(err) } - - report, err := c.autoResearch.store.Readiness(taskID) - if err != nil { - t.Fatalf("Readiness: %v", err) + c := New(Options{WorkspaceRoot: root}) + defer c.Close() + c.SetGoalWithResearchMode("resume .reasonix/autoresearch/"+taskID+"/", GoalResearchAuto) + if got := c.Goal(); got != "find the original root cause" { + t.Fatalf("Goal() = %q, want original archive goal", got) } - if !report.Ready { - t.Fatalf("readiness = %+v, want ready", report) + if got := c.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("turns limit = %d, want research budget", got) } -} - -func TestAutoResearchEvidenceDoesNotChangeDefaultToolSurface(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + if got := c.GoalStatus(); got != GoalStatusRunning { + t.Fatalf("status = %q", got) } - reg := tool.NewRegistry() - New(Options{WorkspaceRoot: root, Registry: reg}) - if _, ok := reg.Get("autoresearch_record_evidence"); ok { - t.Fatalf("autoresearch_record_evidence should not be registered in the default provider-visible tool surface; tools=%v", reg.Names()) + after, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) + if err != nil { + t.Fatal(err) } - for _, schema := range reg.Schemas() { - if schema.Name == "autoresearch_record_evidence" { - t.Fatalf("autoresearch_record_evidence should not appear in provider schemas: %+v", reg.Schemas()) - } + if string(before) != string(after) { + t.Fatal("archive task_spec mutated during resume") } } -func TestResearchGoalCompletionMarksAutoResearchTaskComplete(t *testing.T) { +func TestLegacySidecarEmptyGoalFilledFromArchive(t *testing.T) { root := t.TempDir() if resolved, err := filepath.EvalSymlinks(root); err == nil { root = resolved } sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - [][]provider.Chunk{ - {toolCallChunk("ug1", "update_goal", `{"status":"complete","reason":""}`), {Type: provider.ChunkDone}}, - textTurn(`Done. - - -{"criterion_id":"objective_evidence","id":"f-objective","kind":"file","summary":"The implementation state was inspected directly.","source":"file","paths":["internal/control/controller.go"],"accepted":true} - - -{"criterion_id":"verification","id":"f-verification","kind":"test","summary":"The focused AutoResearch tests passed.","source":"command","command":"go test ./internal/control","accepted":true} -`), - }, - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - }), - }) - c.SetGoalWithResearchMode("verify completion lifecycle", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected autoresearch task id") - } - - if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil { - t.Fatalf("runGoalLoopWithRawDisplay: %v", err) - } - - summary, err := c.autoResearch.store.Summary(taskID) - if err != nil { - t.Fatalf("Summary: %v", err) - } - if summary.Status != "complete" { - t.Fatalf("AutoResearch status = %q, want complete", summary.Status) - } - if summary.StaleCount != 0 { - t.Fatalf("AutoResearch stale_count = %d, want 0 after accepted evidence", summary.StaleCount) + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) } - findings, err := c.autoResearch.store.Findings(taskID, 0) - if err != nil { - t.Fatalf("Findings: %v", err) + taskID := "fill-from-archive" + taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) + if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil { + t.Fatal(err) } - if len(findings) != 2 { - t.Fatalf("findings = %+v, want two assistant evidence records", findings) + if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil { + t.Fatal(err) } - if !containsNotice(notices, "autoresearch task completed") { - t.Fatalf("notices = %+v, want autoresearch task completed", notices) + for name, body := range map[string]string{ + "state/task_spec.json": `{"task_id":"` + taskID + `","goal":"recover me from archive","allowed_operations":{"write":true},"success_criteria":[]}`, + "state/progress.json": `{"status":"running","updated_at":"2026-06-30T10:00:00Z"}`, + "state/directions_tried.json": "[]\n", + "state/findings.jsonl": "", + "state/iteration_log.jsonl": "", + "logs/heartbeat.jsonl": "", + } { + if err := os.WriteFile(filepath.Join(taskRoot, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } } -} - -func TestResearchGoalBlockedMarksAutoResearchTaskBlocked(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"status":"running","researchMode":1,"autoResearchTaskID":"`+taskID+`","turnsUsed":3,"turnsLimit":40}`), 0o644); err != nil { + t.Fatal(err) } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusBlocked, "needs credentials", ""), - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - }), - }) - c.SetGoalWithResearchMode("verify blocked lifecycle", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected autoresearch task id") + sess := agent.NewSession("sys") + exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) + c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) + c.Resume(sess, sessionPath) + defer c.Close() + if got := c.Goal(); got != "recover me from archive" { + t.Fatalf("Goal() = %q", got) } - - if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil { - t.Fatalf("runGoalLoopWithRawDisplay: %v", err) + if got := c.GoalRuntime().TurnsUsed; got != 3 { + t.Fatalf("turns used = %d, want preserved 3", got) } - - summary, err := c.autoResearch.store.Summary(taskID) + raw, err := os.ReadFile(goalStatePath(sessionPath)) if err != nil { - t.Fatalf("Summary: %v", err) - } - if summary.Status != "blocked" || !strings.Contains(summary.Blocker, "needs credentials") { - t.Fatalf("AutoResearch summary = %+v, want blocked with reason", summary) + t.Fatal(err) } - if !containsNotice(notices, "AutoResearch task marked blocked.") { - t.Fatalf("notices = %+v, want autoresearch blocked notice", notices) + if strings.Contains(string(raw), "autoResearchTaskID") { + t.Fatalf("sidecar retained task id: %s", raw) } } @@ -1009,7 +684,7 @@ func TestGoalInterceptsCompleteWithIncompleteTodos(t *testing.T) { func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) { newResult := func(t *testing.T, g *goalMachine) goalAdvanceResult { t.Helper() - g.set("old goal", GoalResearchAuto, "", nil) + g.set("old goal", "", nil) res := g.advance(goalAdvanceInput{ report: &goalTurnReport{status: GoalStatusComplete, reason: ""}, todos: []evidence.TodoItem{{ @@ -1034,7 +709,7 @@ func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) { t.Run("replacement goal invalidates result", func(t *testing.T) { var g goalMachine res := newResult(t, &g) - g.set("replacement goal", GoalResearchAuto, "", nil) + g.set("replacement goal", "", nil) if got, ok := g.acceptContinuation(res); ok { t.Fatalf("replacement goal accepted stale intercept %q", got) } @@ -1251,45 +926,6 @@ func TestRepeatedCompleteWithIncompleteTodosPausesOnBudget(t *testing.T) { } } -func readJSONFileForTest(t *testing.T, path string, out any) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile(%s): %v", path, err) - } - if err := json.Unmarshal(data, out); err != nil { - t.Fatalf("Unmarshal(%s): %v", path, err) - } -} - -func sessionContainsUserText(messages []provider.Message, needles ...string) bool { - for _, msg := range messages { - if msg.Role != provider.RoleUser { - continue - } - ok := true - for _, needle := range needles { - if !strings.Contains(msg.Content, needle) { - ok = false - break - } - } - if ok { - return true - } - } - return false -} - -func containsNotice(notices []string, needle string) bool { - for _, notice := range notices { - if strings.Contains(notice, needle) { - return true - } - } - return false -} - // TestSessionRotationClearsActiveGoal pins the /new & /clear goal semantics: // a fresh session starts with no active goal (so the old goal's text stops // injecting into its first turns), while the OLD session's persisted diff --git a/internal/control/input.go b/internal/control/input.go index e37d2c612d..2a1b2eaf4a 100644 --- a/internal/control/input.go +++ b/internal/control/input.go @@ -3,7 +3,6 @@ package control import ( "context" "fmt" - "strconv" "strings" "unicode" @@ -139,15 +138,13 @@ func (c *Controller) Compose(text string) string { } func (c *Controller) compose(text, source string, includeHookContext bool) string { - goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot() + goal, goalStatus := c.goals.snapshot() return c.composeWithGoal( text, source, includeHookContext, goal, goalStatus, - goalResearchMode, - autoResearchTaskID, ) } @@ -155,8 +152,6 @@ func (c *Controller) composeWithGoal( text, source string, includeHookContext bool, goal, goalStatus string, - goalResearchMode GoalResearchMode, - autoResearchTaskID string, ) string { c.mu.Lock() plan := c.planMode @@ -166,10 +161,7 @@ func (c *Controller) composeWithGoal( notes := c.memory.drainPending() if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning { - prefix := activeGoalBlock(goal, goalResearchMode) - if runtime := c.autoResearchRuntimeBlock(autoResearchTaskID); runtime != "" { - prefix += "\n\n" + runtime - } + prefix := activeGoalBlock(goal) text = prefix + "\n\n" + text } if plan { @@ -291,53 +283,6 @@ func escapeHookContext(s string) string { return strings.ReplaceAll(s, "", "<\\/"+hookContextTag+">") } -func (c *Controller) autoResearchRuntimeBlock(taskID string) string { - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return "" - } - summary, err := c.autoResearch.summary(taskID) - if err != nil { - return "\nstatus: invalid\nerror: " + strings.ReplaceAll(err.Error(), autoResearchRuntimeClose, "<\\/autoresearch-runtime>") + "\n" - } - var b strings.Builder - b.WriteString("\n") - b.WriteString("task_id: " + summary.TaskID + "\n") - b.WriteString("status: " + summary.Status + "\n") - b.WriteString("iteration: ") - b.WriteString(strconv.Itoa(summary.Iteration)) - b.WriteString("\n") - b.WriteString("current_direction: " + summary.CurrentDirection + "\n") - b.WriteString("stale_count: ") - b.WriteString(strconv.Itoa(summary.StaleCount)) - b.WriteString("\n") - b.WriteString("pivot_count: ") - b.WriteString(strconv.Itoa(summary.PivotCount)) - b.WriteString("\n") - if summary.PivotRequired { - b.WriteString("pivot_required: true\n") - } else { - b.WriteString("pivot_required: false\n") - } - b.WriteString("open_success_criteria: ") - b.WriteString(strconv.Itoa(len(summary.OpenCriteria))) - b.WriteString("\n") - for _, criterion := range summary.OpenCriteria { - b.WriteString("- ") - b.WriteString(criterion.ID) - b.WriteString(": ") - b.WriteString(strings.ReplaceAll(criterion.Description, "\n", " ")) - b.WriteString("\n") - } - if summary.Blocker != "" { - b.WriteString("blocker: " + summary.Blocker + "\n") - } - b.WriteString("next_required_action: " + summary.NextRequiredAction + "\n") - b.WriteString("") - return b.String() -} - -const autoResearchRuntimeClose = "" - func reasoningLanguageBlock(lang string) string { return agent.ReasoningLanguageBlock(lang) } @@ -351,7 +296,7 @@ func (c *Controller) ComposeSynthetic(text string) string { return agent.WithReasoningLanguageForSource(text, lang, text) } -func activeGoalBlock(goal string, researchMode GoalResearchMode) string { +func activeGoalBlock(goal string) string { goal = strings.TrimSpace(goal) goal = strings.ReplaceAll(goal, activeGoalClose, "<\\/active-goal>") var b strings.Builder @@ -360,10 +305,6 @@ func activeGoalBlock(goal string, researchMode GoalResearchMode) string { b.WriteString(goal) b.WriteString("\n\n") b.WriteString(goalTaskContractInstructions) - if shouldUseAutoResearch(goal, researchMode) { - b.WriteString("\n\n") - b.WriteString(autoResearchGoalInstructions) - } b.WriteString("\n") b.WriteString(activeGoalClose) return b.String() @@ -377,106 +318,6 @@ const goalTaskContractInstructions = `Goal mode: pursue this goal autonomously. Do not stop after describing a plan; execute the next useful step. End every goal-mode turn by calling the update_goal tool with your disposition: continue (work is ongoing — give the next concrete step in next_action), complete (only when fully done and verified), or blocked (only when the user can unblock). The host validates your claim and decides whether to continue automatically.` -const autoResearchGoalInstructions = `AutoResearch protocol: this goal looks like long-horizon research, debugging, optimization, or implementation work. Treat AutoResearch as a durable strategy for this Goal, not as a background daemon or a global skill. -- Say briefly in the first visible reply that the goal is being handled with AutoResearch and that host-owned state lives under .reasonix/autoresearch//, using the actual task_id from . -- Keep dynamic state out of REASONIX.md, AGENTS.md, project memory, system prompts, and tool schemas. Use project-local .reasonix/autoresearch/ state only. -- Use the task_id and open_success_criteria in as authoritative. The host creates task ids and owns state/task_spec.json, state/progress.json, state/findings.jsonl, state/directions_tried.json, state/iteration_log.jsonl, and logs/heartbeat.jsonl. -- Do not hand-edit the host-owned AutoResearch state files. When you have direct evidence for an open criterion, include an block in your assistant reply so the host can persist it: - -{"criterion_id":"objective_evidence","kind":"file","summary":"What was directly observed","source":"file","paths":["relative/path"],"accepted":true} - -- Before each iteration, use the runtime summary as authoritative, choose a direction that differs materially from directions already tried, execute the smallest evidence-producing chunk, verify it, and report accepted evidence with blocks. -- Increment stale_count when an iteration lacks accepted evidence or repeats a prior direction. At stale_count >= 2, make a structural pivot such as changing evidence source, entrypoint, implementation boundary, test oracle, benchmark, decomposition, environment, platform, or refutation angle. At stale_count >= 4, stop autonomous digging and ask for the smallest external input needed. -- Workers or subagents may gather evidence, but the orchestrator owns canonical state writes. Workers must not publish, push, delete, contact external systems, or write canonical state unless explicitly designated. -- Complete only after auditing every open success criterion in against direct evidence. Public publishing, destructive changes, credential use, payments, external notifications, privacy-sensitive output, and cache-sensitive changes still require the normal Reasonix gates.` - -func shouldUseAutoResearch(goal string, mode GoalResearchMode) bool { - switch mode { - case GoalResearchOn: - return true - case GoalResearchOff: - return false - } - return isAutoResearchGoal(goal) -} - -func isAutoResearchGoal(goal string) bool { - trimmed := strings.TrimSpace(goal) - if trimmed == "" { - return false - } - lower := strings.ToLower(trimmed) - if strings.Contains(lower, ".reasonix/autoresearch/") { - return true - } - for _, kw := range autoResearchStrongKeywords { - if strings.Contains(lower, kw) { - return true - } - } - return autoResearchPhaseCount(lower) >= 4 -} - -func autoResearchPhaseCount(lower string) int { - categories := 0 - for _, group := range autoResearchPhaseKeywords { - if containsAnyGoalKeyword(lower, group) { - categories++ - } - } - return categories -} - -var autoResearchStrongKeywords = []string{ - "持续", - "长期", - "彻底", - "直到根因", - "根因明确", - "多轮", - "不要原地打转", - "别原地打转", - "完整方案", - "完整做成方案", - "跑实验", - "反复验证", - "长期优化", - "系统性研究", - "持续研究", - "持续排查", - "持续推进", - "长期跑", - "long-horizon", - "long horizon", - "long-running", - "keep researching", - "keep working", - "root cause", - "until the root cause", - "do not spin", - "don't spin", - "thoroughly", - "systematically", -} - -var autoResearchPhaseKeywords = [][]string{ - {"研究", "调研", "排查", "分析", "定位", "诊断", "research", "investigate", "diagnose", "analyze", "analysis"}, - {"实现", "修复", "改造", "开发", "重构", "implement", "build", "fix", "refactor"}, - {"验证", "测试", "复现", "联调", "benchmark", "verify", "validate", "test", "reproduce"}, - {"优化", "完善", "提升", "收敛", "optimize", "improve", "tune", "polish"}, - {"文档", "方案", "说明", "总结", "document", "docs", "writeup", "plan"}, - {"发布", "上线", "提交", "pull request", "publish", "ship", "deploy"}, -} - -func containsAnyGoalKeyword(s string, needles []string) bool { - for _, needle := range needles { - if strings.Contains(s, needle) { - return true - } - } - return false -} - // MemoryQuickAddNote parses the "# " memory shortcut. The space after // "#" is intentional: "#7", "#issue", and "#标题" are ordinary user prompts, // not memory writes. Multi-line input starting with "# " is NOT treated as a @@ -518,12 +359,15 @@ const ( ) type GoalCommand struct { - Action GoalCommandAction - Text string - Strict bool - ResearchMode GoalResearchMode + Action GoalCommandAction + Text string + Strict bool + ResearchMode GoalResearchMode + DeprecatedBudgetFlag bool } +const GoalBudgetFlagDeprecatedNotice = "This /goal budget flag is deprecated; Goal now selects its budget automatically." + func ParseGoalCommand(input string) (GoalCommand, bool) { trimmed := strings.TrimSpace(input) if trimmed != "/goal" && !strings.HasPrefix(trimmed, "/goal ") && !strings.HasPrefix(trimmed, "/goal\t") { @@ -531,18 +375,19 @@ func ParseGoalCommand(input string) (GoalCommand, bool) { } args := strings.TrimSpace(trimmed[len("/goal"):]) strict, researchMode, actionArgs := parseLeadingGoalFlags(args) + deprecatedBudgetFlag := researchMode != GoalResearchAuto switch strings.ToLower(actionArgs) { case "", "status": - return GoalCommand{Action: GoalCommandStatus, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandStatus, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true case "clear", "off", "stop", "done": - return GoalCommand{Action: GoalCommandClear, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandClear, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true case "pause": - return GoalCommand{Action: GoalCommandPause, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandPause, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true case "resume": - return GoalCommand{Action: GoalCommandResume, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandResume, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true default: - return GoalCommand{Action: GoalCommandSet, Text: actionArgs, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandSet, Text: actionArgs, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true } } diff --git a/internal/control/input_test.go b/internal/control/input_test.go index ed586c7190..fcea60b20d 100644 --- a/internal/control/input_test.go +++ b/internal/control/input_test.go @@ -844,30 +844,24 @@ func TestGoalAutoResearchTriggersForLongHorizonGoals(t *testing.T) { c.SetGoal("持续排查这个线上卡顿直到根因明确,并验证修复") got := c.Compose("next step?") - for _, want := range []string{ - "AutoResearch protocol", - "", - "task_id:", - "pivot_required:", - "stale_count >= 2", - "durable strategy for this Goal", - } { - if !strings.Contains(got, want) { - t.Fatalf("AutoResearch goal block missing %q:\n%s", want, got) - } + if !strings.Contains(got, "") || strings.Contains(strings.ToLower(got), "autoresearch") { + t.Fatalf("unified research Goal prompt = %q", got) + } + if c.GoalRuntime().TurnsLimit != 40 { + t.Fatalf("research budget = %+v", c.GoalRuntime()) } } func TestGoalAutoResearchCanBeForcedOrDisabled(t *testing.T) { c := New(Options{}) c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn) - if got := c.Compose("start"); !strings.Contains(got, "AutoResearch protocol") { - t.Fatalf("forced research goal should include AutoResearch protocol:\n%s", got) + if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit != 40 { + t.Fatalf("forced research Goal should use hidden 40-turn budget: %q %+v", got, c.GoalRuntime()) } c.SetGoalWithResearchMode("持续排查这个线上卡顿直到根因明确", GoalResearchOff) - if got := c.Compose("start"); strings.Contains(got, "AutoResearch protocol") { - t.Fatalf("simple override should suppress AutoResearch protocol:\n%s", got) + if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit == 40 { + t.Fatalf("simple override should use non-research budget: %q %+v", got, c.GoalRuntime()) } } @@ -876,27 +870,27 @@ func TestGoalCommandPreservesResearchModeFlags(t *testing.T) { if !c.applyGoalCommand("/goal --research fix the typo", "") { t.Fatal("goal command was not parsed") } - if got := c.Compose("start"); !strings.Contains(got, "AutoResearch protocol") { - t.Fatalf("/goal --research should force AutoResearch through command dispatch:\n%s", got) + if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit != 40 { + t.Fatalf("/goal --research should select research budget: %q %+v", got, c.GoalRuntime()) } c = New(Options{}) if !c.applyGoalCommand("/goal --simple 持续排查这个线上卡顿直到根因明确", "") { t.Fatal("goal command was not parsed") } - if got := c.Compose("start"); strings.Contains(got, "AutoResearch protocol") { - t.Fatalf("/goal --simple should suppress AutoResearch through command dispatch:\n%s", got) + if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit == 40 { + t.Fatalf("/goal --simple should suppress research budget: %q %+v", got, c.GoalRuntime()) } } func TestParseGoalCommandResearchFlags(t *testing.T) { cmd, ok := ParseGoalCommand("/goal --research fix the typo") - if !ok || cmd.Action != GoalCommandSet || cmd.Text != "fix the typo" || cmd.ResearchMode != GoalResearchOn { + if !ok || cmd.Action != GoalCommandSet || cmd.Text != "fix the typo" || cmd.ResearchMode != GoalResearchOn || !cmd.DeprecatedBudgetFlag { t.Fatalf("ParseGoalCommand --research = %+v ok=%v", cmd, ok) } cmd, ok = ParseGoalCommand("/goal --simple 持续排查直到根因明确") - if !ok || cmd.Action != GoalCommandSet || cmd.Text != "持续排查直到根因明确" || cmd.ResearchMode != GoalResearchOff { + if !ok || cmd.Action != GoalCommandSet || cmd.Text != "持续排查直到根因明确" || cmd.ResearchMode != GoalResearchOff || !cmd.DeprecatedBudgetFlag { t.Fatalf("ParseGoalCommand --simple = %+v ok=%v", cmd, ok) } } diff --git a/internal/control/planner_gate_test.go b/internal/control/planner_gate_test.go index cacc4948cd..a58e7854ee 100644 --- a/internal/control/planner_gate_test.go +++ b/internal/control/planner_gate_test.go @@ -72,8 +72,8 @@ func TestTaskWarrantsPlanner(t *testing.T) { {"explain how to migrate from v1 to v2", true}, {goalContinueTurn, false}, {"Goal signaled complete but issues remain:\n- the following tasks are still incomplete:\n - Fix login (in_progress)\nFix or use todo_write/complete_step to mark done, then report complete again via update_goal.", false}, - {activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalContinueTurn, false}, - {activeGoalBlock("implement the new caching layer", GoalResearchAuto) + "\n\nimplement the new caching layer across the backend", true}, + {activeGoalBlock("execute plan: fix the parser") + "\n\n" + goalContinueTurn, false}, + {activeGoalBlock("implement the new caching layer") + "\n\nimplement the new caching layer across the backend", true}, } for _, c := range cases { if got := TaskWarrantsPlanner(c.input); got != c.want { @@ -454,7 +454,7 @@ func TestPlannerPolicyUsesPristineMetadataInsteadOfInjectedContext(t *testing.T) ctx := withPlannerTurnMetadata(context.Background(), plannerTurnMetadata{ UserText: "fix typo in README", }) - input := activeGoalBlock("migrate authentication across the backend", GoalResearchAuto) + + input := activeGoalBlock("migrate authentication across the backend") + "\n\n\nhigh risk migration\n\n\nfix typo in README" got := DecidePlannerRoute(ctx, input) if got.Route != agent.PlannerRouteExecutorOnly || got.Reason != plannerReasonAtomicEdit { diff --git a/internal/control/port.go b/internal/control/port.go index 8e6f8c83e8..dd64e30724 100644 --- a/internal/control/port.go +++ b/internal/control/port.go @@ -4,7 +4,6 @@ import ( "context" "reasonix/internal/agent" - "reasonix/internal/autoresearch" "reasonix/internal/billing" "reasonix/internal/checkpoint" "reasonix/internal/command" @@ -101,16 +100,14 @@ type Goals interface { Goal() string GoalStatus() string SetGoal(goal string) + // SetGoalWithResearchMode is retained for deprecated CLI budget flags. The + // mode is translated at the boundary and is not stored in the Goal runtime. SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) ResumeGoal() bool PauseGoal() bool GoalRuntime() GoalRuntimeView GoalStrict(strict bool) ClearGoal() - AutoResearchSummary() (*autoresearch.Summary, bool) - AutoResearchList() ([]autoresearch.Summary, bool) - AutoResearchFindings(limit int) ([]autoresearch.Finding, bool) - RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error ResetPlannerSession() PlanMode() bool SetPlanMode(v bool) diff --git a/internal/control/slash.go b/internal/control/slash.go index 9e414d665e..bf208d44a7 100644 --- a/internal/control/slash.go +++ b/internal/control/slash.go @@ -131,8 +131,6 @@ func goalArgItems(prior []string) []SlashItem { return nil } return []SlashItem{ - {Label: "--research", Insert: "--research ", Hint: "force durable AutoResearch state"}, - {Label: "--simple", Insert: "--simple ", Hint: "force lightweight Goal"}, {Label: "status", Insert: "status", Hint: "show active goal and budget runtime"}, {Label: "pause", Insert: "pause", Hint: "pause the running goal (keeps all state)"}, {Label: "resume", Insert: "resume", Hint: "resume a paused goal (adds one turn slice)"}, diff --git a/internal/control/slash_test.go b/internal/control/slash_test.go index ec55e28883..859a8f6e92 100644 --- a/internal/control/slash_test.go +++ b/internal/control/slash_test.go @@ -145,8 +145,8 @@ func TestSlashArgItems(t *testing.T) { } // /goal items, _ = SlashArgItems("/goal ", data) - if !has(items, "--research") || !has(items, "--simple") || !has(items, "status") || !has(items, "clear") { - t.Errorf("/goal should offer research overrides and management commands; got %v", labelsOf(items)) + if has(items, "--research") || has(items, "--simple") || !has(items, "status") || !has(items, "clear") { + t.Errorf("/goal should hide legacy budget flags and offer management commands; got %v", labelsOf(items)) } if items, _ := SlashArgItems("/goal --research ", data); len(items) != 0 { t.Errorf("/goal after a research flag should accept free-form objectives; got %v", labelsOf(items)) diff --git a/internal/control/turn_orchestrator.go b/internal/control/turn_orchestrator.go index 91cb860b6f..cf96b93199 100644 --- a/internal/control/turn_orchestrator.go +++ b/internal/control/turn_orchestrator.go @@ -5,11 +5,9 @@ import ( "encoding/json" "errors" "fmt" - "strings" "time" "reasonix/internal/agent" - "reasonix/internal/autoresearch" "reasonix/internal/event" "reasonix/internal/evidence" "reasonix/internal/jobs" @@ -207,8 +205,6 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest false, continuation.goal, GoalStatusRunning, - continuation.researchMode, - continuation.autoResearchTaskID, ) } else { input = c.compose(turn.input, turn.raw, !turn.synthetic) @@ -259,14 +255,6 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }() } c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw)) - var autoResearchTaskID string - if continuation != nil { - autoResearchTaskID = continuation.autoResearchTaskID - } else { - autoResearchTaskID = c.goals.currentAutoResearchTaskID() - } - autoResearchAcceptedBefore := c.autoResearch.acceptedEvidenceIDs(autoResearchTaskID) - c.autoResearch.heartbeat(autoResearchTaskID, autoresearch.HeartbeatStartingTurn, "") if continuation != nil { ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ ID: continuation.scopeID, @@ -302,13 +290,8 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest err = c.runner.Run(ctx, modelInput) c.persistGoalDeliveryCheckpoint() if err == nil { - assistantText := lastAssistantText(c.History()) - c.autoResearch.recordEvidenceFromAssistant(autoResearchTaskID, assistantText) - c.autoResearch.recordTurnProgress(autoResearchTaskID, autoResearchAcceptedBefore, assistantText) - c.autoResearch.heartbeat(autoResearchTaskID, autoresearch.HeartbeatTurnDone, "") c.clearInFlightTurn() } else { - c.autoResearch.heartbeat(autoResearchTaskID, autoresearch.HeartbeatWarning, err.Error()) // When the user explicitly cancels, keep the real prompt and any fully // paired tool work. Partial reasoning/output remains durable for display // but is marked local-only, and a bounded recovery summary is folded into @@ -515,17 +498,6 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon } else if c.executor != nil { readiness = c.executor.ReadinessResult() } - if arReadiness := c.autoResearchReadinessFailure(); arReadiness != "" { - readiness.Ready = false - readiness.Missing = append(readiness.Missing, "autoresearch") - if readiness.Reason != "" { - readiness.Reason += "\n" + arReadiness - } else { - readiness.Reason = arReadiness - } - } - autoResearchTaskID := c.goals.currentAutoResearchTaskID() - // The validated update_goal report for this turn, if any. var report *goalTurnReport if recorder != nil { @@ -567,7 +539,6 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon }) c.persistGoalState(res.path, res.data, res.ok) if res.notice != "" { - c.finalizeAutoResearchTask(autoResearchTaskID, res.notice) c.notice(res.notice) } if res.notice == goalCompleteNotice && c.executor != nil { @@ -576,32 +547,6 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon return res } -func (c *Controller) finalizeAutoResearchTask(taskID, notice string) { - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return - } - switch { - case notice == goalCompleteNotice: - status := autoresearch.StatusComplete - if err := c.autoResearch.updateProgress(taskID, autoresearch.ProgressPatch{Status: &status}); err != nil { - c.noticeDetail("AutoResearch status update failed.", "autoresearch task completion update failed: "+err.Error()) - return - } - c.notice("autoresearch task completed: " + taskID) - case strings.HasPrefix(notice, "goal blocked: ") || notice == "goal continuation limit reached": - status := autoresearch.StatusBlocked - reason := strings.TrimPrefix(notice, "goal blocked: ") - if reason == "" { - reason = notice - } - if err := c.autoResearch.updateProgress(taskID, autoresearch.ProgressPatch{Status: &status, BlockedReason: &reason}); err != nil { - c.noticeDetail("AutoResearch status update failed.", "autoresearch task blocked update failed: "+err.Error()) - return - } - c.noticeDetail("AutoResearch task marked blocked.", "autoresearch task blocked: "+taskID+"\nreason: "+reason) - } -} - // completeRemainingGoalTodos force-completes any remaining incomplete canonical // todos when the goal FSM transitions to completed and emits a synthetic // todo_write event so the frontend panel reflects the final state. Handles the diff --git a/internal/goaleval/evaluator.go b/internal/goaleval/evaluator.go index a962b2f142..d4c131d1d3 100644 --- a/internal/goaleval/evaluator.go +++ b/internal/goaleval/evaluator.go @@ -60,13 +60,12 @@ const ( // MaxEvidenceBytes caps the serialized evidence JSON. MaxEvidenceBytes = 6 * 1024 // Field budgets keep the total request inside boundedllm.DefaultMaxTotalBytes. - MaxGoalBytes = 600 - MaxAssistantFinal = 1200 - MaxTodoSummary = 600 - MaxAutoResearchBytes = 600 - MaxTurnStatusBytes = 300 - MaxLastReasonBytes = 200 - MaxReasonBytes = 500 + MaxGoalBytes = 600 + MaxAssistantFinal = 1200 + MaxTodoSummary = 600 + MaxTurnStatusBytes = 300 + MaxLastReasonBytes = 200 + MaxReasonBytes = 500 ) // Outcome is the evaluator's structured verdict disposition. @@ -95,8 +94,6 @@ type GoalEvidence struct { AssistantFinal string // TodoSummary is a host-built todo/readiness summary. TodoSummary string - // AutoResearchSummary is the AutoResearch success-criteria summary. - AutoResearchSummary string // TurnStatus describes turn/budget state. TurnStatus string // LastContinuationReason is the previous continuation's recorded reason. @@ -184,13 +181,12 @@ func (s *Session) Evaluate(ctx context.Context, evidence GoalEvidence) (Verdict, } type evidencePayload struct { - Notice string `json:"notice"` - GoalContract string `json:"goal_contract,omitempty"` - AssistantFinal string `json:"assistant_final,omitempty"` - TodoSummary string `json:"todo_summary,omitempty"` - AutoResearchSummary string `json:"autoresearch_summary,omitempty"` - TurnStatus string `json:"turn_status,omitempty"` - LastReason string `json:"last_reason,omitempty"` + Notice string `json:"notice"` + GoalContract string `json:"goal_contract,omitempty"` + AssistantFinal string `json:"assistant_final,omitempty"` + TodoSummary string `json:"todo_summary,omitempty"` + TurnStatus string `json:"turn_status,omitempty"` + LastReason string `json:"last_reason,omitempty"` } // buildEvidence budgets every field before marshaling; the serialized payload @@ -208,9 +204,6 @@ func buildEvidence(evidence GoalEvidence) (string, error) { if s := clip(strings.TrimSpace(evidence.TodoSummary), MaxTodoSummary); s != "" { payload.TodoSummary = s } - if s := clip(strings.TrimSpace(evidence.AutoResearchSummary), MaxAutoResearchBytes); s != "" { - payload.AutoResearchSummary = s - } if s := clip(strings.TrimSpace(evidence.TurnStatus), MaxTurnStatusBytes); s != "" { payload.TurnStatus = s } diff --git a/internal/taskintent/boundary_test.go b/internal/taskintent/boundary_test.go index e573fd693b..a7cdad3642 100644 --- a/internal/taskintent/boundary_test.go +++ b/internal/taskintent/boundary_test.go @@ -19,14 +19,17 @@ var allowedExports = map[string]bool{ "ObservableRead": true, "Mutation": true, "PersistentAction": true, "Classify": true, "NeedsEvidence": true, "NeedsMutation": true, "NeedsPersistentAction": true, "GoalNeedsWriteBudget": true, + "BudgetClassSimple": true, "BudgetClassWrite": true, "BudgetClassResearch": true, + "BudgetTurns": true, "ClassifyGoalBudget": true, } // lineBudgets caps the heuristic files: vocabulary growth must displace // something or justify a deliberate budget bump in review. var lineBudgets = map[string]int{ - "intent.go": 620, - "heuristic.go": 180, - "goal_budget.go": 140, + "intent.go": 620, + "heuristic.go": 180, + "goal_budget.go": 140, + "goal_research_budget.go": 120, } func TestExportSurfaceIsFrozen(t *testing.T) { diff --git a/internal/taskintent/doc.go b/internal/taskintent/doc.go index 252cbb454a..360a605496 100644 --- a/internal/taskintent/doc.go +++ b/internal/taskintent/doc.go @@ -1,9 +1,9 @@ -// Package taskintent answers exactly one question from task text: is this -// obviously chat, a read, a mutation, or a persistent action. That is its -// whole charter. It must not grow into complexity, risk, planner depth, -// verification depth, completion, budget, or tool-surface decisions — -// those belong to runtime evidence (see internal/taskcontract), where a -// receipt outranks any keyword. +// Package taskintent answers classification questions from task text: is this +// obviously chat, a read, a mutation, or a persistent action, and which Goal +// turn-budget class (simple/write/research) the objective should start on. +// It must not grow into complexity, risk, planner depth, verification depth, +// completion, or tool-surface decisions — those belong to runtime evidence +// (see internal/taskcontract), where a receipt outranks any keyword. // // The vocabulary is a liability, not an asset: every added keyword, negation // rule, or language case moves this package toward an unowned NLP parser. diff --git a/internal/taskintent/goal_budget_test.go b/internal/taskintent/goal_budget_test.go index f779e6323d..9211a1b8f1 100644 --- a/internal/taskintent/goal_budget_test.go +++ b/internal/taskintent/goal_budget_test.go @@ -75,6 +75,21 @@ func TestGoalBareFaultDoesNotChangeDeliveryClassification(t *testing.T) { } } +func TestClassifyGoalBudgetMatrix(t *testing.T) { + if got := ClassifyGoalBudget("hello"); got != BudgetClassSimple { + t.Fatalf("simple = %q", got) + } + if got := ClassifyGoalBudget("fix the crash in a.go"); got != BudgetClassWrite { + t.Fatalf("write = %q", got) + } + if got := ClassifyGoalBudget("持续排查这个线上卡顿直到根因明确,并验证修复"); got != BudgetClassResearch { + t.Fatalf("research = %q", got) + } + if BudgetTurns(BudgetClassSimple) != 10 || BudgetTurns(BudgetClassWrite) != 20 || BudgetTurns(BudgetClassResearch) != 40 { + t.Fatalf("quotas simple=%d write=%d research=%d", BudgetTurns(BudgetClassSimple), BudgetTurns(BudgetClassWrite), BudgetTurns(BudgetClassResearch)) + } +} + func TestTaskFaultSignalsSharedWithGoalClassification(t *testing.T) { // Shared fault list must keep task recognition and Goal classification // aligned for bare problem statements. diff --git a/internal/taskintent/goal_research_budget.go b/internal/taskintent/goal_research_budget.go new file mode 100644 index 0000000000..5caaee37ce --- /dev/null +++ b/internal/taskintent/goal_research_budget.go @@ -0,0 +1,89 @@ +package taskintent + +import "strings" + +// Goal turn-budget classes. Quotas are fixed; classes never gate permissions. +const ( + BudgetClassSimple = "simple" + BudgetClassWrite = "write" + BudgetClassResearch = "research" +) + +// BudgetTurns returns the default turn quota for a Goal budget class. +func BudgetTurns(class string) int { + switch class { + case BudgetClassResearch: + return 40 + case BudgetClassWrite: + return 20 + default: + return 10 + } +} + +// ClassifyGoalBudget selects simple/write/research from goal text alone. +// Legacy CLI flags and sidecars apply on/off overrides in the control package. +func ClassifyGoalBudget(goal string) string { + if needsResearchBudget(goal) { + return BudgetClassResearch + } + if GoalNeedsWriteBudget(goal) { + return BudgetClassWrite + } + return BudgetClassSimple +} + +func needsResearchBudget(goal string) bool { + trimmed := strings.TrimSpace(goal) + if trimmed == "" { + return false + } + lower := strings.ToLower(trimmed) + if strings.Contains(lower, ".reasonix/autoresearch/") { + return true + } + for _, kw := range researchBudgetStrongKeywords { + if strings.Contains(lower, kw) { + return true + } + } + return researchBudgetPhaseCount(lower) >= 4 +} + +func researchBudgetPhaseCount(lower string) int { + categories := 0 + for _, group := range researchBudgetPhaseKeywords { + if containsAnyGoalKeyword(lower, group) { + categories++ + } + } + return categories +} + +func containsAnyGoalKeyword(s string, needles []string) bool { + for _, needle := range needles { + if strings.Contains(s, needle) { + return true + } + } + return false +} + +var researchBudgetStrongKeywords = []string{ + "持续", "长期", "彻底", "直到根因", "根因明确", "多轮", + "不要原地打转", "别原地打转", "完整方案", "完整做成方案", + "跑实验", "反复验证", "长期优化", "系统性研究", "持续研究", + "持续排查", "持续推进", "长期跑", + "long-horizon", "long horizon", "long-running", "keep researching", + "keep working", "root cause", "until the root cause", "do not spin", + "don't spin", "thoroughly", "systematically", +} + +var researchBudgetPhaseKeywords = [][]string{ + {"研究", "调研", "排查", "分析", "定位", "诊断", "research", "investigate", "diagnose", "analyze", "analysis"}, + {"实现", "修复", "改造", "开发", "重构", "implement", "build", "fix", "refactor"}, + {"验证", "测试", "复现", "联调", "benchmark", "verify", "validate", "test", "reproduce"}, + {"优化", "完善", "提升", "收敛", "optimize", "improve", "tune", "polish"}, + {"文档", "方案", "说明", "总结", "document", "docs", "writeup", "plan"}, + {"发布", "上线", "提交", "pull request", "publish", "ship", "deploy"}, +} diff --git a/internal/tool/goal.go b/internal/tool/goal.go index f931d05163..1ad0ea538a 100644 --- a/internal/tool/goal.go +++ b/internal/tool/goal.go @@ -27,6 +27,11 @@ type GoalTurnRecorder interface { type goalTurnRecorderKey struct{} +// noGoalTurnRecorder shadows an ancestor recorder while preserving the rest +// of the context chain. Child agents must not report disposition for the +// parent's goal turn. +type noGoalTurnRecorder struct{} + // WithGoalTurnRecorder stamps ctx with the per-turn goal recorder so the // update_goal tool can reach it from inside the run loop. func WithGoalTurnRecorder(ctx context.Context, r GoalTurnRecorder) context.Context { @@ -36,6 +41,13 @@ func WithGoalTurnRecorder(ctx context.Context, r GoalTurnRecorder) context.Conte return context.WithValue(ctx, goalTurnRecorderKey{}, r) } +// WithoutGoalTurnRecorder returns a child context that cannot access a goal +// recorder inherited from its parent. Other values and cancellation continue +// to flow through the context normally. +func WithoutGoalTurnRecorder(ctx context.Context) context.Context { + return context.WithValue(ctx, goalTurnRecorderKey{}, noGoalTurnRecorder{}) +} + // GoalTurnRecorderFromContext returns the active goal turn's recorder, if any. func GoalTurnRecorderFromContext(ctx context.Context) (GoalTurnRecorder, bool) { if ctx == nil { diff --git a/internal/tool/goal_test.go b/internal/tool/goal_test.go new file mode 100644 index 0000000000..e0bdbaf9de --- /dev/null +++ b/internal/tool/goal_test.go @@ -0,0 +1,30 @@ +package tool + +import ( + "context" + "testing" +) + +type goalTestRecorder struct{} + +func (goalTestRecorder) RecordGoalReport(GoalReport) (string, error) { return "recorded", nil } + +type preservedGoalContextKey struct{} + +func TestWithoutGoalTurnRecorderShadowsOnlyRecorder(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + parent = context.WithValue(parent, preservedGoalContextKey{}, "preserved") + parent = WithGoalTurnRecorder(parent, goalTestRecorder{}) + + child := WithoutGoalTurnRecorder(parent) + if _, ok := GoalTurnRecorderFromContext(child); ok { + t.Fatal("child context inherited the parent goal recorder") + } + if got := child.Value(preservedGoalContextKey{}); got != "preserved" { + t.Fatalf("unrelated context value = %v, want preserved", got) + } + cancel() + if child.Err() != context.Canceled { + t.Fatalf("child cancellation = %v, want context.Canceled", child.Err()) + } +} diff --git a/scripts/check-cache-impact.sh b/scripts/check-cache-impact.sh index b3866f3f1a..90ec4115b0 100755 --- a/scripts/check-cache-impact.sh +++ b/scripts/check-cache-impact.sh @@ -64,15 +64,22 @@ for file in "${changed_files[@]:-}"; do internal/agent/ask.go|\ internal/agent/cache*|\ internal/agent/compact*|\ + internal/agent/goal_display.go|\ internal/agent/parallel_tasks.go|\ + internal/agent/planner_registry.go|\ internal/agent/prune*|\ internal/agent/subagent_registry*|\ + internal/agent/subagent_identity.go|\ internal/agent/task.go|\ internal/boot/*|\ internal/command/slashtool.go|\ internal/config/config.go|\ internal/config/system_prompt*|\ + internal/control/goal.go|\ + internal/control/input.go|\ + internal/control/turn_orchestrator.go|\ internal/environment/*|\ + internal/goaleval/*|\ internal/history/tool.go|\ internal/installsource/*|\ internal/lsp/tool.go|\ @@ -81,6 +88,7 @@ for file in "${changed_files[@]:-}"; do internal/plugin/*|\ internal/provider/*|\ internal/skill/*|\ + internal/taskintent/*|\ internal/tool/*|\ scripts/cache-guard.sh|\ scripts/check-cache-impact.sh) diff --git a/site/src/pages/docs.astro b/site/src/pages/docs.astro index ec2ea0d9d8..7d6bc636b9 100644 --- a/site/src/pages/docs.astro +++ b/site/src/pages/docs.astro @@ -301,7 +301,7 @@ reasonix upgrade

Mouse capture is on by default so Reasonix can handle transcript selection, wheel scroll, and the scrollbar. Turn it off with /mouse, or start with REASONIX_DISABLE_MOUSE=1, when you prefer the terminal's own selection behavior.默认会开启鼠标接管,用于对话选中、滚轮滚动和滚动条。需要终端自己的选中行为时,用 /mouse 关闭;也可以用 REASONIX_DISABLE_MOUSE=1 默认关闭。

In a local session, releasing an in-app text selection copies through the native system clipboard and shows success only after the write completes. SSH falls back to a clearly labelled OSC 52 request. Text paste remains your terminal's bracketed-paste shortcut, such as Cmd+V on macOS. Image paste is separate: use Ctrl+V on macOS/Linux, Alt+V on Windows, or /paste-image; the footer shows Pasting image… while the attachment is prepared.本地会话中,应用内文本选区会写入系统剪贴板,只有写入完成后才提示成功;SSH 会回退到明确标记的 OSC 52 请求。文本继续使用终端原生 bracketed-paste 快捷键,例如 macOS 的 Cmd+V。图片粘贴使用独立入口:macOS/Linux 按 Ctrl+V,Windows 按 Alt+V,或运行 /paste-image;附件准备期间底栏显示“正在粘贴图片…”。

/branch [name] forks the current conversation tip, /switch <id|name> loads another branch, and /clear confirms before discarding unsaved context. Custom commands are Markdown files under .reasonix/commands/ or ~/.reasonix/commands/./branch [name] 从当前会话尖端分叉,/switch <id|name> 加载另一条分支,/clear 会确认后丢弃未保存上下文。自定义命令是 .reasonix/commands/~/.reasonix/commands/ 下的 Markdown 文件。

-

/goal is for long-running objectives. Ordinary chat never changes mode automatically. Goals run under a per-class budget (simple 10 turns / 200k tokens, write 20 turns / 400k tokens, AutoResearch 40 turns / 800k tokens; 4 turns without host-verifiable progress pause) — /goal status shows the runtime, /goal pause suspends, /goal resume continues (budget pauses add one more slice). Each goal turn ends with a structured update_goal report (continue/complete/blocked) that the host validates against Delivery readiness; without a report, an independent bounded evaluator judges the turn once and any failure pauses safely. Clearly long-horizon work can use the AutoResearch strategy, which keeps state under .reasonix/autoresearch/..., tracks evidence, and forces a new direction when progress stalls. Use /goal --research <objective> to force it or /goal --simple <objective> to keep the lightweight path. AutoResearch is a Goal strategy, not a separate app-start daemon or standalone built-in skill./goal 用于长目标。普通聊天不会自动切换模式。Goal 按类别运行在预算内(简单 10 轮 / 20 万 token,写入型 20 轮 / 40 万 token,AutoResearch 40 轮 / 80 万 token;连续 4 轮无宿主可验证进展会暂停)——/goal status 显示运行摘要,/goal pause 暂停,/goal resume 继续(预算型暂停追加一档额度)。每个目标 turn 结束时通过结构化的 update_goal 报告(continue/complete/blocked),宿主会用 Delivery readiness 校验;没有报告时由独立有界 evaluator 判定一次,任何故障都会安全暂停。明显长周期的任务可以启用 AutoResearch 策略,在 .reasonix/autoresearch/... 下保存状态、记录证据,并在进展停滞时强制换方向。用 /goal --research <目标> 强制启用,或用 /goal --simple <目标> 保持轻量路径。AutoResearch 是 Goal 的策略,不是 App 启动即运行的 daemon,也不是独立内置 skill。

+

/goal is for long-running objectives. Ordinary chat never changes mode automatically. Goal selects a simple (10), write (20), or research (40) turn budget and pauses after four turns without host-verifiable progress. /goal status shows the runtime, /goal pause suspends, and /goal resume continues. Every class uses the same Goal state machine, structured update_goal reports, host receipts, Delivery readiness, and bounded evaluator. Legacy research archives are read-only and new Goals never create them./goal 用于长目标。普通聊天不会自动切换模式。Goal 自动选择简单(10)、写入(20)或研究(40)轮预算,连续 4 轮无宿主可验证进展会暂停。/goal status 显示运行摘要,/goal pause 暂停,/goal resume 继续。所有预算类别共用同一个 Goal 状态机、结构化 update_goal、宿主 receipt、Delivery readiness 与有界 evaluator。旧研究归档保持只读,新 Goal 不再创建这些目录。

Use @path to inject files or directories, and @server:uri for MCP resources. Plan Mode is an explicit user choice: select it in the desktop collaboration control or cycle to it with Shift+Tab in the CLI. reasonix config reasoning-language auto|zh|en updates the user default from scripts; --local remains available for settings that support project-local overrides.@path 注入文件或目录,用 @server:uri 引入 MCP resource。计划模式始终由用户显式选择:桌面端在协作方式中选择,CLI 用 Shift+Tab 切换。脚本中可用 reasonix config reasoning-language auto|zh|en 更新用户级默认值;--local 仍可用于支持项目级覆盖的设置。

Built-in documentation search内置文档检索