Menu
popagent
publicLatest change 682410a285bb0a73662cc4650c92c31c918e1cc9 - Add idle autonomous improvement workflow by Ólafur Búi Ólafsson
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import { join } from "node:path";
import { agentSettings } from "./agent-settings";
import { agentWorkspaces, type AgentWorkspaceStore } from "./agent-workspaces";
import type { AgentSettings } from "./api-types";
import { autonomySettings, type AutonomySettingsStore } from "./autonomy-settings";
import { appLogger } from "./observability";
import type { AgentTaskStore } from "./tasks";
export const SELF_UPDATE_SOURCE = "self-update";
export const SELF_UPDATE_MODEL = "titan/qwen3.5-4b";
export const SELF_UPDATE_SCHEDULE_NAME = "[system:self-update] Autonomous source maintenance";
export const SELF_UPDATE_PROMPT = `Run exactly one bounded autonomous improvement for the registered workspace.
Use the built-in autonomous-improvement and popagent-cli skills. Follow this sequence:
1. Read repository rules, current task evidence, and operator-approved scope. If there is no high-confidence useful change, return a clean no-op.
2. Delegate repository inspection to the researcher only when evidence is incomplete.
3. Delegate one smallest complete implementation to the implementer. No broad refactors, dependency churn, or unrelated cleanup.
4. Delegate independent verification to the reviewer. Require exact changed files, checks run, failures, and residual risk.
5. Require the implementer to commit the reviewed result on the prepared autonomous/self-update branch with --no-verify and --no-gpg-sign.
6. Return one concise outcome: no-op, completed, or blocked, with verification evidence.
Never acquire credentials, access files outside the contained checkout, change remotes or hooks, weaken authentication/security/execution limits, or bypass repository release policy. Server-owned policy performs optional publish and deployment only after the contained commit passes every gate.`;
const RECONCILE_INTERVAL_MS = 60_000;
type TaskScheduleRepository = Pick<
AgentTaskStore,
"upsertSelfUpdateSchedule" | "disableSelfUpdateSchedules"
>;
type AutonomySettingsRepository = Pick<AutonomySettingsStore, "get" | "subscribe">;
type WorkspaceRepository = Pick<AgentWorkspaceStore, "list" | "resolveRepository">;
type AgentAccessRepository = {
get(id: string): Promise<
Pick<AgentSettings, "workspaceAccess" | "delegationEnabled"> | undefined
>;
};
type SelfUpdateSchedulerDependencies = {
tasks: TaskScheduleRepository;
settings?: AutonomySettingsRepository;
workspaces?: WorkspaceRepository;
agents?: AgentAccessRepository;
reconcileIntervalMs?: number;
isWritableRepository?: (path: string) => Promise<boolean>;
};
async function isWritableGitRepository(path: string): Promise<boolean> {
try {
await access(path, constants.W_OK);
await access(join(path, ".git"), constants.R_OK | constants.W_OK);
return true;
} catch {
return false;
}
}
export class SelfUpdateScheduler {
private readonly tasks: TaskScheduleRepository;
private readonly settings: AutonomySettingsRepository;
private readonly workspaces: WorkspaceRepository;
private readonly agents: AgentAccessRepository;
private readonly reconcileIntervalMs: number;
private readonly isWritableRepository: (path: string) => Promise<boolean>;
private timer?: Timer;
private unsubscribe?: () => void;
private reconciling?: Promise<void>;
private reconcileRequested = false;
private lifecycle = Promise.resolve();
private starting?: Promise<void>;
private stopping?: Promise<void>;
private desiredRunning = false;
constructor(dependencies: SelfUpdateSchedulerDependencies) {
this.tasks = dependencies.tasks;
this.settings = dependencies.settings ?? autonomySettings;
this.workspaces = dependencies.workspaces ?? agentWorkspaces;
this.agents = dependencies.agents ?? agentSettings;
this.reconcileIntervalMs = dependencies.reconcileIntervalMs ?? RECONCILE_INTERVAL_MS;
this.isWritableRepository = dependencies.isWritableRepository ?? isWritableGitRepository;
}
start(): Promise<void> {
this.desiredRunning = true;
if (this.starting) return this.starting;
const operation = this.serializeLifecycle(async () => {
if (!this.desiredRunning || this.timer) return;
const unsubscribe = this.settings.subscribe(() => this.reconcileSafely());
this.unsubscribe = unsubscribe;
try {
await this.reconcile();
} catch (error) {
unsubscribe();
if (this.unsubscribe === unsubscribe) this.unsubscribe = undefined;
throw error;
}
if (!this.desiredRunning) {
unsubscribe();
if (this.unsubscribe === unsubscribe) this.unsubscribe = undefined;
return;
}
this.timer = setInterval(() => this.reconcileSafely(), this.reconcileIntervalMs);
});
const pending = operation.finally(() => {
if (this.starting === pending) this.starting = undefined;
});
this.starting = pending;
return pending;
}
stop(): Promise<void> {
this.desiredRunning = false;
if (this.stopping) return this.stopping;
const operation = this.serializeLifecycle(async () => {
if (this.desiredRunning) return;
clearInterval(this.timer);
this.timer = undefined;
this.unsubscribe?.();
this.unsubscribe = undefined;
await this.reconciling;
});
const pending = operation.finally(() => {
if (this.stopping === pending) this.stopping = undefined;
});
this.stopping = pending;
return pending;
}
private serializeLifecycle(operation: () => Promise<void>): Promise<void> {
const result = this.lifecycle.then(operation, operation);
this.lifecycle = result.catch(() => undefined);
return result;
}
reconcile(): Promise<void> {
this.reconcileRequested = true;
if (this.reconciling) return this.reconciling;
this.reconciling = (async () => {
while (this.reconcileRequested) {
this.reconcileRequested = false;
await this.reconcileOnce();
}
})().finally(() => {
this.reconciling = undefined;
});
return this.reconciling;
}
private reconcileSafely(): void {
if (!this.desiredRunning) return;
void this.reconcile().catch((error) => {
appLogger().error("self_update.schedule_failed", { error });
});
}
private async reconcileOnce(): Promise<void> {
const settings = await this.settings.get();
if (!settings.enabled || !settings.selfUpdateEnabled) {
await this.tasks.disableSelfUpdateSchedules();
return;
}
const [supervisor, implementer, reviewer, workspaces] = await Promise.all([
this.agents.get("orchistrator"),
this.agents.get("implementer"),
this.agents.get("reviewer"),
this.workspaces.list(),
]);
const rolesEligible = supervisor?.workspaceAccess === "read-write"
&& implementer?.workspaceAccess === "read-write"
&& implementer.delegationEnabled
&& reviewer?.workspaceAccess !== "none"
&& reviewer?.delegationEnabled;
if (!rolesEligible) {
await this.tasks.disableSelfUpdateSchedules();
return;
}
const selected = new Set(settings.idleWorkspaceIds);
const writableWorkspaceIds: string[] = [];
for (const workspace of workspaces) {
if (!settings.idleImprovementEnabled || !selected.has(workspace.id)) continue;
let repositoryPath: string;
try {
repositoryPath = (await this.workspaces.resolveRepository(workspace.id)).path;
} catch {
continue;
}
if (!(await this.isWritableRepository(repositoryPath))) continue;
writableWorkspaceIds.push(workspace.id);
await this.tasks.upsertSelfUpdateSchedule({
workspaceId: workspace.id,
name: SELF_UPDATE_SCHEDULE_NAME,
prompt: SELF_UPDATE_PROMPT,
model: SELF_UPDATE_MODEL,
maxAttempts: settings.maxAttempts,
cron: settings.selfUpdateCron,
enabled: true,
});
}
await this.tasks.disableSelfUpdateSchedules(writableWorkspaceIds);
}
}