agent-relay-server 0.11.6 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/steward.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename, isAbsolute, relative, resolve } from "node:path";
3
+ import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
4
+ import { listOrchestrators } from "./db";
5
+ import { getLifecycleManager } from "./lifecycle-manager";
6
+ import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
7
+
8
+ // Real path containment (same rule as routes/mcp/lifecycle): never string startsWith.
9
+ function pathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
10
+ if (!path || !baseDir) return false;
11
+ const rel = relative(resolve(baseDir), resolve(path));
12
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
13
+ }
14
+
15
+ /** Stable, readable, collision-resistant policy name for a repo's steward. */
16
+ export function repoStewardPolicyName(repoRoot: string): string {
17
+ const slug = basename(repoRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
18
+ const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 8);
19
+ return `steward-${slug}-${hash}`;
20
+ }
21
+
22
+ /**
23
+ * The steward's system prompt — provider-agnostic (works for any provider/model).
24
+ * It tells the agent the workflow: take the worktrees it can't auto-land, rebase,
25
+ * resolve, run the repo's checks, and land green ones via the relay merge API;
26
+ * escalate only what genuinely needs a human.
27
+ */
28
+ export function buildStewardPrompt(repoRoot: string): string {
29
+ return [
30
+ `You are the autonomous repository steward for ${repoRoot}.`,
31
+ "",
32
+ "Multiple agents work this repo in isolated git worktrees. The relay auto-merges branches that fast-forward cleanly; it hands YOU the ones it can't — merge conflicts, or a branch whose base moved on (behind > 0). Your job is to land that work with no human in the loop whenever you safely can.",
33
+ "",
34
+ "When woken, gather what needs attention: read the message(s) you were sent (each names a `workspaceId`, `worktreePath`, `branch`, `baseRef`, `status`), and also query `GET /api/workspaces?status=conflict` and `GET /api/workspaces?status=review_requested` for this repo. Handle one workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
35
+ "",
36
+ "For each workspace:",
37
+ "1. `cd` into its `worktreePath`.",
38
+ "2. Rebase the branch onto its `baseRef` (`git rebase <baseRef>`).",
39
+ "3. Resolve any conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
40
+ "4. Run the repo's checks/tests (look for the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
41
+ "5. If green, land it: `POST /api/workspaces/<id>/actions` with `{\"action\":\"merge\",\"strategy\":\"rebase-ff\"}`. The relay acquires the lease and dispatches the merge.",
42
+ "",
43
+ "Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Send a clear, specific summary to the configured fallback/human target and leave the workspace as-is. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
44
+ "",
45
+ "Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
46
+ ].join("\n");
47
+ }
48
+
49
+ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: StewardConfig): SpawnPolicy {
50
+ // Launch (and scope the runtime token) at the orchestrator's baseDir, not the
51
+ // repo: isolated worktrees live at `<baseDir>/.agent-relay/workspaces/...`
52
+ // (orchestrator/src/spawn.ts), OUTSIDE repo_root. The steward must reach both the
53
+ // repo and those worktrees, and its provider-agent token's cwdPrefixes derive
54
+ // from this cwd — baseDir is their common ancestor, so the merge action against a
55
+ // worktree path passes authorization. The prompt scopes it to the specific repo.
56
+ return {
57
+ name: repoStewardPolicyName(repoRoot),
58
+ description: `Autonomous repo steward for ${repoRoot} (issue #167).`,
59
+ enabled: true,
60
+ orchestratorId: owner.id,
61
+ cwd: owner.baseDir,
62
+ provider: config.provider,
63
+ workspaceMode: "inherit",
64
+ ...(config.model ? { model: config.model } : {}),
65
+ ...(config.effort ? { effort: config.effort } : {}),
66
+ providerArgs: [],
67
+ prompt: buildStewardPrompt(repoRoot),
68
+ tags: ["steward"],
69
+ capabilities: ["merge", "review"],
70
+ label: `steward:${basename(repoRoot)}`,
71
+ mode: "on-demand",
72
+ permissionMode: config.permissionMode,
73
+ restartOnUpdate: false,
74
+ scheduledDailyRestart: false,
75
+ onDemand: { keepaliveSeconds: config.keepaliveSeconds, idleDefinition: "no-activity" },
76
+ backoff: { schedule: [30, 60, 180], resetAfterSeconds: 300 },
77
+ };
78
+ }
79
+
80
+ // Fields whose change means the persisted policy should be refreshed. Avoids
81
+ // churning the config version (and the lifecycle reconcile) every sweep.
82
+ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): boolean {
83
+ return (
84
+ existing.enabled !== desired.enabled ||
85
+ existing.provider !== desired.provider ||
86
+ existing.model !== desired.model ||
87
+ existing.effort !== desired.effort ||
88
+ existing.permissionMode !== desired.permissionMode ||
89
+ existing.cwd !== desired.cwd ||
90
+ existing.orchestratorId !== desired.orchestratorId ||
91
+ existing.prompt !== desired.prompt ||
92
+ existing.onDemand?.keepaliveSeconds !== desired.onDemand?.keepaliveSeconds
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Ensure an on-demand steward spawn policy exists for `repoRoot`, built from the
98
+ * global steward config (issue #167). Returns the policy name (durable `policy:`
99
+ * target the caller wakes), or null when stewards are disabled or no orchestrator
100
+ * owns the repo. Idempotent — refreshes the policy only when config-derived fields
101
+ * change, so it can be called on every sweep without churn.
102
+ */
103
+ export function ensureRepoSteward(repoRoot: string): string | null {
104
+ const config = getStewardConfig();
105
+ if (!config.enabled) return null;
106
+ const owner = listOrchestrators().find((orch) => pathWithinBase(repoRoot, orch.baseDir));
107
+ if (!owner) return null;
108
+
109
+ const name = repoStewardPolicyName(repoRoot);
110
+ const desired = desiredStewardPolicy(repoRoot, owner, config);
111
+ const existing = getSpawnPolicy(name);
112
+ if (existing && !stewardPolicyDiffers(existing.value, desired)) return name;
113
+
114
+ setConfig("spawn-policy", name, desired, "steward-autoprovision");
115
+ getLifecycleManager().onConfigChanged("spawn-policy", name);
116
+ return name;
117
+ }
@@ -0,0 +1,108 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+ import { createCommand } from "./commands-db";
3
+ import {
4
+ acquireMergeLease,
5
+ listOrchestrators,
6
+ releaseMergeLease,
7
+ setMergeLeaseCommand,
8
+ updateWorkspaceStatus,
9
+ } from "./db";
10
+ import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
11
+
12
+ export interface RequestWorkspaceMergeOptions {
13
+ /** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
14
+ requestedBy: string;
15
+ /** Merge strategy; "auto" lets the host pick pr-vs-rebase-ff. Defaults to "auto". */
16
+ strategy?: WorkspaceMergeStrategy;
17
+ /** Delete the agent branch after a successful land. Defaults to true. */
18
+ deleteBranch?: boolean;
19
+ prTitle?: string;
20
+ prBody?: string;
21
+ /** Extra metadata merged onto the workspace row when moving to merge_planned. */
22
+ metadata?: Record<string, unknown>;
23
+ }
24
+
25
+ export type RequestWorkspaceMergeResult =
26
+ | { ok: true; command: Command; workspace: WorkspaceRecord }
27
+ | { ok: false; status: number; error: string };
28
+
29
+ // Real path containment (same rule as routes/mcp): never string startsWith.
30
+ function pathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
31
+ if (!path || !baseDir) return false;
32
+ const rel = relative(resolve(baseDir), resolve(path));
33
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
34
+ }
35
+
36
+ /**
37
+ * Dispatch a base merge for an isolated workspace, serialized by the per-repo
38
+ * merge lease (issue #157). Single source of truth shared by the manual
39
+ * `POST /api/workspaces/:id/actions {action:"merge"}` route and the deterministic
40
+ * `workspace-auto-merge` maintenance job, so both honor the same lease + command
41
+ * contract.
42
+ *
43
+ * Acquires the lease BEFORE mutating status (a loser leaves the workspace
44
+ * untouched), binds it to the dispatched command so `postCommandResult` frees it
45
+ * on settle, and releases it on every early-out or throw. Does NOT emit the
46
+ * command event — the caller emits via its own `emitCommand` (kept module-local).
47
+ */
48
+ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestWorkspaceMergeOptions): RequestWorkspaceMergeResult {
49
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) {
50
+ return { ok: false, status: 422, error: `workspace ${workspace.id} has no worktree to merge` };
51
+ }
52
+ const lease = acquireMergeLease(workspace.repoRoot, workspace.id, opts.requestedBy);
53
+ if (!lease.ok) {
54
+ return {
55
+ ok: false,
56
+ status: 409,
57
+ error: `a merge is already in progress for ${workspace.repoRoot} (workspace ${lease.lease.workspaceId}); retry after it settles`,
58
+ };
59
+ }
60
+ try {
61
+ // Merge needs a live host: rebasing against a stale base later is unsafe.
62
+ const onlineOwner = listOrchestrators().find(
63
+ (candidate) => candidate.status === "online" && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
64
+ );
65
+ if (!onlineOwner) {
66
+ releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
67
+ return { ok: false, status: 409, error: "no online orchestrator available for workspace merge" };
68
+ }
69
+ const updated = updateWorkspaceStatus(workspace.id, "merge_planned", {
70
+ ...(opts.metadata ?? {}),
71
+ lastWorkspaceAction: "merge",
72
+ lastWorkspaceActionAt: Date.now(),
73
+ });
74
+ if (!updated) {
75
+ releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
76
+ return { ok: false, status: 404, error: "workspace not found" };
77
+ }
78
+ const command = createCommand({
79
+ type: "workspace.merge",
80
+ source: "system",
81
+ target: onlineOwner.agentId,
82
+ correlationId: workspace.id,
83
+ params: {
84
+ action: "merge",
85
+ workspaceId: workspace.id,
86
+ repoRoot: workspace.repoRoot,
87
+ worktreePath: workspace.worktreePath,
88
+ branch: workspace.branch,
89
+ baseRef: workspace.baseRef,
90
+ baseSha: workspace.baseSha,
91
+ strategy: opts.strategy ?? "auto",
92
+ deleteBranch: opts.deleteBranch !== false,
93
+ prTitle: opts.prTitle,
94
+ prBody: opts.prBody,
95
+ requestedBy: opts.requestedBy,
96
+ requestedAt: Date.now(),
97
+ },
98
+ });
99
+ // Bind the lease to the dispatched command so it releases by id on settle.
100
+ setMergeLeaseCommand(workspace.repoRoot, command.id);
101
+ return { ok: true, command, workspace: updated };
102
+ } catch (e) {
103
+ // Acquired the lease but failed before dispatch — release it or the repo
104
+ // stays blocked until the TTL expires.
105
+ releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
106
+ throw e;
107
+ }
108
+ }