From 18b8a3247d475efe832367ca626272ea068a2722 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 31 Jul 2026 22:57:54 +0300 Subject: [PATCH] fix: allow research runs to restart after stop; improve FTS search, agent limits, and timeouts - AgentTree.restart() revives a stopped run in the same session: resets the root, archives old branches, and restores spawnability (stop no longer cancels the root). - ResearchLoop.start() restarts from stopped/completed and resets iteration counters; setGoal is allowed in terminal states; supervisor 'start' revives the tree when the root is not spawnable. - Cancelled/failed/archived children no longer count toward the per-agent child limit and archived agents are ignored by the duplicate-task guard. - cancel() no longer overwrites an already recorded agent result. - Add agent_timeout_seconds (default 1800) so hung agent sessions fail instead of blocking wait() forever. - FTS search now prefix-matches tokens (tolerates inflections) and safely handles punctuation/FTS5 metacharacters instead of returning false negatives or throwing. - Add tests for restart semantics, child limits, result preservation, agent timeouts, and FTS morphology/special characters (38/38 passing). --- CHANGELOG.md | 16 +++++++++++ src/agent-tree.ts | 57 +++++++++++++++++++++++++++++-------- src/config.ts | 1 + src/research-loop.ts | 12 ++++++-- src/research-memory.ts | 8 +++++- src/supervisor.ts | 2 +- src/types.ts | 1 + tests/agent-tree.test.ts | 4 +++ tests/memory.test.ts | 2 ++ tests/research-loop.test.ts | 3 ++ 10 files changed, 90 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee7c0a8..c316ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +- Fixed the research loop and agent tree getting permanently stuck after + `research_control stop`: `start` now restarts a stopped/completed run in the + same session (`AgentTree.restart` revives the run, archives old branches, and + resets the root), `ResearchLoop.start` resets iteration counters, and goal + changes are allowed after stop/completion. +- Cancelled/failed/archived children no longer count toward the per-agent child + limit, and archived agents are ignored by the duplicate-task guard. +- `cancel()` no longer overwrites an already recorded agent result. +- Added a per-agent execution timeout (`agent_timeout_seconds`, default 1800) + so a hung agent session fails instead of blocking `wait` forever. +- Full-text search now prefix-matches tokens (tolerates Russian/English + inflections without stemming) and safely handles punctuation and FTS5 + metacharacters instead of throwing or returning false negatives. + ## 0.1.1 — 2026-07-31 - Added GitHub CI, CodeQL, Dependabot, contribution/security templates, and the diff --git a/src/agent-tree.ts b/src/agent-tree.ts index 7be858c..d898c16 100644 --- a/src/agent-tree.ts +++ b/src/agent-tree.ts @@ -62,10 +62,11 @@ export class AgentTree { if (!this.limits.allow_recursive_spawning && parent.depth > 0) throw new AgentTreeError("Recursive spawning is disabled"); if (parent.depth + 1 > this.limits.max_depth) throw new AgentTreeError(`Maximum depth ${this.limits.max_depth} exceeded`); const parentSpecLimit = readAgentSpec(parent.specPath).max_children; - if (parent.children.length >= Math.min(this.limits.max_children_per_agent, parentSpecLimit)) throw new AgentTreeError("Parent child limit exceeded"); + const liveChildren = parent.children.filter((childId) => !["cancelled", "failed", "archived"].includes(this.manifest.agents[childId]?.status ?? "")).length; + if (liveChildren >= Math.min(this.limits.max_children_per_agent, parentSpecLimit)) throw new AgentTreeError("Parent child limit exceeded"); if (this.activeCount() >= this.limits.max_active_agents) throw new AgentTreeError("Active agent limit exceeded"); if (Object.keys(this.manifest.agents).length >= this.limits.max_total_agents_per_run) throw new AgentTreeError("Total agent limit exceeded"); - const duplicate = Object.values(this.manifest.agents).find((candidate) => candidate.taskFingerprint === taskFingerprint(request.task) && candidate.status !== "cancelled"); + const duplicate = Object.values(this.manifest.agents).find((candidate) => candidate.taskFingerprint === taskFingerprint(request.task) && !["cancelled", "archived"].includes(candidate.status)); const validReplication = request.replicationOf && request.independentContext === true; if (duplicate && !validReplication) throw new AgentTreeError(`Duplicate task already owned by ${duplicate.id}; mark an independent replication explicitly`); if (request.replicationOf && !this.manifest.agents[request.replicationOf]) throw new AgentTreeError(`Replication target not found: ${request.replicationOf}`); @@ -94,14 +95,26 @@ export class AgentTree { if (runtime.sessionFile) record.sessionFile = runtime.sessionFile; record.status = "running"; record.startedAt = new Date().toISOString(); this.persist(); const prompt = `Execute your specification at ${record.specPath}. Your agent id is ${id}. Return a concise evidence-backed result. Use spawn_agent when a genuinely specialized subtask merits recursion.`; - const execution = runtime.start(prompt).then((result) => { - if (record.status === "interrupted") return result; - if (record.status === "cancelled") return record.result ?? ({ status: "cancelled", summary: "Cancelled", completedAt: record.finishedAt ?? new Date().toISOString() } satisfies AgentResult); - record.result = result; record.status = result.status; record.finishedAt = result.completedAt; this.persist(); return result; - }).catch((error: unknown) => { - record.status = "failed"; record.error = error instanceof Error ? error.message : String(error); record.finishedAt = new Date().toISOString(); this.persist(); - return { status: "failed", summary: record.error, completedAt: record.finishedAt } satisfies AgentResult; - }).finally(() => { runtime.dispose(); this.runtimes.delete(id); this.executions.delete(id); }); + const timeoutSeconds = this.limits.agent_timeout_seconds ?? 1800; + const execution = new Promise((resolveExecution) => { + let settled = false; + const finish = (result: AgentResult) => { if (settled) return; settled = true; clearTimeout(timer); record.finishedAt = result.completedAt; this.persist(); resolveExecution(result); }; + const timer = setTimeout(() => { + record.status = "failed"; record.error = `Agent timed out after ${timeoutSeconds}s`; record.finishedAt = new Date().toISOString(); + void runtime.cancel().catch(() => undefined); + finish({ status: "failed", summary: `Agent timed out after ${timeoutSeconds}s`, completedAt: record.finishedAt }); + }, timeoutSeconds * 1000); + runtime.start(prompt).then((result) => { + if (settled) return; + if (record.status === "interrupted") { finish(result); return; } + if (record.status === "cancelled") { finish(record.result ?? { status: "cancelled", summary: "Cancelled", completedAt: record.finishedAt ?? new Date().toISOString() }); return; } + record.result = result; record.status = result.status; finish(result); + }).catch((error: unknown) => { + if (settled) return; + record.status = "failed"; record.error = error instanceof Error ? error.message : String(error); + finish({ status: "failed", summary: record.error, completedAt: new Date().toISOString() }); + }).finally(() => { clearTimeout(timer); runtime.dispose(); this.runtimes.delete(id); this.executions.delete(id); }); + }); this.executions.set(id, execution); return execution; } @@ -113,7 +126,7 @@ export class AgentTree { async waitMany(ids: string[]): Promise { return Promise.all(ids.map((id) => this.wait(id))); } async cancel(id: string): Promise { - const record = this.mutable(id); await this.runtimes.get(id)?.cancel(); record.status = "cancelled"; record.finishedAt = new Date().toISOString(); record.result = { status: "cancelled", summary: "Cancelled by branch control", completedAt: record.finishedAt }; this.persist(); + const record = this.mutable(id); await this.runtimes.get(id)?.cancel(); record.status = "cancelled"; record.finishedAt = new Date().toISOString(); record.result ??= { status: "cancelled", summary: "Cancelled by branch control", completedAt: record.finishedAt }; this.persist(); } async cancelBranch(id: string): Promise { const record = this.mutable(id); await Promise.all(record.children.map((child) => this.cancelBranch(child))); await this.cancel(id); } collectResult(id: string): AgentResult | undefined { return this.mutable(id).result ? structuredClone(this.mutable(id).result) : undefined; } @@ -121,7 +134,27 @@ export class AgentTree { pause(): void { this.manifest.status = "paused"; this.persist(); } resume(): void { this.manifest.status = "active"; this.persist(); } setGoal(goal: string): void { this.manifest.goal = goal.trim(); const root = this.mutable(this.rootId); if (root.children.length === 0) { root.task = goal.trim(); root.taskFingerprint = taskFingerprint(goal); } this.persist(); } - async stop(): Promise { this.manifest.status = "stopped"; await this.cancelBranch(this.rootId); this.persist(); } + async stop(): Promise { + this.manifest.status = "stopped"; + const root = this.mutable(this.rootId); + await Promise.all(root.children.map((child) => this.cancelBranch(child))); + this.persist(); + } + /** Start a fresh run in the same session: revive the manifest, archive old branches, and reset the root so new agents can be spawned. */ + restart(goal: string): void { + const trimmed = goal.trim(); + this.manifest.status = "active"; this.manifest.goal = trimmed; + const root = this.mutable(this.rootId); + for (const childId of root.children) { + const child = this.mutable(childId); + if (child.status === "running" || child.status === "waiting") { void this.runtimes.get(childId)?.cancel().catch(() => undefined); child.status = "interrupted"; } + child.status = "archived"; + } + root.children = []; + root.status = "created"; root.task = trimmed; root.taskFingerprint = taskFingerprint(trimmed); + delete root.result; delete root.error; delete root.startedAt; delete root.finishedAt; delete root.sessionFile; + this.persist(); + } async shutdown(): Promise { for (const [id, runtime] of this.runtimes) { await runtime.cancel().catch(() => undefined); const record = this.mutable(id); if (record.status === "running" || record.status === "waiting") record.status = "interrupted"; runtime.dispose(); } this.runtimes.clear(); this.executions.clear(); this.persist(); diff --git a/src/config.ts b/src/config.ts index 76c6e61..324a6d6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ export const DEFAULT_CONFIG: HypothesisMachineConfig = { max_total_agents_per_run: 200, max_iterations_without_progress: 3, max_research_iterations: 12, + agent_timeout_seconds: 1800, allow_recursive_spawning: true, searxng_url: "http://127.0.0.1:8888", firecrawl_url: "http://127.0.0.1:3002", diff --git a/src/research-loop.ts b/src/research-loop.ts index 2b2e9f2..eb0997a 100644 --- a/src/research-loop.ts +++ b/src/research-loop.ts @@ -14,8 +14,16 @@ export class ResearchLoop { this.state = (() => { try { return JSON.parse(readFileSync(this.path, "utf8")) as ResearchLoopState; } catch { const now = new Date().toISOString(); return { runId, goal, status: "planning", iteration: 0, noProgressIterations: 0, createdAt: now, updatedAt: now, reports: [] }; } })(); this.persist(); } snapshot(): ResearchLoopState { return structuredClone(this.state); } - setGoal(goal: string): void { if (this.state.iteration > 0 && this.state.goal !== goal.trim()) throw new Error("Start a new research run to change the goal after iterations have been recorded"); this.state.goal = goal.trim(); this.persist(); } - start(): void { if (["completed", "stopped"].includes(this.state.status)) throw new Error(`Research loop is ${this.state.status}`); this.state.status = "running"; this.persist(); } + setGoal(goal: string): void { + const terminal = ["stopped", "completed"].includes(this.state.status); + if (!terminal && this.state.iteration > 0 && this.state.goal !== goal.trim()) throw new Error("Start a new research run to change the goal after iterations have been recorded"); + this.state.goal = goal.trim(); this.persist(); + } + start(): void { + if (this.state.status === "running") return; + if (["stopped", "completed"].includes(this.state.status)) { this.state.iteration = 0; this.state.noProgressIterations = 0; this.state.reports = []; delete this.state.stopReason; } + this.state.status = "running"; this.persist(); + } pause(): void { if (this.state.status === "running") { this.state.status = "paused"; this.persist(); } } resume(): void { if (this.state.status !== "paused") throw new Error("Only a paused loop can resume"); this.state.status = "running"; this.persist(); } stop(reason = "Stopped by user"): void { this.state.status = "stopped"; this.state.stopReason = reason; this.persist(); } diff --git a/src/research-memory.ts b/src/research-memory.ts index 1868a48..05e30ca 100644 --- a/src/research-memory.ts +++ b/src/research-memory.ts @@ -83,9 +83,15 @@ export class ResearchMemory { search(query: string, limit = 10): MemorySearchResult[] { const safeLimit = Math.max(1, Math.min(50, limit)); - const expression = query.replace(/[\"']/g, " ").trim().split(/\s+/).filter(Boolean).map((word) => `\"${word}\"`).join(" OR "); if (!expression) return []; + const expression = this.ftsExpression(query); if (!expression) return []; return this.database().prepare("SELECT d.id,d.kind,d.status,d.title,d.path,snippet(documents_fts,2,'[',']',' … ',24) snippet,bm25(documents_fts) rank FROM documents_fts JOIN documents d ON d.id=documents_fts.id WHERE documents_fts MATCH ? ORDER BY rank LIMIT ?").all(expression, safeLimit) as unknown as MemorySearchResult[]; } + /** Build an FTS5 MATCH expression: split on punctuation, drop operators, prefix long tokens to tolerate inflections (no stemming in unicode61). */ + private ftsExpression(query: string): string { + const tokens = query.replace(/["'`]/g, " ").split(/[^\p{L}\p{N}_]+/u).filter(Boolean).filter((word) => !/^(and|or|not|near)$/i.test(word)); + if (!tokens.length) return ""; + return tokens.map((word) => (word.length >= 4 ? `\"${word}\"*` : `\"${word}\"`)).join(" OR "); + } list(kind?: string): Array> { return this.database().prepare(kind ? "SELECT * FROM documents WHERE kind=? ORDER BY updated_at DESC" : "SELECT * FROM documents ORDER BY updated_at DESC").all(...(kind ? [kind] : [])) as Array>; } close(): void { this.db?.close(); this.db = undefined; } } diff --git a/src/supervisor.ts b/src/supervisor.ts index 213ebd9..ff5c543 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -49,7 +49,7 @@ export class SupervisorIntegration { name: "research_control", label: "Research loop control", description: "Start, record, inspect, pause, resume, or stop the explicit research state machine. Record one report per completed iteration; coded stop conditions prevent infinite prompt loops.", promptSnippet: "Control the bounded autonomous research loop", parameters: Type.Object({ action: StringEnum(["start", "status", "record_iteration", "pause", "resume", "stop"] as const), goal: Type.Optional(Type.String()), report: Type.Optional(Type.Object({ goal: Type.String(), tasks: Type.Array(Type.String()), activeAgents: Type.Array(Type.String()), expectedOutput: Type.String(), state: Type.String(), newFindings: Type.Integer({ minimum: 0 }), closedQuestions: Type.Integer({ minimum: 0 }), contradictions: Type.Integer({ minimum: 0 }), reason: Type.String(), goalAchieved: Type.Optional(Type.Boolean()), criticalMethodError: Type.Optional(Type.Boolean()), onlyExternalQuestions: Type.Optional(Type.Boolean()), userDecisionRequired: Type.Optional(Type.Boolean()) })) }), - execute: async (_id, params) => { const { tree, loop } = this.required(); if (params.action === "start") { if (!params.goal?.trim()) throw new Error("goal is required"); tree.setGoal(params.goal); loop.setGoal(params.goal); loop.start(); return toolText(loop.snapshot()); } if (params.action === "status") return toolText(loop.snapshot()); if (params.action === "pause") { loop.pause(); tree.pause(); } else if (params.action === "resume") { loop.resume(); tree.resume(); } else if (params.action === "stop") { loop.stop(); await tree.stop(); } else { if (!params.report) throw new Error("report is required"); return toolText(loop.record(params.report)); } return toolText(loop.snapshot()); }, + execute: async (_id, params) => { const { tree, loop } = this.required(); if (params.action === "start") { if (!params.goal?.trim()) throw new Error("goal is required"); const loopState = loop.snapshot(); const loopTerminal = ["stopped", "completed"].includes(loopState.status); const root = tree.inspect(tree.rootId); const rootSpawnable = !["cancelled", "failed", "archived"].includes(root.status); if (loopTerminal || tree.status !== "active" || !rootSpawnable) tree.restart(params.goal); loop.setGoal(params.goal); loop.start(); return toolText(loop.snapshot()); } if (params.action === "status") return toolText(loop.snapshot()); if (params.action === "pause") { loop.pause(); tree.pause(); } else if (params.action === "resume") { loop.resume(); tree.resume(); } else if (params.action === "stop") { loop.stop(); await tree.stop(); } else { if (!params.report) throw new Error("report is required"); return toolText(loop.record(params.report)); } return toolText(loop.snapshot()); }, }); } diff --git a/src/types.ts b/src/types.ts index ea29cc1..c051c91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,4 +95,5 @@ export interface ResearchLimits { max_iterations_without_progress: number; allow_recursive_spawning: boolean; max_research_iterations: number; + agent_timeout_seconds: number; } diff --git a/tests/agent-tree.test.ts b/tests/agent-tree.test.ts index c7be93d..e363852 100644 --- a/tests/agent-tree.test.ts +++ b/tests/agent-tree.test.ts @@ -18,4 +18,8 @@ describe("AgentTree", () => { it("restores relationships and marks active work interrupted", async () => { const { store, tree } = setup(); const child = await tree.spawn(request(tree.rootId, "Crash", "Investigate recovery behavior after process crash")); const path = store.manifestPath(tree.runId); const manifest = JSON.parse(readFileSync(path, "utf8")); manifest.agents[child.id].status = "running"; writeFileSync(path, JSON.stringify(manifest)); const restored = AgentTree.restore(store, new FakeRuntimeFactory(), DEFAULT_CONFIG, tree.runId); expect(restored.inspect(child.id).status).toBe("interrupted"); expect(restored.inspect(child.id).parentId).toBe(tree.rootId); }); it("rejects vague tasks", async () => { const { tree } = setup(); await expect(tree.spawn(request(tree.rootId, "Vague", "look"))).rejects.toThrow(AgentTreeError); }); it("bridges partial child results to the Supervisor root", async () => { const dir = mkdtempSync(resolve(tmpdir(), "hm-tree-message-")); const updates: string[] = []; const tree = new AgentTree(new RunStore(dir), new FakeRuntimeFactory(), DEFAULT_CONFIG, { goal: "Receive upward results", onRootMessage: (from, message) => updates.push(`${from}:${message}`) }); await tree.message(tree.rootId, "partial evidence", "child-1"); expect(updates).toEqual(["child-1:partial evidence"]); }); + it("keeps the root spawnable after stop, and restart revives the run", async () => { const { tree } = setup(); const child = await tree.spawn(request(tree.rootId, "First", "First sufficiently concrete research assignment")); await tree.start(child.id); await tree.stop(); expect(tree.status).toBe("stopped"); expect(tree.inspect(tree.rootId).status).not.toBe("cancelled"); tree.restart("Entirely new research question to investigate"); expect(tree.status).toBe("active"); expect(tree.inspect(tree.rootId).status).toBe("created"); expect(tree.inspect(child.id).status).toBe("archived"); expect(tree.inspect(tree.rootId).children).toEqual([]); await expect(tree.spawn(request(tree.rootId, "Next", "Next sufficiently concrete research assignment"))).resolves.toBeTruthy(); }); + it("does not count cancelled or failed children toward the child limit", async () => { const { tree } = setup({ ...DEFAULT_CONFIG, max_children_per_agent: 2 }); const first = await tree.spawn(request(tree.rootId, "Alpha", "First concrete assignment in a limited tree")); await tree.cancel(first.id); const second = await tree.spawn(request(tree.rootId, "Beta", "Second concrete assignment in a limited tree")); await tree.cancel(second.id); await expect(tree.spawn(request(tree.rootId, "Gamma", "Third concrete assignment in a limited tree"))).resolves.toBeTruthy(); }); + it("cancel does not clobber an already recorded result", async () => { const { tree } = setup(); const child = await tree.spawn(request(tree.rootId, "Done", "Complete a concrete assignment and return")); const result = await tree.start(child.id); expect(result.status).toBe("completed"); await tree.cancel(child.id); expect(tree.inspect(child.id).status).toBe("cancelled"); expect(tree.inspect(child.id).result?.status).toBe("completed"); }); + it("fails agents that exceed the configured timeout", async () => { const { tree } = setup({ ...DEFAULT_CONFIG, agent_timeout_seconds: 0.05 }, 200); const child = await tree.spawn(request(tree.rootId, "Slow", "Run a deliberately slow concrete assignment")); const result = await tree.start(child.id); expect(result.status).toBe("failed"); expect(result.summary).toMatch(/timed out/); expect(tree.inspect(child.id).error).toMatch(/timed out/); }); }); diff --git a/tests/memory.test.ts b/tests/memory.test.ts index d74a0a5..e169660 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -8,4 +8,6 @@ describe("ResearchMemory", () => { it("persists findings and performs rebuildable full-text search", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); const id = memory.save({ type: "fact", status: "corroborated", createdBy: "verifier", runId: "run-1", title: "Catalyst result", statement: "Catalyst alpha improves the measured yield.", evidence: "Independent measurements agree.", sources: ["source-a", "source-b"], limitations: "Small sample" }); expect(memory.search("catalyst")[0]?.id).toBe(id); memory.close(); expect(memory.rebuildIndex()).toBe(1); expect(memory.search("yield")[0]?.id).toBe(id); memory.close(); }); it("does not allow unsourced claims to become corroborated facts", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); expect(() => memory.save({ type: "fact", status: "corroborated", createdBy: "agent", runId: "run", title: "Claim", statement: "Unsupported" })).toThrow(/requires sources/); memory.close(); }); it("keeps negative results searchable", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); memory.save({ type: "experiment_result", status: "rejected", createdBy: "runner", runId: "run", title: "Null replication", statement: "No measurable effect", negativeResult: true }); expect(memory.search("replication")).toHaveLength(1); memory.close(); }); + it("prefixes tokens so inflected forms of the same word match", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); const id = memory.save({ type: "fact", status: "observed", createdBy: "agent", runId: "run", title: "Ribosome dynamics", statement: "Рибосомами управляют рибосомные белки в рибосоме." }); const hits = memory.search("рибосома"); expect(hits.some((row) => row.id === id)).toBe(true); expect(hits[0]?.title).toBe("Ribosome dynamics"); memory.close(); }); + it("survives queries with punctuation and FTS metacharacters", () => { const memory = new ResearchMemory(mkdtempSync(resolve(tmpdir(), "hm-memory-"))); memory.save({ type: "fact", status: "observed", createdBy: "agent", runId: "run", title: "Code expansion", statement: "non-AUG starts and C++ style operators are searched." }); expect(memory.search("non-AUG").some((row) => row.title === "Code expansion")).toBe(true); expect(memory.search("C++").some((row) => row.title === "Code expansion")).toBe(true); expect(memory.search("NOT")).toHaveLength(0); memory.close(); }); }); diff --git a/tests/research-loop.test.ts b/tests/research-loop.test.ts index f19b042..dff69b1 100644 --- a/tests/research-loop.test.ts +++ b/tests/research-loop.test.ts @@ -9,4 +9,7 @@ const report = (newFindings = 0) => ({ goal: "goal", tasks: ["task"], activeAgen describe("ResearchLoop", () => { it("stops after configured iterations without information gain", () => { const loop = new ResearchLoop(mkdtempSync(resolve(tmpdir(), "hm-loop-")), "run", "goal", { ...DEFAULT_CONFIG, max_iterations_without_progress: 2 }); loop.start(); loop.record(report()); expect(loop.record(report()).status).toBe("completed"); expect(loop.snapshot().stopReason).toMatch(/without information gain/); }); it("resets no-progress counter and handles pause/resume", () => { const loop = new ResearchLoop(mkdtempSync(resolve(tmpdir(), "hm-loop-")), "run", "goal", DEFAULT_CONFIG); loop.start(); loop.record(report()); loop.record(report(1)); expect(loop.snapshot().noProgressIterations).toBe(0); loop.pause(); expect(loop.snapshot().status).toBe("paused"); loop.resume(); expect(loop.snapshot().status).toBe("running"); }); + it("restarts from stopped and resets counters and reports", () => { const loop = new ResearchLoop(mkdtempSync(resolve(tmpdir(), "hm-loop-")), "run", "goal", DEFAULT_CONFIG); loop.start(); loop.record(report(1)); expect(loop.snapshot().iteration).toBe(1); loop.stop(); expect(loop.snapshot().status).toBe("stopped"); loop.start(); expect(loop.snapshot().status).toBe("running"); expect(loop.snapshot().iteration).toBe(0); expect(loop.snapshot().reports).toHaveLength(0); expect(loop.snapshot().stopReason).toBeUndefined(); }); + it("allows changing the goal after stop, then restarting", () => { const loop = new ResearchLoop(mkdtempSync(resolve(tmpdir(), "hm-loop-")), "run", "old goal", DEFAULT_CONFIG); loop.start(); loop.record(report()); loop.stop(); loop.setGoal("new goal"); expect(loop.snapshot().goal).toBe("new goal"); loop.start(); expect(loop.snapshot().status).toBe("running"); }); + it("rejects changing the goal mid-run after iterations", () => { const loop = new ResearchLoop(mkdtempSync(resolve(tmpdir(), "hm-loop-")), "run", "goal", DEFAULT_CONFIG); loop.start(); loop.record(report()); expect(() => loop.setGoal("different")).toThrow(/new research run/); }); });