Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <remote> <branch>` 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
Expand Down
48 changes: 48 additions & 0 deletions bin/symphony-supervisor.sh
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function buildConfig(raw: Record<string, unknown>, baseDir: string): WorkflowCon
const hooks = ((raw.hooks ?? {}) as Record<string, unknown>);
const agent = ((raw.agent ?? {}) as Record<string, unknown>);
const server = raw.server as Record<string, unknown> | undefined;
const autoUpdate = ((raw.auto_update ?? {}) as Record<string, unknown>);

const apiKeyRaw = (tracker.api_key as string | undefined) ?? "$LINEAR_API_KEY";
const apiKey = resolveEnvVar(apiKeyRaw);
Expand Down Expand Up @@ -130,6 +131,17 @@ function buildConfig(raw: Record<string, unknown>, 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",
},
};
}

Expand Down
24 changes: 19 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): void {
Expand Down Expand Up @@ -70,22 +73,24 @@ async function main(): Promise<void> {
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();
Expand All @@ -101,6 +106,15 @@ async function main(): Promise<void> {
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();
Expand Down
Loading