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).
This commit is contained in:
@@ -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
|
||||
|
||||
+45
-12
@@ -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<AgentResult>((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<AgentResult[]> { return Promise.all(ids.map((id) => this.wait(id))); }
|
||||
|
||||
async cancel(id: string): Promise<void> {
|
||||
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<void> { 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<void> { this.manifest.status = "stopped"; await this.cancelBranch(this.rootId); this.persist(); }
|
||||
async stop(): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
+10
-2
@@ -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(); }
|
||||
|
||||
@@ -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<Record<string, unknown>> { 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<Record<string, unknown>>; }
|
||||
close(): void { this.db?.close(); this.db = undefined; }
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()); },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -95,4 +95,5 @@ export interface ResearchLimits {
|
||||
max_iterations_without_progress: number;
|
||||
allow_recursive_spawning: boolean;
|
||||
max_research_iterations: number;
|
||||
agent_timeout_seconds: number;
|
||||
}
|
||||
|
||||
@@ -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/); });
|
||||
});
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
|
||||
@@ -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/); });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user