agent-relay-orchestrator 0.17.0 → 0.18.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-sdk": "0.2.8"
19
+ "agent-relay-sdk": "0.2.9"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
2
+ import { stringValue } from "agent-relay-sdk";
2
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
4
  import type { ServerWebSocket } from "bun";
4
5
  import { proxyArtifactRequest } from "./artifact-proxy";
@@ -797,7 +798,3 @@ function cleanTerminalGuestInput(value: unknown): { agentId?: string; policyName
797
798
  }
798
799
  return result;
799
800
  }
800
-
801
- function stringValue(value: unknown): string | undefined {
802
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
803
- }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
2
2
  import { homedir } from "node:os";
3
3
  import { join, relative, resolve } from "node:path";
4
4
  import type { OrchestratorConfig } from "./config";
5
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
5
6
 
6
7
  const SAFE_ARTIFACT_ID = /^[a-zA-Z0-9._-]{1,160}$/;
7
8
  const CONTENT_ROUTE = /^\/api\/artifacts\/([^/]+)\/content$/;
@@ -77,7 +78,7 @@ function forwardHeaders(req: Request, config: OrchestratorConfig): Headers {
77
78
  if (value) headers.set(name, value);
78
79
  }
79
80
  const token = config.token || req.headers.get("x-agent-relay-token");
80
- if (token) headers.set("X-Agent-Relay-Token", token);
81
+ if (token) headers.set(RELAY_TOKEN_HEADER, token);
81
82
  return headers;
82
83
  }
83
84
 
package/src/control.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "agent-relay-sdk";
1
2
  import type { OrchestratorConfig } from "./config";
2
3
  import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
3
4
  import { handleSelfUpgrade } from "./self-upgrade";
@@ -115,6 +116,7 @@ export function createControlHandler(
115
116
  baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
116
117
  strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
117
118
  deleteBranch: command.params.deleteBranch !== false,
119
+ push: command.params.push !== false,
118
120
  prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
119
121
  prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
120
122
  });
@@ -245,7 +247,3 @@ function stringArray(value: unknown): string[] | undefined {
245
247
  function workspaceMode(value: unknown): SpawnOptions["workspaceMode"] {
246
248
  return value === "isolated" || value === "shared" || value === "inherit" ? value : undefined;
247
249
  }
248
-
249
- function isRecord(value: unknown): value is Record<string, any> {
250
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
251
- }
package/src/relay.ts CHANGED
@@ -2,7 +2,8 @@ import type { OrchestratorConfig } from "./config";
2
2
  import type { ProviderProbeCache } from "./provider-probe";
3
3
  import { detectSelfSupervision } from "./self-supervision";
4
4
  import { GIT_SHA, ORCHESTRATOR_PROTOCOL_VERSION, VERSION, runtimeMetadata } from "./version";
5
- import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
5
+ import type { WorkspaceMetadata, WorkspaceMode, ManagedSessionExitDiagnostics as SdkManagedSessionExitDiagnostics } from "agent-relay-sdk";
6
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
6
7
 
7
8
  export interface RelayClient {
8
9
  register(): Promise<void>;
@@ -46,46 +47,8 @@ export interface ManagedAgentReport {
46
47
  startedAt: number;
47
48
  }
48
49
 
49
- export interface ManagedSessionExitDiagnostics {
50
- agentId: string;
51
- provider: "claude" | "codex";
52
- workspaceMode?: WorkspaceMode;
53
- workspace?: WorkspaceMetadata;
54
- sessionName?: string;
55
- tmuxSession: string;
56
- cwd: string;
57
- label?: string;
58
- policyName?: string;
59
- spawnRequestId?: string;
60
- automationRunId?: string;
61
- supervisor: "process" | "systemd" | "launchd" | "unknown";
62
- systemdUnit?: string;
63
- terminalSession?: string;
64
- terminalAvailable?: boolean;
65
- pid?: number;
66
- currentPid?: number;
67
- startedAt: number;
68
- detectedAt: number;
69
- runtimeMs: number;
70
- logFile?: string;
71
- logBytes?: number;
72
- logEmpty?: boolean;
73
- logTail?: string[];
74
- runnerInfoFile?: string;
75
- runnerInfoPresent?: boolean;
76
- systemd?: {
77
- unit: string;
78
- activeState?: string;
79
- subState?: string;
80
- result?: string;
81
- execMainCode?: string;
82
- execMainStatus?: string;
83
- mainPid?: number;
84
- unavailable?: string;
85
- };
86
- unavailable?: string[];
87
- lastError: string;
88
- }
50
+ // Canonical shape lives in agent-relay-sdk — alias and re-export, never re-declare.
51
+ export type ManagedSessionExitDiagnostics = SdkManagedSessionExitDiagnostics;
89
52
 
90
53
  export interface RelayCommand {
91
54
  id: string;
@@ -133,7 +96,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
133
96
  function headers(): Record<string, string> {
134
97
  const h: Record<string, string> = { "Content-Type": "application/json" };
135
98
  const token = runtimeToken ?? config.token;
136
- if (token) h["X-Agent-Relay-Token"] = token;
99
+ if (token) h[RELAY_TOKEN_HEADER] = token;
137
100
  return h;
138
101
  }
139
102
 
package/src/version.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { CONTRACT_VERSIONS } from "agent-relay-sdk";
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
7
8
 
8
9
  export const PACKAGE_NAME = pkg.name || "agent-relay-orchestrator";
9
10
  export const VERSION = pkg.version || "0.0.0";
10
- export const ORCHESTRATOR_PROTOCOL_VERSION = 3;
11
- export const RUNNER_PROTOCOL_VERSION = 1;
12
- export const PROVIDER_PLUGIN_PROTOCOL_VERSION = 1;
11
+ // Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
12
+ export const ORCHESTRATOR_PROTOCOL_VERSION = CONTRACT_VERSIONS.orchestratorProtocol;
13
+ export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
14
+ export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
13
15
  export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
14
16
 
15
17
  export const CONTRACTS = {
@@ -394,6 +394,8 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
394
394
  const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
395
395
 
396
396
  const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount };
397
+ const liveBranch = shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], path).stdout || undefined);
398
+ if (liveBranch) state.branch = liveBranch;
397
399
 
398
400
  const log = git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path);
399
401
  if (log.ok && log.stdout) {
@@ -552,10 +554,22 @@ interface WorkspaceMergeInput {
552
554
  baseSha?: string;
553
555
  strategy?: "pr" | "rebase-ff" | "auto";
554
556
  deleteBranch?: boolean;
557
+ /** Push the advanced base to its upstream (origin) after a rebase-ff land.
558
+ * Defaults to true; auto-skipped when base has no upstream. Disable with
559
+ * AGENT_RELAY_WORKSPACE_PUSH=0. */
560
+ push?: boolean;
555
561
  prTitle?: string;
556
562
  prBody?: string;
557
563
  }
558
564
 
565
+ /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
566
+ function countBehind(worktreePath: string, base: string): number {
567
+ const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
568
+ if (!counts.ok || !counts.stdout) return 0;
569
+ const behind = Number(counts.stdout.split(/\s+/)[0]);
570
+ return Number.isFinite(behind) ? behind : 0;
571
+ }
572
+
559
573
  function hasOriginRemote(cwd: string): boolean {
560
574
  return git(["remote", "get-url", "origin"], cwd).ok;
561
575
  }
@@ -679,7 +693,8 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
679
693
  /**
680
694
  * Integrate a workspace's work back into its base branch. Two strategies:
681
695
  * - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
682
- * then remove the worktree and delete the branch. Lands work locally.
696
+ * push base to origin, then (unless deleteBranch is false) remove the worktree
697
+ * and delete the branch. Lands work locally and publishes it.
683
698
  * - pr: push the branch to origin and open a PR via gh. Leaves the worktree
684
699
  * and branch intact (the PR needs them).
685
700
  * Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
@@ -742,8 +757,24 @@ function mergeRebaseFf(
742
757
  if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
743
758
  if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
744
759
 
760
+ // Reconcile with origin before landing (#190/#203). When base tracks an
761
+ // upstream (e.g. main -> origin/main) and we'll push, fetch it and refuse if
762
+ // origin has moved ahead of local base: pushing would then be a non-fast-forward,
763
+ // and we won't rewrite published history or strand a local-only land. The
764
+ // refusal happens BEFORE we mutate anything, so a diverged base is a clean no-op.
765
+ const upstream = upstreamRef(worktreePath, base);
766
+ const slash = upstream ? upstream.indexOf("/") : -1;
767
+ const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
768
+ const pushEnabled = input.push !== false && process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0" && Boolean(remote);
769
+ if (upstream && remote && pushEnabled) {
770
+ git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
771
+ if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
772
+ return head({ status: "review_requested", error: `origin moved ahead of local ${base}; sync ${base} with ${upstream} before landing` });
773
+ }
774
+ }
775
+
745
776
  // Rebase the agent branch onto base so base can fast-forward to it.
746
- if ((preview.behind ?? 0) > 0) {
777
+ if (countBehind(worktreePath, base) > 0) {
747
778
  const rebase = git(["rebase", base], worktreePath);
748
779
  if (!rebase.ok) {
749
780
  git(["rebase", "--abort"], worktreePath);
@@ -765,16 +796,38 @@ function mergeRebaseFf(
765
796
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
766
797
  }
767
798
 
768
- // Work is landed. Tear down the worktree and delete the agent branch.
799
+ // Publish the advanced base so local and origin converge (#190). We verified
800
+ // origin was an ancestor of base above, so this is a fast-forward; a failure
801
+ // means origin raced us — surface it instead of claiming an unpublished land.
802
+ let pushed = false;
803
+ if (upstream && remote && pushEnabled) {
804
+ const push = git(["push", remote, `${base}:${base}`], worktreePath);
805
+ if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
806
+ pushed = true;
807
+ }
808
+
809
+ // Work is landed (and published). Tear down only when the owner is gone:
810
+ // deleteBranch is false for a live owner (the relay sets it from owner liveness,
811
+ // #204). A live owner keeps its worktree and is recycled onto a fresh branch cut
812
+ // from the advanced base (#206) so it continues the next task from clean, current
813
+ // state in the same CWD; status returns to `active`. An absent owner gets the
814
+ // worktree reclaimed.
769
815
  const deleteBranch = input.deleteBranch !== false;
770
- let worktreeRemoved = false;
771
- let branchDeleted = false;
772
- if (deleteBranch) {
773
- const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
774
- worktreeRemoved = removed.ok;
775
- if (worktreeRemoved) branchDeleted = git(["branch", "-D", branch], repoRoot).ok;
816
+ if (!deleteBranch) {
817
+ const fresh = nextBranchName(repoRoot, branch);
818
+ const switched = git(["checkout", "-B", fresh, base], worktreePath);
819
+ if (switched.ok) {
820
+ // The old branch now equals base (just fast-forwarded) — drop the litter.
821
+ const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
822
+ return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, error: undefined });
823
+ }
824
+ // Recycle failed — keep the existing branch. Still landed, still active.
825
+ return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
776
826
  }
777
- return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, error: undefined });
827
+ const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
828
+ const worktreeRemoved = removed.ok;
829
+ const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
830
+ return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, pushed, error: undefined });
778
831
  }
779
832
 
780
833
  async function availableBranch(repoRoot: string, base: string): Promise<string> {
@@ -796,6 +849,17 @@ function branchName(input: WorkspaceResolutionInput, id: string): string {
796
849
  return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 24)}`;
797
850
  }
798
851
 
852
+ /** Next free `<branch>-N` cycle name for a recycled worktree (#206). Strips any
853
+ * existing -N suffix so cycles increment instead of nesting. */
854
+ function nextBranchName(repoRoot: string, branch: string): string {
855
+ const stem = branch.replace(/-\d+$/, "");
856
+ for (let i = 2; i < 1000; i++) {
857
+ const candidate = `${stem}-${i}`;
858
+ if (!git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot).ok) return candidate;
859
+ }
860
+ return `${stem}-${Date.now()}`;
861
+ }
862
+
799
863
  function repoSlug(repoRoot: string): string {
800
864
  const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
801
865
  return `${safeSegment(basename(repoRoot), 60)}-${hash}`;