Add autoresearch lifecycle safeguards

This commit is contained in:
Gianfranco Piana
2026-03-23 17:43:06 -04:00
parent 4a4df9cbf0
commit 830579cc66
27 changed files with 804 additions and 37 deletions
+14 -8
View File
@@ -2,7 +2,7 @@
Autonomous experiment loop for any optimization target.
Faithful OpenClaw port of [`davebcn87/pi-autoresearch`](https://github.com/davebcn87/pi-autoresearch).
Faithful OpenClaw port of [`davebcn87/pi-autoresearch`](https://github.com/davebcn87/pi-autoresearch), including upstream statistical confidence scoring.
## How it works
@@ -12,13 +12,13 @@ Three tools drive the loop:
| Tool | What it does |
|---|---|
| `init_experiment` | Configures the session: name, primary metric, unit, direction (lower/higher). Re-calling starts a new segment. |
| `init_experiment` | Configures the session: name, primary metric, unit, direction (lower/higher). Once runs exist, starting a new segment requires `reset: true`, and the prior segment's best result is carried forward into checkpoint context. |
| `run_experiment` | Executes a shell command, times it, captures stdout/stderr, parses `METRIC name=number` lines, and opens a pending experiment window that must be logged before another run can start. |
| `log_experiment` | Records the pending run. `keep` auto-commits to git. `discard`/`crash` log without committing. If the prior `run_experiment` captured the primary metric, `log_experiment` can infer `commit` and `metric` automatically. |
| `log_experiment` | Records the pending run. The first logged run in a segment is tagged as the baseline automatically. `keep` auto-commits to git. `discard`/`crash` log without committing, and `discard` now requires an `idea` note that is appended to `autoresearch.ideas.md`. If the prior `run_experiment` captured the primary metric, `log_experiment` can infer `commit` and `metric` automatically. After 3+ runs in a segment, it also reports a confidence score for the best improvement versus noise. |
Each tool also accepts an optional `cwd` so callers can target a nested repo explicitly instead of relying on the current session working directory.
All state lives in five repo-root files:
All state lives in six repo-root files:
| File | Purpose |
|---|---|
@@ -27,6 +27,7 @@ All state lives in five repo-root files:
| `autoresearch.jsonl` | Structured log: config headers + experiment entries (metric, status, timestamp, segment, commit hash). |
| `autoresearch.ideas.md` | Backlog of promising ideas not yet tried. Optional. |
| `autoresearch.checkpoint.json` | Plugin-managed checkpoint: latest logged state, recent runs, and any pending unlogged run. |
| `autoresearch.lock` | Session lock with PID + timestamp so another agent can detect an active or stale loop before forking a second session. |
The design is file-first: any agent can pick up the repo-root files and continue the loop without prior context.
@@ -77,9 +78,13 @@ Prefer the explicit `/autoresearch` command surface in OpenClaw. The auto-genera
- `run_experiment` refuses to start a second run until the previous one is logged.
- `run_experiment` parses `METRIC name=number` lines and stores a pending run so `log_experiment` can default from the actual benchmark output.
- `init_experiment` refuses to reset a live history unless `reset: true` is passed explicitly.
- The first `log_experiment` in a segment is tagged as the baseline automatically, even if it is later discarded.
- `discard` logs must include an `idea` note, and that note is appended to `autoresearch.ideas.md`.
- During active autoresearch mode, raw benchmark execution through OpenClaw `exec`/`bash` is blocked. Use `run_experiment` instead.
- `autoresearch_status` warns when a pending run is unlogged or git history has moved ahead of the last logged experiment.
- The plugin updates `autoresearch.checkpoint.json` and refreshes plugin-managed sections in `autoresearch.md` after init, run, and log transitions.
- `autoresearch_status` warns when a pending run is unlogged, when the canonical branch has drifted, when a stale/live lock exists, or when git history has moved ahead of the last logged experiment. On `autoresearch/*` branches it explicitly warns not to push unlogged commits.
- After 3+ positive-metric runs in a segment, `log_experiment`, `autoresearch_status`, and the synced session doc report a MAD-based confidence score so the agent can distinguish likely wins from noise.
- The plugin updates `autoresearch.checkpoint.json`, `autoresearch.lock`, and the plugin-managed sections in `autoresearch.md` after init, run, and log transitions.
## Use
@@ -89,7 +94,7 @@ In the repo you want to optimize:
2. Run `/autoresearch` or `/autoresearch setup <goal>`.
3. Send a normal message with the goal, command, metric (+ direction), files in scope, and constraints.
4. If you need the raw skill invocation, use `/skill autoresearch-create`.
5. The agent writes `autoresearch.md` and `autoresearch.sh`, runs a baseline with `run_experiment`, then records it with `log_experiment`.
5. The agent writes `autoresearch.md` and `autoresearch.sh`, captures or reuses the canonical `autoresearch/*` branch, runs a baseline with `run_experiment`, then records it with `log_experiment`.
6. Use `/autoresearch` or `/autoresearch status` to re-prime context on a later turn.
To resume an existing session, a new agent reads the repo-root files and continues from where the last one stopped.
@@ -100,7 +105,7 @@ Messages sent while an experiment is running are queued and surfaced after the n
### Ideas backlog
When the agent discovers promising but complex ideas mid-loop, it appends them to `autoresearch.ideas.md`. On resume, the agent reads the backlog, prunes stale entries, and uses the remaining ideas as experiment paths.
When the agent discovers promising but complex ideas mid-loop, it appends them to `autoresearch.ideas.md`. Discarded experiments now require an `idea` note, so failed paths leave behind concrete follow-up suggestions instead of disappearing. On resume, the agent reads the backlog, prunes stale entries, and uses the remaining ideas as experiment paths.
## Upstream reference
@@ -108,6 +113,7 @@ This port preserves upstream semantics, names, and file contracts while adapting
- upstream repo: `https://github.com/davebcn87/pi-autoresearch`
- pinned upstream commit: `2227029fa5712944a36938b5fe59f709cb30ed22` (`2227029f`)
- later upstream parity cherry-pick: confidence scoring from `cf1bbf03debca8f3fb2cca2c3e799b9e23320f87` (`cf1bbf0`, March 19, 2026)
## Validation
+1
View File
@@ -6,6 +6,7 @@ Pinned upstream reference:
- Repo: `https://github.com/davebcn87/pi-autoresearch`
- Commit: `2227029fa5712944a36938b5fe59f709cb30ed22` (`2227029f`)
- Later upstream semantics also ported: confidence scoring from `cf1bbf03debca8f3fb2cca2c3e799b9e23320f87` (`cf1bbf0`)
## Principle
@@ -6,10 +6,19 @@ import type {
} from "./state.js";
import type { PendingExperimentRun } from "./runtime-state.js";
export type AutoresearchCarryForwardContext = {
readonly metricName: string;
readonly metricUnit: string;
readonly bestDirection: "lower" | "higher";
readonly run: AutoresearchRunSnapshot;
};
export type AutoresearchCheckpoint = {
readonly version: 1;
readonly updatedAt: number;
readonly sessionStartCommit: string | null;
readonly canonicalBranch?: string | null;
readonly carryForwardContext?: AutoresearchCarryForwardContext | null;
readonly session: {
readonly name: string | null;
readonly metricName: string;
@@ -20,6 +29,7 @@ export type AutoresearchCheckpoint = {
readonly totalRunCount: number;
readonly currentBaselineMetric: number | null;
readonly currentBestMetric: number | null;
readonly confidence: number | null;
};
readonly lastLoggedRun: AutoresearchRunSnapshot | null;
readonly recentLoggedRuns: readonly AutoresearchRunSnapshot[];
@@ -44,6 +54,8 @@ export function writeAutoresearchCheckpoint(options: {
cwd: string;
state: AutoresearchStateSnapshot;
sessionStartCommit: string | null;
canonicalBranch: string | null;
carryForwardContext: AutoresearchCarryForwardContext | null;
recentLoggedRuns: readonly AutoresearchRunSnapshot[];
pendingRun: PendingExperimentRun | null;
}): AutoresearchCheckpoint {
@@ -51,6 +63,8 @@ export function writeAutoresearchCheckpoint(options: {
version: 1,
updatedAt: Date.now(),
sessionStartCommit: options.sessionStartCommit,
canonicalBranch: options.canonicalBranch,
carryForwardContext: options.carryForwardContext,
session: {
name: options.state.name,
metricName: options.state.metricName,
@@ -61,6 +75,7 @@ export function writeAutoresearchCheckpoint(options: {
totalRunCount: options.state.totalRunCount,
currentBaselineMetric: options.state.currentBaselineMetric,
currentBestMetric: options.state.currentBestMetric,
confidence: options.state.confidence,
},
lastLoggedRun: options.state.lastRun,
recentLoggedRuns: options.recentLoggedRuns,
@@ -14,6 +14,11 @@ import {
setAutoresearchRunInFlight,
setAutoresearchRuntimeMode,
} from "../runtime-state.js";
import {
acquireAutoresearchSessionLock,
getAutoresearchSessionLockStatus,
removeAutoresearchSessionLock,
} from "../session-lock.js";
type CommandContext = {
args?: string;
@@ -59,6 +64,7 @@ export function registerAutoresearchCommand(api: OpenClawPluginApi): void {
setAutoresearchPendingCommand(cwd, null);
clearAutoresearchSteers(cwd);
setAutoresearchRunInFlight(cwd, false);
removeAutoresearchSessionLock(cwd);
return {
text: [
"Autoresearch mode OFF.",
@@ -86,7 +92,11 @@ export function buildAutoresearchCommandText(
): string {
const runtimeState = getAutoresearchRuntimeState(cwd);
const presentFiles = getPresentCanonicalFiles(cwd);
const hasSession = presentFiles.length > 0;
const presentSessionFiles = presentFiles.filter(
(file) => file !== AUTORESEARCH_ROOT_FILES.sessionLock,
);
const hasSession = presentSessionFiles.length > 0;
const lockStatus = getAutoresearchSessionLockStatus(cwd);
if (!hasSession) {
return [
@@ -95,6 +105,7 @@ export function buildAutoresearchCommandText(
`Expected canonical files: ${Object.values(AUTORESEARCH_ROOT_FILES).join(", ")}`,
"Recommended OpenClaw entrypoint: `/autoresearch` or `/autoresearch setup <goal>`.",
"Direct skill fallback: `/skill autoresearch-create`.",
`Session lock: ${formatLockStatus(lockStatus)}`,
].join("\n");
}
@@ -105,7 +116,7 @@ export function buildAutoresearchCommandText(
];
if (mode === "status") {
lines.push("", formatAutoresearchStatusText(state, runtimeState));
lines.push("", formatAutoresearchStatusText(state, runtimeState), `Session lock: ${formatLockStatus(lockStatus)}`);
} else if (state.mode === "active" || state.hasSessionDoc) {
lines.push(
"Use `/autoresearch` or `/autoresearch on` to enable mode for the next agent turn, then continue the upstream loop with `init_experiment`, `run_experiment`, and `log_experiment` as needed.",
@@ -120,9 +131,18 @@ export function buildAutoresearchCommandText(
}
function enableAutoresearchMode(cwd: string, args: string | null): string {
const lockStatus = acquireAutoresearchSessionLock(cwd);
if (lockStatus.state === "active" && !lockStatus.ownedByCurrentProcess) {
return [
"Autoresearch mode NOT enabled.",
`Another live autoresearch loop holds autoresearch.lock (PID ${lockStatus.pid}, started ${new Date(lockStatus.timestamp ?? 0).toISOString()}).`,
"Resume that loop instead of creating a parallel session.",
].join("\n");
}
setAutoresearchRuntimeMode(cwd, "on");
const presentFiles = getPresentCanonicalFiles(cwd);
const hasSession = presentFiles.length > 0;
const hasSession = presentFiles.some((file) => file !== AUTORESEARCH_ROOT_FILES.sessionLock);
if (!hasSession) {
setAutoresearchPendingCommand(cwd, {
@@ -150,6 +170,15 @@ function enableAutoresearchMode(cwd: string, args: string | null): string {
}
function primeAutoresearchSetup(cwd: string, args: string | null): string {
const lockStatus = acquireAutoresearchSessionLock(cwd);
if (lockStatus.state === "active" && !lockStatus.ownedByCurrentProcess) {
return [
"Autoresearch setup NOT primed.",
`Another live autoresearch loop holds autoresearch.lock (PID ${lockStatus.pid}, started ${new Date(lockStatus.timestamp ?? 0).toISOString()}).`,
"Resume that loop instead of starting a parallel setup flow.",
].join("\n");
}
setAutoresearchRuntimeMode(cwd, "on");
setAutoresearchPendingCommand(cwd, {
kind: "setup",
@@ -180,3 +209,14 @@ function getPresentCanonicalFiles(cwd: string): string[] {
}
return present;
}
function formatLockStatus(lockStatus: ReturnType<typeof getAutoresearchSessionLockStatus>): string {
if (lockStatus.state === "missing") {
return "missing";
}
const timestamp = lockStatus.timestamp
? new Date(lockStatus.timestamp).toISOString()
: "unknown time";
return `${lockStatus.state} (pid ${lockStatus.pid}, started ${timestamp})`;
}
@@ -0,0 +1,82 @@
export type ConfidenceRun = {
readonly metric: number;
readonly status: string;
};
export function computeConfidence(
runs: readonly ConfidenceRun[],
direction: "lower" | "higher",
): number | null {
const usableRuns = runs.filter((run) => Number.isFinite(run.metric) && run.metric > 0);
if (usableRuns.length < 3) {
return null;
}
const baseline = runs.find((run) => Number.isFinite(run.metric));
if (!baseline) {
return null;
}
const values = usableRuns.map((run) => run.metric);
const median = sortedMedian(values);
const deviations = values.map((value) => Math.abs(value - median));
const mad = sortedMedian(deviations);
if (mad === 0) {
return null;
}
let bestKept: number | null = null;
for (const run of usableRuns) {
if (run.status !== "keep") {
continue;
}
if (bestKept === null || isBetter(run.metric, bestKept, direction)) {
bestKept = run.metric;
}
}
if (bestKept === null || bestKept === baseline.metric) {
return null;
}
return Math.abs(bestKept - baseline.metric) / mad;
}
export function formatConfidenceLine(
confidence: number | null,
label = "Confidence",
): string {
return confidence === null ? `${label}: n/a` : `${label}: ${describeConfidence(confidence)}`;
}
export function describeConfidence(confidence: number): string {
const rendered = confidence.toFixed(1);
if (confidence >= 2.0) {
return `${rendered}x noise floor - improvement is likely real`;
}
if (confidence >= 1.0) {
return `${rendered}x noise floor - improvement is above noise but marginal`;
}
return `${rendered}x noise floor - improvement is within noise. Consider re-running to confirm before keeping`;
}
function sortedMedian(values: readonly number[]): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((left, right) => left - right);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
function isBetter(
current: number,
best: number,
direction: "lower" | "higher",
): boolean {
return direction === "lower" ? current < best : current > best;
}
@@ -6,6 +6,7 @@ export const AUTORESEARCH_ROOT_FILES = {
resultsLog: "autoresearch.jsonl",
ideasBacklog: "autoresearch.ideas.md",
checkpoint: "autoresearch.checkpoint.json",
sessionLock: "autoresearch.lock",
} as const;
export type AutoresearchRootFileKey = keyof typeof AUTORESEARCH_ROOT_FILES;
@@ -29,10 +30,6 @@ export function readAutoresearchRootFile(
return fs.readFileSync(filePath, "utf8");
}
/**
* PR 2 skeleton only.
* This module will own canonical root-level file IO helpers in later PRs.
*/
export function describeCanonicalFiles(): typeof AUTORESEARCH_ROOT_FILES {
return AUTORESEARCH_ROOT_FILES;
}
@@ -54,6 +54,14 @@ export async function readShortHeadCommit(options: GitRuntimeOptions): Promise<s
return result.code === 0 && result.stdout.trim().length > 0 ? result.stdout.trim() : null;
}
export async function readCurrentBranch(options: GitRuntimeOptions): Promise<string | null> {
const result = await runGitCommand(options.runCommandWithTimeout, options.cwd, [
"branch",
"--show-current",
]);
return result.code === 0 && result.stdout.trim().length > 0 ? result.stdout.trim() : null;
}
export async function countCommitsSince(
options: GitRuntimeOptions & { sinceCommit: string },
): Promise<number | null> {
@@ -1,6 +1,8 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { AUTORESEARCH_ROOT_FILES } from "./files.js";
import { reconstructStateFromJsonl } from "./state.js";
import { readAutoresearchCheckpoint } from "./checkpoint.js";
import { formatConfidenceLine } from "./confidence.js";
import {
clearAutoresearchRuntimeState,
consumeAutoresearchContinuationReminder,
@@ -9,6 +11,7 @@ import {
queueAutoresearchSteer,
setAutoresearchContinuationReminder,
} from "./runtime-state.js";
import { removeAutoresearchSessionLock } from "./session-lock.js";
type BeforeAgentStartEvent = {
systemPrompt?: string;
@@ -106,6 +109,7 @@ export function registerAutoresearchHooks(api: OpenClawPluginApi): void {
return;
}
removeAutoresearchSessionLock(cwd);
clearAutoresearchRuntimeState(cwd);
});
return;
@@ -136,6 +140,7 @@ export function registerAutoresearchHooks(api: OpenClawPluginApi): void {
export function buildBeforePromptBuildContext(cwd: string): string | null {
const state = reconstructStateFromJsonl(cwd);
const runtimeState = getAutoresearchRuntimeState(cwd);
const checkpoint = readAutoresearchCheckpoint(cwd);
if (!shouldEnforceAutoresearchMode(cwd, state, runtimeState)) {
return null;
}
@@ -166,7 +171,24 @@ export function buildBeforePromptBuildContext(cwd: string): string | null {
"Use init_experiment, run_experiment, and log_experiment for experiment state changes. Never stop unless the user explicitly interrupts the loop.",
"Never run benchmark or test commands through raw exec/bash during autoresearch mode. Use run_experiment so the plugin can capture metrics, enforce logging, and preserve resumable state.",
"After every run_experiment, call log_experiment before starting another run. If METRIC lines were captured, log_experiment can infer commit and metric from the pending run.",
"Once runs have been logged, init_experiment requires reset: true before starting a new segment.",
`Discarded experiments must include an idea note in log_experiment so it can be appended to ${AUTORESEARCH_ROOT_FILES.ideasBacklog}.`,
);
if (state.confidence !== null) {
lines.push(
`${formatConfidenceLine(state.confidence, "Current confidence")}. Treat low-confidence wins as provisional and re-run before keeping when the score is below 1.0x.`,
);
}
if (checkpoint?.canonicalBranch) {
lines.push(
`Canonical branch: ${checkpoint.canonicalBranch}. Continue on this branch instead of creating a new autoresearch/* lineage.`,
);
}
if (checkpoint?.carryForwardContext) {
lines.push(
`Carry-forward best context from the previous segment: ${checkpoint.carryForwardContext.metricName} ${checkpoint.carryForwardContext.run.metric}${checkpoint.carryForwardContext.metricUnit} on ${checkpoint.carryForwardContext.run.commit}.`,
);
}
if (pendingCommand?.args) {
lines.push(`Additional resume instruction from /autoresearch: ${pendingCommand.args}`);
}
@@ -0,0 +1,14 @@
import * as fs from "node:fs";
import { getAutoresearchRootFilePath } from "./files.js";
export function appendIdeaBacklogEntry(cwd: string, idea: string): void {
const normalized = idea.trim();
if (!normalized) {
return;
}
const ideasPath = getAutoresearchRootFilePath(cwd, "ideasBacklog");
const prefix =
fs.existsSync(ideasPath) && fs.readFileSync(ideasPath, "utf8").trim().length > 0 ? "\n" : "";
fs.appendFileSync(ideasPath, `${prefix}- ${normalized}\n`);
}
@@ -44,9 +44,11 @@ export type AutoresearchResultEntry = {
readonly metric: number;
readonly metrics: Record<string, number>;
readonly status: "keep" | "discard" | "crash";
readonly baseline?: boolean;
readonly description: string;
readonly timestamp: number;
readonly segment: number;
readonly confidence: number | null;
};
export function appendResultEntry(cwd: string, entry: AutoresearchResultEntry): void {
@@ -1,6 +1,7 @@
import * as fs from "node:fs";
import { AUTORESEARCH_ROOT_FILES, getAutoresearchRootFilePath } from "./files.js";
import type { AutoresearchCheckpoint } from "./checkpoint.js";
import { formatConfidenceLine } from "./confidence.js";
export function syncAutoresearchSessionDoc(
cwd: string,
@@ -65,7 +66,8 @@ function buildTriedSection(checkpoint: AutoresearchCheckpoint): string {
const metricUnit = checkpoint.session.metricUnit;
const renderedMetric =
metricUnit && metricUnit.length > 0 ? `${run.metric}${metricUnit}` : `${run.metric}`;
return `- #${run.run} ${run.status} ${renderedMetric} ${run.commit}${run.description}`;
const baselineLabel = run.baseline ? " baseline" : "";
return `- #${run.run}${baselineLabel} ${run.status} ${renderedMetric} ${run.commit}${run.description}`;
})
.join("\n");
}
@@ -76,8 +78,19 @@ function buildCheckpointSection(checkpoint: AutoresearchCheckpoint): string {
`- Runs tracked: ${checkpoint.session.currentRunCount} current / ${checkpoint.session.totalRunCount} total`,
`- Baseline: ${formatMetric(checkpoint.session.currentBaselineMetric, checkpoint.session.metricUnit)}`,
`- Best kept: ${formatMetric(checkpoint.session.currentBestMetric, checkpoint.session.metricUnit)}`,
`- ${formatConfidenceLine(checkpoint.session.confidence)}`,
];
if (checkpoint.canonicalBranch) {
lines.push(`- Canonical branch: ${checkpoint.canonicalBranch}`);
}
if (checkpoint.carryForwardContext) {
lines.push(
`- Carry-forward best: ${checkpoint.carryForwardContext.metricName} ${formatMetric(checkpoint.carryForwardContext.run.metric, checkpoint.carryForwardContext.metricUnit)} from #${checkpoint.carryForwardContext.run.run} ${checkpoint.carryForwardContext.run.commit}${checkpoint.carryForwardContext.run.description}`,
);
}
if (checkpoint.lastLoggedRun) {
lines.push(
`- Last logged run: #${checkpoint.lastLoggedRun.run} ${checkpoint.lastLoggedRun.status} ${checkpoint.lastLoggedRun.commit}${checkpoint.lastLoggedRun.description}`,
@@ -0,0 +1,96 @@
import * as fs from "node:fs";
import { getAutoresearchRootFilePath } from "./files.js";
export type AutoresearchSessionLock = {
readonly pid: number;
readonly timestamp: number;
};
export type AutoresearchSessionLockStatus = {
readonly state: "missing" | "active" | "stale";
readonly pid: number | null;
readonly timestamp: number | null;
readonly ownedByCurrentProcess: boolean;
};
export function readAutoresearchSessionLock(cwd: string): AutoresearchSessionLock | null {
const lockPath = getAutoresearchRootFilePath(cwd, "sessionLock");
if (!fs.existsSync(lockPath)) {
return null;
}
try {
const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as Partial<AutoresearchSessionLock>;
if (typeof parsed.pid !== "number" || typeof parsed.timestamp !== "number") {
return null;
}
return {
pid: parsed.pid,
timestamp: parsed.timestamp,
};
} catch {
return null;
}
}
export function getAutoresearchSessionLockStatus(cwd: string): AutoresearchSessionLockStatus {
const lock = readAutoresearchSessionLock(cwd);
if (!lock) {
return {
state: "missing",
pid: null,
timestamp: null,
ownedByCurrentProcess: false,
};
}
const active = isProcessAlive(lock.pid);
return {
state: active ? "active" : "stale",
pid: lock.pid,
timestamp: lock.timestamp,
ownedByCurrentProcess: lock.pid === process.pid,
};
}
export function acquireAutoresearchSessionLock(cwd: string): AutoresearchSessionLockStatus {
const status = getAutoresearchSessionLockStatus(cwd);
if (status.state === "active" && !status.ownedByCurrentProcess) {
return status;
}
const nextLock: AutoresearchSessionLock = {
pid: process.pid,
timestamp: Date.now(),
};
const lockPath = getAutoresearchRootFilePath(cwd, "sessionLock");
fs.writeFileSync(lockPath, `${JSON.stringify(nextLock, null, 2)}\n`);
return {
state: "active",
pid: nextLock.pid,
timestamp: nextLock.timestamp,
ownedByCurrentProcess: true,
};
}
export function removeAutoresearchSessionLock(cwd: string): void {
const lockPath = getAutoresearchRootFilePath(cwd, "sessionLock");
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
}
function isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : null;
return code === "EPERM";
}
}
+52 -2
View File
@@ -1,4 +1,5 @@
import { readAutoresearchRootFile } from "./files.js";
import { computeConfidence, type ConfidenceRun } from "./confidence.js";
export type SecondaryMetricDef = {
readonly name: string;
@@ -19,9 +20,11 @@ export type AutoresearchRunSnapshot = {
readonly metric: number;
readonly metrics: Record<string, number>;
readonly status: "keep" | "discard" | "crash";
readonly baseline: boolean;
readonly description: string;
readonly timestamp: number;
readonly segment: number;
readonly confidence: number | null;
};
export type AutoresearchStateSnapshot = {
@@ -35,6 +38,7 @@ export type AutoresearchStateSnapshot = {
readonly totalRunCount: number;
readonly currentBaselineMetric: number | null;
readonly currentBestMetric: number | null;
readonly confidence: number | null;
readonly lastRun: AutoresearchRunSnapshot | null;
readonly mode: AutoresearchMode;
readonly hasSessionDoc: boolean;
@@ -51,6 +55,7 @@ type MutableStateSnapshot = {
totalRunCount: number;
currentBaselineMetric: number | null;
currentBestMetric: number | null;
confidence: number | null;
lastRun: AutoresearchRunSnapshot | null;
mode: AutoresearchMode;
hasSessionDoc: boolean;
@@ -68,9 +73,11 @@ type JsonlEntry = {
readonly metric?: number;
readonly metrics?: Record<string, number>;
readonly status?: "keep" | "discard" | "crash";
readonly baseline?: boolean;
readonly description?: string;
readonly timestamp?: number;
readonly segment?: number;
readonly confidence?: number | null;
};
export function createEmptyStateSnapshot(): AutoresearchStateSnapshot {
@@ -85,6 +92,7 @@ export function createEmptyStateSnapshot(): AutoresearchStateSnapshot {
totalRunCount: 0,
currentBaselineMetric: null,
currentBestMetric: null,
confidence: null,
lastRun: null,
mode: "inactive",
hasSessionDoc: false,
@@ -120,6 +128,7 @@ export function reconstructStateFromJsonl(cwd: string): AutoresearchStateSnapsho
}
const currentSecondaryMetrics = new Map<string, SecondaryMetricDef>();
let currentSegmentRuns: ConfidenceRun[] = [];
let currentRunIndex = 0;
let hasSeenAnyRun = false;
@@ -155,7 +164,9 @@ export function reconstructStateFromJsonl(cwd: string): AutoresearchStateSnapsho
state.currentRunCount = 0;
state.currentBaselineMetric = null;
state.currentBestMetric = null;
state.confidence = null;
currentRunIndex = 0;
currentSegmentRuns = [];
currentSecondaryMetrics.clear();
continue;
}
@@ -168,6 +179,7 @@ export function reconstructStateFromJsonl(cwd: string): AutoresearchStateSnapsho
currentRunIndex += 1;
state.currentRunCount = currentRunIndex;
state.totalRunCount += 1;
const isBaseline = entry.baseline === true || currentRunIndex === 1;
const run: AutoresearchRunSnapshot = {
run: typeof entry.run === "number" ? entry.run : currentRunIndex,
@@ -175,9 +187,11 @@ export function reconstructStateFromJsonl(cwd: string): AutoresearchStateSnapsho
metric: entry.metric,
metrics: normalizeMetrics(entry.metrics),
status: entry.status ?? "keep",
baseline: isBaseline,
description: entry.description ?? "",
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : 0,
segment: typeof entry.segment === "number" ? entry.segment : state.currentSegment,
confidence: typeof entry.confidence === "number" ? entry.confidence : null,
};
if (state.currentBaselineMetric === null) {
@@ -202,11 +216,16 @@ export function reconstructStateFromJsonl(cwd: string): AutoresearchStateSnapsho
}
}
currentSegmentRuns.push({
metric: run.metric,
status: run.status,
});
state.lastRun = run;
}
return {
...state,
confidence: computeConfidence(currentSegmentRuns, state.bestDirection),
secondaryMetrics: [...currentSecondaryMetrics.values()],
};
}
@@ -215,8 +234,36 @@ export function readRecentLoggedRuns(
cwd: string,
limit: number,
): readonly AutoresearchRunSnapshot[] {
const runs = readAllLoggedRuns(cwd);
return limit <= 0 ? [] : runs.slice(-limit);
}
export function readBestLoggedRun(
cwd: string,
direction: "lower" | "higher",
segment?: number,
): AutoresearchRunSnapshot | null {
const runs = readAllLoggedRuns(cwd).filter((run) =>
typeof segment === "number" ? run.segment === segment : true,
);
if (runs.length === 0) {
return null;
}
const keepRuns = runs.filter((run) => run.status === "keep");
const candidates = keepRuns.length > 0 ? keepRuns : runs;
let best = candidates[0] ?? null;
for (const run of candidates.slice(1)) {
if (!best || isBetter(run.metric, best.metric, direction)) {
best = run;
}
}
return best;
}
function readAllLoggedRuns(cwd: string): readonly AutoresearchRunSnapshot[] {
const jsonl = readAutoresearchRootFile(cwd, "resultsLog");
if (jsonl === null || limit <= 0) {
if (jsonl === null) {
return [];
}
@@ -252,19 +299,22 @@ export function readRecentLoggedRuns(
hasSeenAnyRun = true;
currentRunIndex += 1;
const isBaseline = entry.baseline === true || currentRunIndex === 1;
runs.push({
run: typeof entry.run === "number" ? entry.run : currentRunIndex,
commit: entry.commit ?? "",
metric: entry.metric,
metrics: normalizeMetrics(entry.metrics),
status: entry.status ?? "keep",
baseline: isBaseline,
description: entry.description ?? "",
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : 0,
segment: typeof entry.segment === "number" ? entry.segment : currentSegment,
confidence: typeof entry.confidence === "number" ? entry.confidence : null,
});
}
return runs.slice(-limit);
return runs;
}
function normalizeMetrics(metrics: Record<string, number> | undefined): Record<string, number> {
@@ -10,12 +10,19 @@ import {
readAutoresearchCheckpoint,
type AutoresearchCheckpoint,
} from "../checkpoint.js";
import { countCommitsSince, readShortHeadCommit } from "../git.js";
import { countCommitsSince, readCurrentBranch, readShortHeadCommit } from "../git.js";
import { formatConfidenceLine } from "../confidence.js";
import {
getAutoresearchSessionLockStatus,
type AutoresearchSessionLockStatus,
} from "../session-lock.js";
export type AutoresearchStatusDiagnostics = {
readonly warnings: readonly string[];
readonly checkpoint: AutoresearchCheckpoint | null;
readonly gitHead: string | null;
readonly gitBranch: string | null;
readonly lock: AutoresearchSessionLockStatus;
};
const AutoresearchStatusParams = Type.Object(
@@ -83,6 +90,7 @@ export function formatAutoresearchStatusText(
`Runs: ${state.currentRunCount} current / ${state.totalRunCount} total`,
`Baseline: ${formatMetric(state.currentBaselineMetric, state.metricUnit)}`,
`Best kept: ${formatMetric(state.currentBestMetric, state.metricUnit)}`,
formatConfidenceLine(state.confidence),
];
if (state.name) {
@@ -114,6 +122,24 @@ export function formatAutoresearchStatusText(
lines.push(`Git HEAD: ${diagnostics.gitHead}`);
}
if (diagnostics?.gitBranch) {
lines.push(`Current branch: ${diagnostics.gitBranch}`);
}
if (diagnostics?.checkpoint?.canonicalBranch) {
lines.push(`Canonical branch: ${diagnostics.checkpoint.canonicalBranch}`);
}
if (diagnostics?.checkpoint?.carryForwardContext) {
lines.push(
`Carry-forward best: ${diagnostics.checkpoint.carryForwardContext.metricName} ${formatMetric(diagnostics.checkpoint.carryForwardContext.run.metric, diagnostics.checkpoint.carryForwardContext.metricUnit)} from ${diagnostics.checkpoint.carryForwardContext.run.commit}`,
);
}
if (diagnostics?.lock) {
lines.push(`Session lock: ${formatLockStatus(diagnostics.lock)}`);
}
if (diagnostics && diagnostics.warnings.length > 0) {
lines.push("", "Warnings:");
for (const warning of diagnostics.warnings) {
@@ -143,6 +169,11 @@ async function buildAutoresearchStatusDiagnostics(
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
cwd,
});
const gitBranch = await readCurrentBranch({
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
cwd,
});
const lock = getAutoresearchSessionLockStatus(cwd);
const warnings: string[] = [];
if (checkpoint?.pendingRun) {
@@ -151,6 +182,26 @@ async function buildAutoresearchStatusDiagnostics(
);
}
if (lock.state === "stale") {
warnings.push(
`Stale autoresearch.lock detected for PID ${lock.pid}. Remove it after confirming that process is gone.`,
);
} else if (lock.state === "active" && !lock.ownedByCurrentProcess) {
warnings.push(
`Another live autoresearch loop appears active (PID ${lock.pid}, started ${new Date(lock.timestamp ?? 0).toISOString()}). Resume that loop instead of branching off a new one.`,
);
}
if (
checkpoint?.canonicalBranch &&
gitBranch &&
checkpoint.canonicalBranch !== gitBranch
) {
warnings.push(
`Branch drift: current branch is ${gitBranch}, but the canonical autoresearch branch is ${checkpoint.canonicalBranch}. Switch back before continuing the loop.`,
);
}
const driftBase =
state.lastRun?.commit && state.lastRun.commit.length > 0
? state.lastRun.commit
@@ -163,10 +214,17 @@ async function buildAutoresearchStatusDiagnostics(
});
if (commitsAhead !== null && commitsAhead > 0) {
const branchLabel = gitBranch ? `Branch ${gitBranch}` : "Current HEAD";
const shouldBlockPush =
(gitBranch ?? checkpoint?.canonicalBranch ?? "").startsWith("autoresearch/");
warnings.push(
state.lastRun
? `${commitsAhead} commit${commitsAhead === 1 ? "" : "s"} since the last logged experiment (${state.lastRun.commit}).`
: `${commitsAhead} commit${commitsAhead === 1 ? "" : "s"} since init_experiment, but no experiment has been logged yet.`,
shouldBlockPush
? state.lastRun
? `UNLOGGED COMMITS: ${branchLabel} is ${commitsAhead} commit${commitsAhead === 1 ? "" : "s"} ahead of the last logged experiment (${state.lastRun.commit}). Do not push this branch until each commit is captured by run_experiment -> log_experiment.`
: `UNLOGGED COMMITS: ${branchLabel} is ${commitsAhead} commit${commitsAhead === 1 ? "" : "s"} ahead of init_experiment with no logged experiment yet. Do not push this branch until the baseline and follow-up runs are logged.`
: state.lastRun
? `${commitsAhead} commit${commitsAhead === 1 ? "" : "s"} since the last logged experiment (${state.lastRun.commit}).`
: `${commitsAhead} commit${commitsAhead === 1 ? "" : "s"} since init_experiment, but no experiment has been logged yet.`,
);
}
}
@@ -175,5 +233,22 @@ async function buildAutoresearchStatusDiagnostics(
warnings,
checkpoint,
gitHead,
gitBranch,
lock,
};
}
function formatLockStatus(lock: AutoresearchSessionLockStatus): string {
if (lock.state === "missing") {
return "missing";
}
const owner = `pid ${lock.pid}`;
const timestamp = lock.timestamp ? new Date(lock.timestamp).toISOString() : "unknown time";
if (lock.state === "stale") {
return `stale (${owner}, started ${timestamp})`;
}
return lock.ownedByCurrentProcess
? `active (current process, ${owner}, started ${timestamp})`
: `active (${owner}, started ${timestamp})`;
}
@@ -3,15 +3,17 @@ import { InitExperimentParams } from "./schemas.js";
import { createConfigHeader, writeConfigHeader } from "../logging.js";
import {
createEmptyStateSnapshot,
readBestLoggedRun,
readRecentLoggedRuns,
reconstructStateFromJsonl,
type AutoresearchStateSnapshot,
} from "../state.js";
import { readAutoresearchCheckpoint, writeAutoresearchCheckpoint } from "../checkpoint.js";
import { syncAutoresearchSessionDoc } from "../session-doc.js";
import { readShortHeadCommit } from "../git.js";
import { readCurrentBranch, readShortHeadCommit } from "../git.js";
import { setAutoresearchPendingRun, setAutoresearchRunInFlight } from "../runtime-state.js";
import { resolveToolCwd } from "./tool-cwd.js";
import { acquireAutoresearchSessionLock } from "../session-lock.js";
export function createInitExperimentTool(api: OpenClawPluginApi) {
return {
@@ -28,14 +30,72 @@ export function createInitExperimentTool(api: OpenClawPluginApi) {
metric_name: string;
metric_unit?: string;
direction?: "lower" | "higher";
reset?: boolean;
},
_signal: AbortSignal,
_onUpdate: unknown,
) {
const cwd = resolveToolCwd(api, params.cwd);
const lockStatus = acquireAutoresearchSessionLock(cwd);
if (lockStatus.state === "active" && !lockStatus.ownedByCurrentProcess) {
return {
content: [
{
type: "text" as const,
text:
`Another autoresearch loop already holds ${"autoresearch.lock"}.\n` +
`Lock owner PID: ${lockStatus.pid}\n` +
`Started: ${new Date(lockStatus.timestamp ?? 0).toISOString()}\n` +
"Resume that loop instead of starting a parallel session, or remove the stale lock after confirming the process is gone.",
},
],
details: {
status: "error",
phase: "lock",
},
};
}
const previousState = reconstructStateFromJsonl(cwd);
const previousCheckpoint = readAutoresearchCheckpoint(cwd);
const isReinit = previousState.currentRunCount > 0;
const hasLoggedRuns = previousState.totalRunCount > 0;
const isReinit = hasLoggedRuns && (params.reset ?? false);
if (previousCheckpoint?.pendingRun) {
return {
content: [
{
type: "text" as const,
text:
"A run_experiment result is still pending log_experiment.\n" +
`Pending command: ${previousCheckpoint.pendingRun.command}\n` +
"Log or clear that run before re-initializing the session.",
},
],
details: {
status: "error",
phase: "pending_log",
},
};
}
if (hasLoggedRuns && !params.reset) {
return {
content: [
{
type: "text" as const,
text:
`This session already has ${previousState.totalRunCount} logged experiment${previousState.totalRunCount === 1 ? "" : "s"} across ${previousState.currentSegment + 1} segment${previousState.currentSegment === 0 ? "" : "s"}.\n` +
"init_experiment now requires reset: true before starting a new segment so the reset is explicit and prior results stay comparable.",
},
],
details: {
status: "error",
phase: "reset_required",
},
};
}
const nextState: AutoresearchStateSnapshot = {
...createEmptyStateSnapshot(),
name: params.name,
@@ -80,10 +140,28 @@ export function createInitExperimentTool(api: OpenClawPluginApi) {
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
cwd,
});
const currentBranch = await readCurrentBranch({
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
cwd,
});
const previousBestRun =
isReinit
? readBestLoggedRun(cwd, previousState.bestDirection, previousState.currentSegment)
: null;
const checkpoint = writeAutoresearchCheckpoint({
cwd,
state: nextPersistentState,
sessionStartCommit: sessionStartCommit ?? previousCheckpoint?.sessionStartCommit ?? null,
canonicalBranch: previousCheckpoint?.canonicalBranch ?? currentBranch,
carryForwardContext:
previousBestRun && isReinit
? {
metricName: previousState.metricName,
metricUnit: previousState.metricUnit,
bestDirection: previousState.bestDirection,
run: previousBestRun,
}
: previousCheckpoint?.carryForwardContext ?? null,
recentLoggedRuns: readRecentLoggedRuns(cwd, 8),
pendingRun: null,
});
@@ -92,6 +170,10 @@ export function createInitExperimentTool(api: OpenClawPluginApi) {
const reinitNote = isReinit
? " (re-initialized - previous results archived, new baseline needed)"
: "";
const carryForwardNote =
checkpoint.carryForwardContext
? `\nCarry-forward context: best prior result was ${checkpoint.carryForwardContext.metricName}=${checkpoint.carryForwardContext.run.metric}${checkpoint.carryForwardContext.metricUnit} on ${checkpoint.carryForwardContext.run.commit}.`
: "";
return {
content: [
@@ -100,7 +182,8 @@ export function createInitExperimentTool(api: OpenClawPluginApi) {
text:
`Experiment initialized: "${nextState.name}"${reinitNote}\n` +
`Metric: ${nextState.metricName} (${nextState.metricUnit || "unitless"}, ${nextState.bestDirection} is better)\n` +
"Config written to autoresearch.jsonl. Now run the baseline with run_experiment, then log it before starting another run.",
"Config written to autoresearch.jsonl. Now run the baseline with run_experiment, then log it before starting another run." +
carryForwardNote,
},
],
details: {
@@ -1,9 +1,10 @@
import * as fs from "node:fs";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { LogExperimentParams } from "./schemas.js";
import { commitKeptExperiment, readShortHeadCommit } from "../git.js";
import { commitKeptExperiment, readCurrentBranch, readShortHeadCommit } from "../git.js";
import { appendResultEntry, type AutoresearchResultEntry } from "../logging.js";
import { getAutoresearchRootFilePath } from "../files.js";
import { appendIdeaBacklogEntry } from "../ideas.js";
import {
readRecentLoggedRuns,
reconstructStateFromJsonl,
@@ -19,6 +20,8 @@ import {
import { resolveToolCwd } from "./tool-cwd.js";
import { readAutoresearchCheckpoint, writeAutoresearchCheckpoint } from "../checkpoint.js";
import { syncAutoresearchSessionDoc } from "../session-doc.js";
import { computeConfidence, formatConfidenceLine } from "../confidence.js";
import { acquireAutoresearchSessionLock } from "../session-lock.js";
export function createLogExperimentTool(api: OpenClawPluginApi) {
return {
@@ -35,6 +38,7 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
metric?: number;
status: "keep" | "discard" | "crash";
description: string;
idea?: string;
metrics?: Record<string, number>;
force?: boolean;
},
@@ -42,6 +46,26 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
_onUpdate: unknown,
) {
const cwd = resolveToolCwd(api, params.cwd);
const lockStatus = acquireAutoresearchSessionLock(cwd);
if (lockStatus.state === "active" && !lockStatus.ownedByCurrentProcess) {
return {
content: [
{
type: "text" as const,
text:
"Another autoresearch loop is already active for this repo.\n" +
`Lock owner PID: ${lockStatus.pid}\n` +
`Started: ${new Date(lockStatus.timestamp ?? 0).toISOString()}\n` +
"Resume that loop instead of logging results from a parallel session.",
},
],
details: {
status: "error",
phase: "lock",
},
};
}
const checkpoint = readAutoresearchCheckpoint(cwd);
const pendingRun = getAutoresearchPendingRun(cwd) ?? checkpoint?.pendingRun ?? null;
const state = reconstructStateFromJsonl(cwd);
@@ -74,6 +98,23 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
};
}
if (params.status === "discard" && !(params.idea ?? "").trim()) {
return {
content: [
{
type: "text" as const,
text:
"Discarded experiments must record what was learned.\n" +
'Provide idea with a short note like "cache key was too coarse; retry with per-file invalidation". It will be appended to autoresearch.ideas.md automatically.',
},
],
details: {
status: "error",
phase: "idea_required",
},
};
}
if (state.secondaryMetrics.length > 0) {
const validationError = validateSecondaryMetrics(
state.secondaryMetrics,
@@ -93,15 +134,18 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
const knownSecondaryMetrics = mergeSecondaryMetrics(state.secondaryMetrics, secondaryMetrics);
const currentResults = readCurrentSegmentResults(cwd, state.currentSegment);
const isBaselineRun = currentResults.length === 0;
const experiment: AutoresearchResultEntry = {
run: state.currentRunCount + 1,
commit: inferredCommit.slice(0, 7),
metric: inferredMetric,
metrics: secondaryMetrics,
status: params.status,
baseline: isBaselineRun,
description: params.description,
timestamp: Date.now(),
segment: state.currentSegment,
confidence: null,
};
let finalExperiment = experiment;
@@ -145,6 +189,21 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
};
}
finalExperiment = {
...finalExperiment,
confidence: computeConfidence(
[
...currentResults,
{
metric: finalExperiment.metric,
metrics: finalExperiment.metrics,
status: finalExperiment.status,
},
],
state.bestDirection,
),
};
try {
appendResultEntry(cwd, finalExperiment);
} catch (error) {
@@ -164,6 +223,12 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
};
}
let ideaAppended = false;
if ((params.idea ?? "").trim()) {
appendIdeaBacklogEntry(cwd, params.idea ?? "");
ideaAppended = true;
}
const baselineMetric =
currentResults.length > 0 ? currentResults[0].metric : experiment.metric;
const baselineSecondaryMetrics = findBaselineSecondaryMetrics(
@@ -174,10 +239,16 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
const queuedSteers = consumeAutoresearchSteers(cwd);
consumeAutoresearchPendingRun(cwd);
setAutoresearchRunInFlight(cwd, false);
const currentBranch = await readCurrentBranch({
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
cwd,
});
const nextCheckpoint = writeAutoresearchCheckpoint({
cwd,
state: nextState,
sessionStartCommit: checkpoint?.sessionStartCommit ?? experiment.commit,
canonicalBranch: checkpoint?.canonicalBranch ?? currentBranch,
carryForwardContext: checkpoint?.carryForwardContext ?? null,
recentLoggedRuns: readRecentLoggedRuns(cwd, 8),
pendingRun: null,
});
@@ -197,6 +268,9 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
knownSecondaryMetrics,
queuedSteers,
usedPendingRun: pendingRun !== null,
baseline: isBaselineRun,
ideaAppended,
confidence: finalExperiment.confidence,
}),
},
],
@@ -214,6 +288,7 @@ export function createLogExperimentTool(api: OpenClawPluginApi) {
type CurrentSegmentResult = {
readonly metric: number;
readonly metrics: Record<string, number>;
readonly status: "keep" | "discard" | "crash";
};
function validateSecondaryMetrics(
@@ -303,6 +378,10 @@ function readCurrentSegmentResults(cwd: string, segment: number): CurrentSegment
entry.metrics && typeof entry.metrics === "object"
? (entry.metrics as Record<string, number>)
: {},
status:
entry.status === "keep" || entry.status === "discard" || entry.status === "crash"
? entry.status
: "keep",
});
}
@@ -349,8 +428,13 @@ function buildResultText(options: {
knownSecondaryMetrics: readonly SecondaryMetricDef[];
queuedSteers: readonly string[];
usedPendingRun: boolean;
baseline: boolean;
ideaAppended: boolean;
confidence: number | null;
}): string {
let text = `Logged #${options.experiment.run}: ${options.experiment.status} - ${options.experiment.description}`;
let text = options.baseline
? `Logged #${options.experiment.run}: baseline - ${options.experiment.description}`
: `Logged #${options.experiment.run}: ${options.experiment.status} - ${options.experiment.description}`;
text += `\nBaseline ${options.state.metricName}: ${formatMetric(options.baselineMetric, options.state.metricUnit)}`;
if (options.experiment.run > 1 && options.experiment.status === "keep" && options.experiment.metric > 0) {
@@ -382,10 +466,20 @@ function buildResultText(options: {
text += `\nSecondary: ${parts.join(" ")}`;
}
if (options.confidence !== null) {
text += `\n${formatConfidenceLine(options.confidence)}`;
}
text += `\n(${options.totalRunCount} experiments in current segment)`;
if (options.usedPendingRun) {
text += "\nUsed the pending run_experiment result as the source of truth for commit/metric defaults.";
}
if (options.baseline) {
text += "\nThis run established the baseline for the current segment.";
}
if (options.ideaAppended) {
text += "\nAdded a follow-up idea to autoresearch.ideas.md.";
}
text += `\n${options.gitSummary}`;
if (options.queuedSteers.length > 0) {
@@ -8,10 +8,11 @@ import {
} from "../runtime-state.js";
import { resolveToolCwd } from "./tool-cwd.js";
import { parseMetricLines } from "../metrics.js";
import { readShortHeadCommit } from "../git.js";
import { readCurrentBranch, readShortHeadCommit } from "../git.js";
import { readAutoresearchCheckpoint, writeAutoresearchCheckpoint } from "../checkpoint.js";
import { readRecentLoggedRuns, reconstructStateFromJsonl } from "../state.js";
import { syncAutoresearchSessionDoc } from "../session-doc.js";
import { acquireAutoresearchSessionLock } from "../session-lock.js";
export function createRunExperimentTool(api: OpenClawPluginApi) {
return {
@@ -31,6 +32,26 @@ export function createRunExperimentTool(api: OpenClawPluginApi) {
onUpdate: ((update: unknown) => void | Promise<void>) | undefined,
) {
const cwd = resolveToolCwd(api, params.cwd);
const lockStatus = acquireAutoresearchSessionLock(cwd);
if (lockStatus.state === "active" && !lockStatus.ownedByCurrentProcess) {
return {
content: [
{
type: "text" as const,
text:
"Another autoresearch loop is already active for this repo.\n" +
`Lock owner PID: ${lockStatus.pid}\n` +
`Started: ${new Date(lockStatus.timestamp ?? 0).toISOString()}\n` +
"Resume that loop instead of running a parallel experiment session.",
},
],
details: {
status: "error",
phase: "lock",
},
};
}
const checkpoint = readAutoresearchCheckpoint(cwd);
const existingPendingRun = getAutoresearchPendingRun(cwd) ?? checkpoint?.pendingRun ?? null;
@@ -96,6 +117,10 @@ export function createRunExperimentTool(api: OpenClawPluginApi) {
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
cwd,
});
const currentBranch = await readCurrentBranch({
runCommandWithTimeout: api.runtime.system.runCommandWithTimeout,
cwd,
});
const pendingRun = {
command: params.command,
commit: currentCommit,
@@ -113,6 +138,8 @@ export function createRunExperimentTool(api: OpenClawPluginApi) {
cwd,
state,
sessionStartCommit: checkpoint?.sessionStartCommit ?? currentCommit,
canonicalBranch: checkpoint?.canonicalBranch ?? currentBranch,
carryForwardContext: checkpoint?.carryForwardContext ?? null,
recentLoggedRuns: readRecentLoggedRuns(cwd, 8),
pendingRun,
});
@@ -29,6 +29,12 @@ export const InitExperimentParams = Type.Object({
enum: ["lower", "higher"],
}),
),
reset: Type.Optional(
Type.Boolean({
description:
"Required when starting a new segment after experiments have already been logged. Preserves prior history but makes the reset explicit.",
}),
),
});
export const RunExperimentParams = Type.Object({
@@ -64,6 +70,12 @@ export const LogExperimentParams = Type.Object({
description: Type.String({
description: "Short description of what this experiment tried.",
}),
idea: Type.Optional(
Type.String({
description:
"Required when status is discard. Summarize what you learned and what you would try differently; this is appended to autoresearch.ideas.md.",
}),
),
metrics: Type.Optional(
Type.Record(Type.String(), Type.Number(), {
description:
+1 -1
View File
@@ -5,7 +5,7 @@
"skills": [
"./skills"
],
"version": "1.0.3",
"version": "1.0.5",
"configSchema": {
"type": "object",
"additionalProperties": false,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@gianfrancopiana/openclaw-autoresearch",
"version": "1.0.3",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gianfrancopiana/openclaw-autoresearch",
"version": "1.0.3",
"version": "1.0.5",
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "0.34.48"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gianfrancopiana/openclaw-autoresearch",
"version": "1.0.3",
"version": "1.0.5",
"description": "Faithful OpenClaw port of pi-autoresearch.",
"type": "module",
"main": "./index.ts",
+6 -3
View File
@@ -9,14 +9,14 @@ Autonomous experiment loop: try ideas, keep what works, discard what doesn't, ne
## Tools
- **`init_experiment`** — configure session (name, metric, unit, direction). Call again to re-initialize with a new baseline when the optimization target changes.
- **`init_experiment`** — configure session (name, metric, unit, direction). Once runs exist, calling it again requires `reset: true` to start a new segment explicitly.
- **`run_experiment`** — runs the benchmark command, times it, captures output, parses `METRIC name=number` lines, and opens a pending run that must be logged before another run can start.
- **`log_experiment`** — records the pending run. `keep` auto-commits. `discard`/`crash``git checkout -- .` to revert. If the previous `run_experiment` captured the primary metric, `commit` and `metric` can be omitted and will default from the pending run.
- **`log_experiment`** — records the pending run. The first logged run in a segment becomes the baseline automatically. `keep` auto-commits. `discard`/`crash``git checkout -- .` to revert. `discard` also requires an `idea` note so the failed path gets appended to `autoresearch.ideas.md`. If the previous `run_experiment` captured the primary metric, `commit` and `metric` can be omitted and will default from the pending run.
## Setup
1. Ask (or infer): **Goal**, **Command**, **Metric** (+ direction), **Files in scope**, **Constraints**.
2. `git checkout -b autoresearch/<goal>-<date>`
2. If `autoresearch.checkpoint.json` already names a canonical branch, reuse it. Otherwise create one with `git checkout -b autoresearch/<goal>-<date>` and keep using that branch for the whole loop.
3. Read the source files. Understand the workload deeply before writing anything.
4. Write `autoresearch.md` and `autoresearch.sh` (see below). Commit both.
5. `init_experiment``run_experiment` baseline → `log_experiment` → start looping immediately.
@@ -68,7 +68,10 @@ Bash script (`set -euo pipefail`) that: pre-checks fast (syntax errors in <1s),
- **Crashes:** fix if trivial, otherwise log and move on. Don't over-invest.
- **Think longer when stuck.** Re-read source files, study the profiling data, reason about what the CPU is actually doing. The best ideas come from deep understanding, not from trying random variations.
- **Resuming:** if `autoresearch.md` exists, read it plus `autoresearch.checkpoint.json`, then continue looping.
- **Respect the canonical branch.** If the checkpoint names a canonical branch, switch back to it before resuming. Do not create a fresh autoresearch branch for every session.
- **Respect the lock file.** If `autoresearch.lock` exists and the PID is alive, another loop is active. Resume that loop instead of forking a second session.
- **No raw benchmark exec:** during active autoresearch mode, benchmark/test commands should go through `run_experiment`, not raw `exec`/`bash`.
- **Reset explicitly.** If you need a new segment, call `init_experiment` with `reset: true`; do not silently wipe the current baseline.
**NEVER STOP.** The user may be away for hours. Keep going until interrupted.
+1
View File
@@ -61,6 +61,7 @@ describe("buildAutoresearchCommandText", () => {
expect(text).toContain("Mode: active");
expect(text).toContain("Runtime mode: auto");
expect(text).toContain("Baseline: 101ms");
expect(text).toContain("Confidence: n/a");
});
it("registers a mode-aware /autoresearch command that primes resume instructions", () => {
+112
View File
@@ -49,6 +49,7 @@ async function initExperiment(
metric_name: string;
metric_unit?: string;
direction?: "lower" | "higher";
reset?: boolean;
},
) {
return await createInitExperimentTool(createApi(cwd) as never).execute(
@@ -82,6 +83,7 @@ async function logExperiment(
metric?: number;
status: "keep" | "discard" | "crash";
description: string;
idea?: string;
metrics?: Record<string, number>;
force?: boolean;
},
@@ -139,6 +141,44 @@ describe("experiment lifecycle tools", () => {
]);
});
it("requires reset: true before starting a new segment after logged runs exist", async () => {
const cwd = createTempDir("autoresearch-reset-required-");
await initExperiment(cwd, {
name: "Parser optimization",
metric_name: "total_ms",
metric_unit: "ms",
direction: "lower",
});
fs.appendFileSync(
getAutoresearchRootFilePath(cwd, "resultsLog"),
`${JSON.stringify({
run: 1,
commit: "abc1234",
metric: 120,
metrics: {},
status: "keep",
baseline: true,
description: "baseline",
timestamp: 1700000000000,
segment: 0,
})}\n`,
);
const result = await initExperiment(cwd, {
name: "Parser optimization v2",
metric_name: "total_ms",
metric_unit: "ms",
direction: "lower",
});
expect(result.details).toMatchObject({
status: "error",
phase: "reset_required",
});
expect((result.content[0] as { text: string }).text).toContain("reset: true");
});
it("appends a new config header on re-init instead of overwriting prior history", async () => {
const cwd = createTempDir("autoresearch-reinit-");
@@ -156,6 +196,7 @@ describe("experiment lifecycle tools", () => {
metric: 120,
metrics: {},
status: "keep",
baseline: true,
description: "baseline",
timestamp: 1700000000000,
segment: 0,
@@ -167,6 +208,7 @@ describe("experiment lifecycle tools", () => {
metric_name: "total_ms",
metric_unit: "ms",
direction: "lower",
reset: true,
});
expect(result.content[0]?.type).toBe("text");
@@ -185,6 +227,7 @@ describe("experiment lifecycle tools", () => {
metric: 120,
metrics: {},
status: "keep",
baseline: true,
description: "baseline",
timestamp: 1700000000000,
segment: 0,
@@ -204,6 +247,9 @@ describe("experiment lifecycle tools", () => {
currentRunCount: 0,
},
});
expect(fs.readFileSync(path.join(cwd, "autoresearch.checkpoint.json"), "utf8")).toContain(
'"carryForwardContext"',
);
});
it("reports a successful run and emits a running update", async () => {
@@ -311,6 +357,7 @@ describe("experiment lifecycle tools", () => {
metric: 120,
status: "discard",
description: "baseline",
idea: "baseline is noisy; retry with warm cache isolation",
metrics: {
compile_ms: 15,
},
@@ -326,8 +373,10 @@ describe("experiment lifecycle tools", () => {
compile_ms: 15,
},
status: "discard",
baseline: true,
description: "baseline",
segment: 0,
confidence: null,
});
expect(result.details).toMatchObject({
status: "ok",
@@ -345,9 +394,15 @@ describe("experiment lifecycle tools", () => {
attempted: false,
},
});
expect((result.content[0] as { text: string }).text).toContain(
"This run established the baseline for the current segment.",
);
expect((result.content[0] as { text: string }).text).toContain(
"Git: skipped commit (discard) - revert tracked changes yourself with git checkout -- .",
);
expect(fs.readFileSync(path.join(cwd, "autoresearch.ideas.md"), "utf8")).toContain(
"baseline is noisy; retry with warm cache isolation",
);
});
it("validates missing and newly added secondary metrics unless forced", async () => {
@@ -358,6 +413,7 @@ describe("experiment lifecycle tools", () => {
metric: 120,
status: "discard",
description: "baseline",
idea: "record compile_ms on every run before comparing changes",
metrics: {
compile_ms: 15,
},
@@ -369,6 +425,7 @@ describe("experiment lifecycle tools", () => {
metric: 110,
status: "discard",
description: "missing secondary metric",
idea: "keep compile_ms in the log for comparability",
metrics: {},
});
@@ -385,6 +442,7 @@ describe("experiment lifecycle tools", () => {
metric: 110,
status: "discard",
description: "add bundle size",
idea: "bundle_kb looks useful enough to track on future runs",
metrics: {
compile_ms: 12,
bundle_kb: 7,
@@ -404,6 +462,7 @@ describe("experiment lifecycle tools", () => {
metric: 110,
status: "discard",
description: "add bundle size",
idea: "bundle_kb looks useful enough to track on future runs",
metrics: {
compile_ms: 12,
bundle_kb: 7,
@@ -464,6 +523,7 @@ describe("experiment lifecycle tools", () => {
metric: 130,
status: "discard",
description: "discard regression",
idea: "regression suggests parser cache invalidation is too broad",
});
expect(discardResult.details).toMatchObject({
@@ -508,6 +568,56 @@ describe("experiment lifecycle tools", () => {
expect(statuses).toEqual(["keep", "discard", "crash"]);
});
it("computes and persists the upstream-style confidence score after enough runs", async () => {
const cwd = createTempDir("autoresearch-confidence-");
await seedExperiment(cwd);
await logExperiment(cwd, {
commit: "abc1234",
metric: 100,
status: "keep",
description: "baseline",
});
await logExperiment(cwd, {
commit: "def5678",
metric: 99,
status: "discard",
description: "noise sample",
idea: "rerun without the optimization to confirm the noise floor",
});
const result = await logExperiment(cwd, {
commit: "fedcba9",
metric: 95,
status: "keep",
description: "confirmed improvement",
});
expect(result.details).toMatchObject({
status: "ok",
experiment: {
confidence: 5,
},
state: {
confidence: 5,
},
});
expect((result.content[0] as { text: string }).text).toContain(
"Confidence: 5.0x noise floor - improvement is likely real",
);
const rows = readJsonl(cwd).filter((entry) => entry.type !== "config");
expect(rows[2]).toMatchObject({
confidence: 5,
});
expect(fs.readFileSync(path.join(cwd, "autoresearch.checkpoint.json"), "utf8")).toContain(
'"confidence": 5',
);
expect(fs.readFileSync(path.join(cwd, "autoresearch.md"), "utf8")).toContain(
"Confidence: 5.0x noise floor - improvement is likely real",
);
});
it("keeps the experiment window open across run_experiment and surfaces queued steers in log_experiment", async () => {
const cwd = createTempDir("autoresearch-queued-steers-");
await seedExperiment(cwd);
@@ -528,6 +638,7 @@ describe("experiment lifecycle tools", () => {
metric: 120,
status: "discard",
description: "baseline",
idea: "parser cache might still help if keyed on file contents",
});
expect((result.content[0] as { text: string }).text).toContain(
@@ -581,6 +692,7 @@ describe("experiment lifecycle tools", () => {
const result = await logExperiment(cwd, {
status: "discard",
description: "baseline",
idea: "compile_ms should stay in the log for future comparisons",
});
expect(result.details).toMatchObject({
+2
View File
@@ -16,6 +16,7 @@ describe("reconstructStateFromJsonl", () => {
expect(state.totalRunCount).toBe(4);
expect(state.currentBaselineMetric).toBe(130);
expect(state.currentBestMetric).toBe(118);
expect(state.confidence).toBeNull();
expect(state.lastRun).toMatchObject({
run: 2,
commit: "7654321",
@@ -23,6 +24,7 @@ describe("reconstructStateFromJsonl", () => {
status: "keep",
description: "keep winner",
segment: 1,
confidence: null,
});
expect(state.secondaryMetrics).toEqual([
{ name: "compile_ms", unit: "ms" },
+10
View File
@@ -15,6 +15,13 @@ describe("formatAutoresearchStatusText", () => {
warnings: ["2 commits since the last logged experiment (7654321)."],
checkpoint: null,
gitHead: "89abcde",
gitBranch: "autoresearch/parser-cache",
lock: {
state: "missing",
pid: null,
timestamp: null,
ownedByCurrentProcess: false,
},
};
const text = formatAutoresearchStatusText(
reconstructStateFromJsonl(activeSessionFixture),
@@ -31,8 +38,11 @@ describe("formatAutoresearchStatusText", () => {
expect(text).toContain("Metric: total_ms (ms, lower is better)");
expect(text).toContain("Runs: 2 current / 4 total");
expect(text).toContain("Best kept: 118ms");
expect(text).toContain("Confidence: n/a");
expect(text).toContain("Last run: #2 keep 118ms 7654321 keep winner");
expect(text).toContain("Git HEAD: 89abcde");
expect(text).toContain("Current branch: autoresearch/parser-cache");
expect(text).toContain("Session lock: missing");
expect(text).toContain("Warnings:");
expect(text).toContain("2 commits since the last logged experiment");
expect(text).toContain(
+4 -2
View File
@@ -115,6 +115,7 @@ describe("autoresearch tools", () => {
metric: 5,
status: "discard",
description: "baseline",
idea: "keep a discard note so follow-up experiments are not lost",
},
new AbortController().signal,
undefined,
@@ -122,7 +123,7 @@ describe("autoresearch tools", () => {
expect(api.resolvePath).toHaveBeenCalledWith(".");
expect(result.details).toMatchObject({ status: "ok" });
expect(result.content[0]?.text).toContain("Logged #1: discard - baseline");
expect(result.content[0]?.text).toContain("Logged #1: baseline - baseline");
});
it("supports explicit cwd overrides for nested repo tool execution", async () => {
@@ -179,12 +180,13 @@ describe("autoresearch tools", () => {
metric: 5,
status: "discard",
description: "baseline",
idea: "keep a discard note so follow-up experiments are not lost",
},
new AbortController().signal,
undefined,
);
expect(logResult.details).toMatchObject({ status: "ok" });
expect(logResult.content[0]?.text).toContain("Logged #1: discard - baseline");
expect(logResult.content[0]?.text).toContain("Logged #1: baseline - baseline");
});
});