diff --git a/README.md b/README.md index 87d9169..eec7132 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,13 @@ You are an autonomous coding agent working on {{ issue.identifier }}: {{ issue.t | `agent.max_retry_backoff_ms` | `300000` | Maximum retry back-off (ms) for failed agents | | `agent.max_concurrent_agents_by_state` | `{}` | Per-state concurrency cap, e.g. `{ "in progress": 2 }` | | `server.port` | `7777` | Port for the status HTTP server (loopback only) | +| `auto_update.enabled` | `true` | Periodically pull new commits from the Symphony git remote, rebuild, and restart | +| `auto_update.interval_ms` | `300000` | Poll interval (ms) for the self-updater | +| `auto_update.remote` | `origin` | Git remote to fetch from | +| `auto_update.branch` | current branch | Branch to track on the remote; defaults to whichever branch Symphony is checked out on | +| `auto_update.repo_root` | Symphony checkout | Absolute path to the Symphony git working tree (rarely needs overriding) | +| `auto_update.build_command` | `npm run build` | Command run after a successful pull | +| `auto_update.install_command` | `npm install` | Command run when `package.json` or `package-lock.json` changes | #### Prompt template variables @@ -233,8 +240,29 @@ node dist/index.js /path/to/WORKFLOW.md # Override the status server port node dist/index.js --port 8080 + +# Start under the supervisor wrapper so self-updates are picked up automatically +./bin/symphony-supervisor.sh # forwards args to dist/index.js +# or equivalently: +npm run start:watch ``` +### Auto-update from GitHub + +When `auto_update.enabled` is `true` (the default), Symphony periodically: + +1. Runs `git fetch ` against its own checkout. +2. If new commits exist and the working tree is clean, fast-forward pulls them. +3. Re-runs the install command (only when `package.json` or `package-lock.json` changed) and then the build command. +4. Exits with code **75** to ask the supervisor wrapper to relaunch Symphony on the fresh build. + +The in-process self-updater always exits on update — actual restart is performed by `bin/symphony-supervisor.sh`. Run Symphony under the supervisor (or any process manager that re-runs on exit code 75, e.g. systemd with `RestartForceExitStatus=75`) to get hands-off updates. If launched directly with `node dist/index.js`, Symphony will still pull and rebuild but exit instead of restarting. + +Self-update is skipped — never destructive — when: +- The working tree has uncommitted changes, +- The local branch is ahead of the remote, or +- HEAD is detached and `auto_update.branch` is not set. + Symphony will: 1. Validate configuration 2. Fetch the Linear team URL for display in the TUI diff --git a/bin/symphony-supervisor.sh b/bin/symphony-supervisor.sh new file mode 100755 index 0000000..996ca12 --- /dev/null +++ b/bin/symphony-supervisor.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Symphony supervisor: launches `node dist/index.js` and re-launches it whenever +# Symphony exits with code 75 (the in-process self-updater's "restart" signal). +# Any other exit code is propagated to the caller. SIGINT/SIGTERM are forwarded +# to the child and end the loop. +# +# Usage: bin/symphony-supervisor.sh [args passed through to symphony] + +set -u + +# Resolve the Symphony repo root from this script's location. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +RESTART_EXIT_CODE=75 +child_pid="" +terminating=0 + +forward_signal() { + local sig="$1" + terminating=1 + if [[ -n "$child_pid" ]] && kill -0 "$child_pid" 2>/dev/null; then + kill "-$sig" "$child_pid" 2>/dev/null || true + fi +} + +trap 'forward_signal TERM' TERM +trap 'forward_signal INT' INT + +while true; do + node "$REPO_ROOT/dist/index.js" "$@" & + child_pid=$! + wait "$child_pid" + code=$? + child_pid="" + + if (( terminating == 1 )); then + exit "$code" + fi + + if (( code == RESTART_EXIT_CODE )); then + echo "[symphony-supervisor] restart requested by self-updater; relaunching" >&2 + continue + fi + + exit "$code" +done diff --git a/package-lock.json b/package-lock.json index 35d787a..b9f4b60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "liquidjs": "^10.21.0" }, "bin": { - "symphony": "dist/index.js" + "symphony": "dist/index.js", + "symphony-status": "dist/status.js", + "symphony-supervisor": "bin/symphony-supervisor.sh" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/package.json b/package.json index d2e65ce..a656c60 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,14 @@ "type": "module", "bin": { "symphony": "./dist/index.js", - "symphony-status": "./dist/status.js" + "symphony-status": "./dist/status.js", + "symphony-supervisor": "./bin/symphony-supervisor.sh" }, "scripts": { "build": "tsc", "dev": "tsx src/index.ts", "start": "node dist/index.js", + "start:watch": "./bin/symphony-supervisor.sh", "status": "node dist/status.js" }, "dependencies": { diff --git a/src/config.ts b/src/config.ts index c253267..8705c96 100644 --- a/src/config.ts +++ b/src/config.ts @@ -80,6 +80,7 @@ function buildConfig(raw: Record, baseDir: string): WorkflowCon const hooks = ((raw.hooks ?? {}) as Record); const agent = ((raw.agent ?? {}) as Record); const server = raw.server as Record | undefined; + const autoUpdate = ((raw.auto_update ?? {}) as Record); const apiKeyRaw = (tracker.api_key as string | undefined) ?? "$LINEAR_API_KEY"; const apiKey = resolveEnvVar(apiKeyRaw); @@ -130,6 +131,17 @@ function buildConfig(raw: Record, baseDir: string): WorkflowCon maxConcurrentAgentsByState, }, server: server ? { port: server.port as number | undefined } : undefined, + autoUpdate: { + enabled: (autoUpdate.enabled as boolean | undefined) ?? true, + intervalMs: (autoUpdate.interval_ms as number | undefined) ?? 300000, + remote: (autoUpdate.remote as string | undefined) ?? "origin", + branch: (autoUpdate.branch as string | undefined) ?? null, + repoRoot: typeof autoUpdate.repo_root === "string" + ? resolvePath(autoUpdate.repo_root, baseDir) + : null, + buildCommand: (autoUpdate.build_command as string | undefined) ?? "npm run build", + installCommand: (autoUpdate.install_command as string | undefined) ?? "npm install", + }, }; } diff --git a/src/index.ts b/src/index.ts index af93d15..a776fa7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,12 @@ import "dotenv/config"; import * as path from "node:path"; import { Orchestrator } from "./orchestrator.js"; import { startStatusServer, type StatusServer } from "./server.js"; +import { SelfUpdater } from "./self-update.js"; import type { Logger } from "./types.js"; const DEFAULT_STATUS_PORT = 7777; +/** Exit code used to tell the supervisor wrapper to restart Symphony. */ +const RESTART_EXIT_CODE = 75; function createLogger(): Logger { function log(level: string, msg: string, context?: Record): void { @@ -70,22 +73,24 @@ async function main(): Promise { const port = portArg ?? cfgPort ?? DEFAULT_STATUS_PORT; let statusServer: StatusServer | null = null; + let selfUpdater: SelfUpdater | null = null; let stopping = false; - const shutdown = (): void => { + const shutdown = (exitCode: number = 0): void => { if (stopping) return; stopping = true; - logger.info("Shutdown requested"); + logger.info("Shutdown requested", { exit_code: String(exitCode) }); + selfUpdater?.stop(); orchestrator.stop(); if (statusServer) { statusServer.close().catch((e) => logger.warn(`Status server close failed: ${String(e)}`)); } // Give in-flight agents a moment to clean up - setTimeout(() => process.exit(0), 2000); + setTimeout(() => process.exit(exitCode), 2000); }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + process.on("SIGINT", () => shutdown(0)); + process.on("SIGTERM", () => shutdown(0)); try { await orchestrator.start(); @@ -101,6 +106,15 @@ async function main(): Promise { logger.warn(`Status server failed to start on port ${port}: ${String(e)}`); } + // Self-updater: periodically pull new commits, rebuild, then exit with + // RESTART_EXIT_CODE so the supervisor wrapper relaunches us on fresh code. + selfUpdater = new SelfUpdater({ + config: orchestrator.getConfigSnapshot().autoUpdate, + logger, + onRestartRequested: () => shutdown(RESTART_EXIT_CODE), + }); + selfUpdater.start().catch(e => logger.warn(`Self-updater failed to start: ${String(e)}`)); + // Print a human-friendly status line every 60s setInterval(() => { const snap = orchestrator.getSnapshot(); diff --git a/src/self-update.ts b/src/self-update.ts new file mode 100644 index 0000000..5ad7161 --- /dev/null +++ b/src/self-update.ts @@ -0,0 +1,250 @@ +import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import type { AutoUpdateConfig, Logger } from "./types.js"; + +const execFileP = promisify(execFile); + +export interface SelfUpdaterOptions { + config: AutoUpdateConfig; + logger: Logger; + /** Invoked once an update has been pulled + built. Should perform graceful shutdown + restart. */ + onRestartRequested: () => void; +} + +interface ResolvedContext { + repoRoot: string; + branch: string; + remote: string; +} + +/** + * Periodically polls a git remote for new commits on the tracked branch. + * On update: fast-forward pulls, runs install (when lockfile/manifest changed) + * and the build command, then asks the host process to restart via the + * supplied callback. + */ +export class SelfUpdater { + private readonly cfg: AutoUpdateConfig; + private readonly log: Logger; + private readonly onRestartRequested: () => void; + private timer: ReturnType | null = null; + private context: ResolvedContext | null = null; + /** Reentrancy guard — true while an update cycle is in progress. */ + private cycleInFlight = false; + /** Set once we've fired the restart callback to avoid loops. */ + private restartRequested = false; + + constructor(opts: SelfUpdaterOptions) { + this.cfg = opts.config; + this.log = opts.logger; + this.onRestartRequested = opts.onRestartRequested; + } + + async start(): Promise { + if (!this.cfg.enabled) { + this.log.info("Self-update disabled"); + return; + } + + try { + this.context = await this.resolveContext(); + } catch (e) { + this.log.warn(`Self-update disabled: ${errMsg(e)}`); + return; + } + + this.log.info("Self-update enabled", { + repo_root: this.context.repoRoot, + remote: this.context.remote, + branch: this.context.branch, + interval_ms: String(this.cfg.intervalMs), + }); + + this.schedule(this.cfg.intervalMs); + } + + stop(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private schedule(delayMs: number): void { + this.timer = setTimeout(() => void this.tick(), delayMs); + } + + private async tick(): Promise { + if (this.restartRequested) return; + if (this.cycleInFlight) { + this.schedule(this.cfg.intervalMs); + return; + } + this.cycleInFlight = true; + try { + await this.checkAndUpdate(); + } catch (e) { + this.log.warn(`Self-update tick failed: ${errMsg(e)}`); + } finally { + this.cycleInFlight = false; + if (!this.restartRequested) this.schedule(this.cfg.intervalMs); + } + } + + private async checkAndUpdate(): Promise { + const ctx = this.context; + if (!ctx) return; + + await this.git(ctx, ["fetch", "--quiet", ctx.remote, ctx.branch]); + + const local = (await this.git(ctx, ["rev-parse", "HEAD"])).stdout.trim(); + const remote = (await this.git(ctx, [ + "rev-parse", + `${ctx.remote}/${ctx.branch}`, + ])).stdout.trim(); + + if (!local || !remote || local === remote) return; + + // Behind only when remote contains commits we don't have. + const aheadBehind = (await this.git(ctx, [ + "rev-list", + "--left-right", + "--count", + `${local}...${remote}`, + ])).stdout.trim(); + const [aheadStr, behindStr] = aheadBehind.split(/\s+/); + const behind = parseInt(behindStr ?? "0", 10); + const ahead = parseInt(aheadStr ?? "0", 10); + if (!Number.isFinite(behind) || behind <= 0) return; + + if (ahead > 0) { + this.log.warn(`Skipping self-update: local branch has ${ahead} commit(s) not on ${ctx.remote}/${ctx.branch}`); + return; + } + + // Refuse to pull over a dirty tree — preserves operator changes. + const status = (await this.git(ctx, ["status", "--porcelain"])).stdout; + if (status.trim().length > 0) { + this.log.warn(`Skipping self-update: working tree dirty in ${ctx.repoRoot}`); + return; + } + + this.log.info(`Self-update: pulling ${behind} new commit(s)`, { + from: local.slice(0, 7), + to: remote.slice(0, 7), + }); + + // Capture manifest/lockfile state to decide whether install needs to run. + const manifestBefore = this.snapshotManifests(ctx.repoRoot); + + try { + await this.git(ctx, ["pull", "--ff-only", ctx.remote, ctx.branch]); + } catch (e) { + this.log.error(`Self-update pull failed, keeping current build: ${errMsg(e)}`); + return; + } + + const manifestAfter = this.snapshotManifests(ctx.repoRoot); + const depsChanged = manifestAfter !== manifestBefore; + + if (depsChanged && this.cfg.installCommand) { + this.log.info(`Self-update: running install (${this.cfg.installCommand})`); + try { + await this.runShell(this.cfg.installCommand, ctx.repoRoot); + } catch (e) { + this.log.error(`Self-update install failed, keeping current build: ${errMsg(e)}`); + return; + } + } + + if (this.cfg.buildCommand) { + this.log.info(`Self-update: running build (${this.cfg.buildCommand})`); + try { + await this.runShell(this.cfg.buildCommand, ctx.repoRoot); + } catch (e) { + this.log.error(`Self-update build failed, keeping current build: ${errMsg(e)}`); + return; + } + } + + this.log.info("Self-update complete, requesting restart"); + this.restartRequested = true; + this.stop(); + this.onRestartRequested(); + } + + private async resolveContext(): Promise { + const repoRoot = this.cfg.repoRoot ?? this.detectRepoRoot(); + if (!repoRoot) { + throw new Error("could not locate Symphony git checkout (no .git directory found)"); + } + if (!fs.existsSync(path.join(repoRoot, ".git"))) { + throw new Error(`not a git repository: ${repoRoot}`); + } + + let branch = this.cfg.branch ?? ""; + if (!branch) { + const head = (await this.gitAt(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim(); + if (!head || head === "HEAD") { + throw new Error("detached HEAD; set auto_update.branch in WORKFLOW.md to track a branch"); + } + branch = head; + } + + return { repoRoot, branch, remote: this.cfg.remote }; + } + + private detectRepoRoot(): string | null { + // Walk up from this module's compiled location (dist/self-update.js) until we find .git. + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i++) { + if (fs.existsSync(path.join(dir, ".git"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; + } + + private snapshotManifests(repoRoot: string): string { + const parts: string[] = []; + for (const name of ["package.json", "package-lock.json"]) { + try { + const stat = fs.statSync(path.join(repoRoot, name)); + parts.push(`${name}:${stat.size}:${stat.mtimeMs}`); + } catch { + parts.push(`${name}:missing`); + } + } + return parts.join("|"); + } + + private git(ctx: ResolvedContext, args: string[]): Promise<{ stdout: string; stderr: string }> { + return this.gitAt(ctx.repoRoot, args); + } + + private gitAt(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return execFileP("git", args, { cwd, env: process.env, encoding: "utf8" }); + } + + private runShell(command: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + execFile("bash", ["-lc", command], { cwd, env: process.env, encoding: "utf8" }, (err, stdout, stderr) => { + if (err) { + const tail = String(stderr || stdout || "").trim().split("\n").slice(-5).join("\n"); + reject(new Error(`${command} failed: ${err.message}${tail ? `\n${tail}` : ""}`)); + return; + } + resolve(); + }); + }); + } +} + +function errMsg(e: unknown): string { + if (e instanceof Error) return e.message; + try { return JSON.stringify(e); } catch { return String(e); } +} diff --git a/src/types.ts b/src/types.ts index c254c99..a651c10 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,7 @@ export interface WorkflowConfig { hooks: HooksConfig; agent: AgentConfig; server?: ServerConfig; + autoUpdate: AutoUpdateConfig; } export interface TrackerConfig { @@ -74,6 +75,23 @@ export interface ServerConfig { port?: number; } +export interface AutoUpdateConfig { + /** When false, Symphony will not poll GitHub for self-updates. */ + enabled: boolean; + /** Poll interval in milliseconds. */ + intervalMs: number; + /** Remote name to fetch from (default "origin"). */ + remote: string; + /** Branch to track; defaults to the current local branch. */ + branch: string | null; + /** Absolute path to the Symphony git checkout; defaults to the directory containing the running build. */ + repoRoot: string | null; + /** Build command run after a successful pull (default "npm run build"). */ + buildCommand: string; + /** Install command run when package-lock.json or package.json changes (default "npm install"). */ + installCommand: string; +} + export interface AgentResult { success: boolean; error?: string;