AkurAI Build
Menu

popagent

public

Latest change 0ae5d6f2d0cff07db8b0c9ef89afaba73cbcbfd3 - Require evidence for autonomous no-ops by AkurAI Build

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/ornith-1.0-9b-mtp-q4_k_m";
export const SELF_UPDATE_SCHEDULE_NAME = "[system:self-update] Autonomous source maintenance";
export const SELF_UPDATE_PROMPT = `Find and complete one small, high-confidence improvement in the registered repository.

Use the built-in autonomous-improvement and popagent-cli skills. A clean Git status or empty starting diff is baseline evidence only; it does not prove that no improvement exists.

Follow this sequence:
1. Read repository rules, exact task evidence, and operator-approved scope.
2. Delegate bounded inspection to the Researcher. Check concrete candidates in this order: correctness or incomplete error handling; dead or unreachable code with call-site evidence; reliability, security, or performance defects; then maintainability defects with demonstrable operational cost.
3. Select at most one candidate. Prefer correctness over cosmetic cleanup. No broad refactors, dependency churn, subsystem redesign, or unrelated cleanup.
4. Return a no-op only after recording paths and symbols inspected, deterministic evidence used, candidates considered, and why each candidate was invalid, already fixed, unsafe, or outside scope. A clean checkout alone is never sufficient no-op evidence.
5. For a valid candidate, state the observable defect and acceptance criterion, then delegate the smallest complete implementation to the Implementer.
6. Delegate independent verification to the Reviewer after the Implementer returns. Require exact changed files, checks run, failures, and residual risk.
7. Require the Implementer to commit the changed reviewed result on the prepared autonomous/self-update branch with --no-verify and --no-gpg-sign.
8. 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.`;
export const BUILD_MAINTENANCE_SOURCE = "build-maintenance";
export const BUILD_MAINTENANCE_MODEL = "titan/ornith-1.0-9b-mtp-q4_k_m";
export const BUILD_MAINTENANCE_SCHEDULE_NAME = "[system:build-maintenance] AkurAI-Build reviewed maintenance";
export const BUILD_MAINTENANCE_PROMPT = `Run one bounded, reviewable maintenance cycle for the writable AkurAI-Build repository.

Use the Build Maintainer agent and an independent Reviewer. The Build Maintainer must:
1. Inspect failed CI runs, delivery metrics, and open community issues through the governed AkurAI Build tools. Read the repository rules and current branch evidence before deciding.
2. Choose at most one small, high-confidence source or pipeline improvement, or choose a no-op when evidence is insufficient. Never broaden scope or modify production configuration.
3. For a change, edit only the contained checkout, update its CHANGELOG.md Unreleased section, run the repository's focused verification, and commit the result to the prepared autonomous/build-maintenance/* branch.
4. Have the independent Reviewer inspect the diff, verification output, and failure reason. Reject unsafe, unrelated, unverified, or credential-bearing changes.
5. Queue CI for the exact branch/ref through the governed integration and publish branch/run/issue evidence as an issue and comment. Include cancellation, restart, retry, and failure lineage when applicable.

Return concise evidence: source, workspace, branch, commit, CI run, issue/comment, checks, outcome, and failure reason. A no-op still publishes the inspected evidence. Never advance main, push manually, promote a protected environment, deploy, restart services, use Release Manager, or call production promotion/deploy tools.`;
const RECONCILE_INTERVAL_MS = 60_000;

type TaskScheduleRepository = Pick<
  AgentTaskStore,
  "upsertSelfUpdateSchedule" | "disableSelfUpdateSchedules"
> & Partial<Pick<
  AgentTaskStore,
  "upsertBuildMaintenanceSchedule" | "disableBuildMaintenanceSchedules"
>>;
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) {
      await Promise.all([
        this.tasks.disableSelfUpdateSchedules(),
        this.tasks.disableBuildMaintenanceSchedules?.(),
      ]);
      return;
    }

    const [supervisor, implementer, reviewer, buildMaintainer, workspaces] = await Promise.all([
      this.agents.get("orchistrator"),
      this.agents.get("implementer"),
      this.agents.get("reviewer"),
      this.agents.get("build-maintainer"),
      this.workspaces.list(),
    ]);
    const reviewerEligible = reviewer?.workspaceAccess !== "none" && reviewer?.delegationEnabled;
    const selfUpdateRolesEligible = supervisor?.workspaceAccess === "read-write"
      && implementer?.workspaceAccess === "read-write"
      && implementer.delegationEnabled
      && reviewerEligible;
    const buildRolesEligible = buildMaintainer?.workspaceAccess === "read-write"
      && buildMaintainer.delegationEnabled
      && reviewerEligible;
    const selected = new Set(settings.idleWorkspaceIds);
    const selfUpdateWorkspaceIds: string[] = [];
    const buildWorkspaceIds: 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;

      if (settings.selfUpdateEnabled && selfUpdateRolesEligible) {
        selfUpdateWorkspaceIds.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,
        });
      }

      const isBuildWorkspace = workspace.name.trim().toLowerCase() === "akurai-build"
        || /(?:^|[\\/])akurai-build$/.test(workspace.repositoryPath.trim().toLowerCase());
      if (isBuildWorkspace && buildRolesEligible && this.tasks.upsertBuildMaintenanceSchedule) {
        buildWorkspaceIds.push(workspace.id);
        await this.tasks.upsertBuildMaintenanceSchedule({
          workspaceId: workspace.id,
          name: BUILD_MAINTENANCE_SCHEDULE_NAME,
          prompt: BUILD_MAINTENANCE_PROMPT,
          model: BUILD_MAINTENANCE_MODEL,
          maxAttempts: settings.maxAttempts,
          cron: settings.selfUpdateCron,
          enabled: true,
        });
      }
    }

    if (!settings.selfUpdateEnabled || !selfUpdateRolesEligible) {
      await this.tasks.disableSelfUpdateSchedules();
    } else {
      await this.tasks.disableSelfUpdateSchedules(selfUpdateWorkspaceIds);
    }
    if (!buildRolesEligible || !settings.idleImprovementEnabled) {
      await this.tasks.disableBuildMaintenanceSchedules?.();
    } else {
      await this.tasks.disableBuildMaintenanceSchedules?.(buildWorkspaceIds);
    }
  }
}