agent-relay-server 0.16.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/src/db.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { isRecord, stringValue } from "agent-relay-sdk";
3
4
  import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./config.ts";
5
+ import { parseJson } from "./utils";
4
6
  import {
5
7
  CONTRACT_REQUIREMENTS,
6
8
  contractCompatibility,
@@ -239,7 +241,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
239
241
  payload TEXT NOT NULL DEFAULT '{}',
240
242
  meta TEXT NOT NULL DEFAULT '{}',
241
243
  read_by TEXT NOT NULL DEFAULT '[]',
242
- created_at INTEGER NOT NULL
244
+ created_at INTEGER NOT NULL,
245
+ occurred_at INTEGER
243
246
  );
244
247
 
245
248
  CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_target);
@@ -890,6 +893,11 @@ export function initDb(path: string = "agent-relay.db"): Database {
890
893
  if (!colNames.includes("resolved_to_agent")) {
891
894
  db.run("ALTER TABLE messages ADD COLUMN resolved_to_agent TEXT");
892
895
  }
896
+ // Event time (#196): when a Runner queues a message in its durable outbox during an
897
+ // outage, occurred_at preserves when it really happened vs. the later receive time.
898
+ if (!colNames.includes("occurred_at")) {
899
+ db.run("ALTER TABLE messages ADD COLUMN occurred_at INTEGER");
900
+ }
893
901
  db.query(
894
902
  "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
895
903
  ).run(Date.now(), CLAIM_LEASE_MS);
@@ -1167,13 +1175,6 @@ export function getDb(): Database {
1167
1175
  export class ValidationError extends Error {}
1168
1176
  class ClaimError extends Error {}
1169
1177
 
1170
- function parseJson<T>(raw: string, fallback: T): T {
1171
- try {
1172
- return JSON.parse(raw);
1173
- } catch {
1174
- return fallback;
1175
- }
1176
- }
1177
1178
 
1178
1179
  function parseStringArray(raw: string): string[] {
1179
1180
  const parsed = parseJson<unknown>(raw, []);
@@ -1185,10 +1186,6 @@ function normalizeTags(tags: string[] | undefined): string[] {
1185
1186
  return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
1186
1187
  }
1187
1188
 
1188
- function stringValue(value: unknown): string | undefined {
1189
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
1190
- }
1191
-
1192
1189
  function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
1193
1190
  if (input.kind) return input.kind;
1194
1191
  if (input.id === "user") return "user";
@@ -1308,6 +1305,7 @@ function rowToMessage(row: any): Message {
1308
1305
  reactions: parseJson(row.reactions_json ?? "[]", []),
1309
1306
  readBy: parseJson(row.read_by_agents ?? "[]", []),
1310
1307
  createdAt: row.created_at,
1308
+ occurredAt: row.occurred_at ?? undefined,
1311
1309
  };
1312
1310
  }
1313
1311
 
@@ -2411,10 +2409,6 @@ function runtimeTokenJtisFromMeta(meta: Record<string, unknown>): string[] {
2411
2409
  return [...jtis];
2412
2410
  }
2413
2411
 
2414
- function isRecord(value: unknown): value is Record<string, unknown> {
2415
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2416
- }
2417
-
2418
2412
  // --- Tasks ---
2419
2413
 
2420
2414
  const TASK_SELECT = "SELECT * FROM tasks";
@@ -3680,6 +3674,16 @@ function findRecentDuplicateReply(input: SendMessageInput, threadId: number | nu
3680
3674
  return row ? rowToMessage(row) : null;
3681
3675
  }
3682
3676
 
3677
+ // Event time may be queued-then-backfilled, so it can legitimately be older than the
3678
+ // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
3679
+ // readers fall back to created_at) for absent/invalid values or anything more than a minute
3680
+ // in the future (clock-skew guard). Only a real backfilled time is stored.
3681
+ function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
3682
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return null;
3683
+ if (occurredAt <= 0 || occurredAt > receivedAt + 60_000) return null;
3684
+ return Math.floor(occurredAt);
3685
+ }
3686
+
3683
3687
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
3684
3688
  const now = Date.now();
3685
3689
  const payload = input.payload ?? {};
@@ -3710,12 +3714,12 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3710
3714
  INSERT INTO messages (
3711
3715
  from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
3712
3716
  idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
3713
- payload, meta, created_at
3717
+ payload, meta, created_at, occurred_at
3714
3718
  )
3715
3719
  VALUES (
3716
3720
  $from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable,
3717
3721
  $idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
3718
- $payload, $meta, $now
3722
+ $payload, $meta, $now, $occurredAt
3719
3723
  )
3720
3724
  `);
3721
3725
  const setSelfThread = db.query("UPDATE messages SET thread_id = ? WHERE id = ?");
@@ -3756,6 +3760,8 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3756
3760
  $payload: JSON.stringify(payload),
3757
3761
  $meta: JSON.stringify(input.meta ?? {}),
3758
3762
  $now: now,
3763
+ // Sanitize: only accept a plausible epoch-ms event time, else fall back to receive time.
3764
+ $occurredAt: sanitizeOccurredAt(input.occurredAt, now),
3759
3765
  });
3760
3766
  const newId = Number(result.lastInsertRowid);
3761
3767
  if (threadId === null) setSelfThread.run(newId, newId);
@@ -5222,6 +5228,18 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
5222
5228
  return getWorkspace(id);
5223
5229
  }
5224
5230
 
5231
+ // Repoint a workspace row at a recycled branch after a land-and-continue merge
5232
+ // (#206): the worktree switched to a fresh branch cut from the advanced base, so
5233
+ // the row must track the new branch (else the next merge command targets a branch
5234
+ // that no longer exists) and the new base sha. No-op if the row is gone.
5235
+ export function setWorkspaceBranch(id: string, branch: string, baseSha?: string): WorkspaceRecord | null {
5236
+ const existing = getWorkspace(id);
5237
+ if (!existing) return null;
5238
+ db.query(`UPDATE workspaces SET branch = ?, base_sha = coalesce(?, base_sha), updated_at = ? WHERE id = ?`)
5239
+ .run(branch, baseSha ?? null, Date.now(), id);
5240
+ return getWorkspace(id);
5241
+ }
5242
+
5225
5243
  // Workspace statuses that count as "live" for stewardship — an agent owning one
5226
5244
  // of these is a candidate steward; the repo is worth coordinating.
5227
5245
  const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "./compaction-watch";
11
11
  import { getConfig, setConfig } from "./config-store";
12
12
  import { startConnectorStatusPoller } from "./connectors";
13
13
  import { resolve, sep } from "path";
14
+ import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
14
15
  import {
15
16
  VERSION,
16
17
  } from "./config";
@@ -264,8 +265,7 @@ export function createFetchHandler(
264
265
  }
265
266
  const file = Bun.file(resolved);
266
267
  if (await file.exists()) {
267
- const headers = staticHeaders(requested);
268
- return new Response(file, { headers });
268
+ return await serveStaticFile(req, resolved, requested, file);
269
269
  }
270
270
 
271
271
  return Response.json({ error: "not found" }, { status: 404 });
@@ -275,13 +275,108 @@ export function createFetchHandler(
275
275
  };
276
276
  }
277
277
 
278
+ // In-memory compressed-variant cache, keyed by absolute path. Invalidated when
279
+ // the file's mtime/size changes, so a rebuilt bundle is recompressed on next
280
+ // request. The single-file dashboard bundle (~10 MB unminified) is served
281
+ // uncompressed by Bun.serve otherwise; brotli/gzip cuts it ~6-7x on the wire,
282
+ // which is the dominant cost over high-latency links. Further wins (minify,
283
+ // code-split) tracked in #200 / #201.
284
+ type CompressedVariant = { body: Uint8Array; encoding: "br" | "gzip" };
285
+ const compressedCache = new Map<
286
+ string,
287
+ { mtimeMs: number; size: number; br?: Uint8Array; gzip?: Uint8Array }
288
+ >();
289
+
290
+ const COMPRESSIBLE = /\.(html|js|css|svg|json|webmanifest|map)$/;
291
+
292
+ function isCompressible(pathname: string): boolean {
293
+ return pathname === "/" || pathname.endsWith("/") || COMPRESSIBLE.test(pathname);
294
+ }
295
+
296
+ function negotiateEncoding(req: Request): "br" | "gzip" | null {
297
+ const accept = req.headers.get("accept-encoding") ?? "";
298
+ if (/\bbr\b/.test(accept)) return "br";
299
+ if (/\bgzip\b/.test(accept)) return "gzip";
300
+ return null;
301
+ }
302
+
303
+ async function getCompressedVariant(
304
+ resolved: string,
305
+ mtimeMs: number,
306
+ size: number,
307
+ encoding: "br" | "gzip",
308
+ raw: () => Promise<Uint8Array>,
309
+ ): Promise<CompressedVariant> {
310
+ let entry = compressedCache.get(resolved);
311
+ if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) {
312
+ entry = { mtimeMs, size };
313
+ compressedCache.set(resolved, entry);
314
+ }
315
+ const cached = entry[encoding];
316
+ if (cached) return { body: cached, encoding };
317
+ const data = await raw();
318
+ const body =
319
+ encoding === "br"
320
+ ? brotliCompressSync(data, {
321
+ params: {
322
+ // Quality 5 ~ near-gzip CPU, much better ratio; this runs once per
323
+ // file version then serves from cache, so cost is amortized away.
324
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 5,
325
+ [zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength,
326
+ },
327
+ })
328
+ : gzipSync(data, { level: 6 });
329
+ entry[encoding] = body;
330
+ return { body, encoding };
331
+ }
332
+
333
+ async function serveStaticFile(
334
+ req: Request,
335
+ resolved: string,
336
+ requested: string,
337
+ file: ReturnType<typeof Bun.file>,
338
+ ): Promise<Response> {
339
+ const stat = await file.stat();
340
+ const headers = staticHeaders(requested);
341
+ // Strong-ish validator from mtime + size; encoding-suffixed so a client never
342
+ // gets a 304 for a variant it didn't store.
343
+ const baseTag = `${stat.mtime.getTime().toString(36)}-${stat.size.toString(36)}`;
344
+
345
+ const encoding = isCompressible(requested) ? negotiateEncoding(req) : null;
346
+ const etag = `"${baseTag}${encoding ? `-${encoding}` : ""}"`;
347
+ headers.set("ETag", etag);
348
+ if (req.headers.get("if-none-match") === etag) {
349
+ return new Response(null, { status: 304, headers });
350
+ }
351
+
352
+ if (encoding) {
353
+ const { body } = await getCompressedVariant(
354
+ resolved,
355
+ stat.mtime.getTime(),
356
+ stat.size,
357
+ encoding,
358
+ () => file.bytes(),
359
+ );
360
+ headers.set("Content-Encoding", encoding);
361
+ headers.set("Vary", "Accept-Encoding");
362
+ headers.set("Content-Length", String(body.byteLength));
363
+ // Uint8Array is a valid BodyInit at runtime in Bun; the DOM lib types omit it.
364
+ return new Response(body as unknown as BodyInit, { headers });
365
+ }
366
+
367
+ return new Response(file, { headers });
368
+ }
369
+
278
370
  function staticHeaders(pathname: string): Headers {
279
371
  const headers = new Headers();
280
372
  const contentType = staticContentType(pathname);
281
373
  if (contentType) headers.set("Content-Type", contentType);
282
- if (pathname === "/sw.js") {
374
+ // The shell + PWA control files must always revalidate (ETag makes this a
375
+ // cheap 304 when unchanged). Hashing isn't available — viteSingleFile inlines
376
+ // everything into index.html — so no immutable long-cache assets exist.
377
+ if (pathname === "/sw.js" || pathname === "/manifest.webmanifest") {
283
378
  headers.set("Cache-Control", "no-cache");
284
- } else if (pathname === "/manifest.webmanifest") {
379
+ } else if (pathname === "/" || pathname.endsWith("/") || pathname.endsWith(".html")) {
285
380
  headers.set("Cache-Control", "no-cache");
286
381
  }
287
382
  return headers;
@@ -1,5 +1,5 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
2
1
  import { createCommand } from "./commands-db";
2
+ import { isPathWithinBase } from "./utils";
3
3
  import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
4
4
  import {
5
5
  getManagedAgentState,
@@ -8,7 +8,9 @@ import {
8
8
  upsertManagedAgentState,
9
9
  } from "./config-store";
10
10
  import { emitRelayEvent } from "./events";
11
+ import { emitCommandEvent } from "./command-events";
11
12
  import { buildManagedSpawnParams } from "./managed-policy";
13
+ import { generateSpawnRequestId } from "./spawn-command";
12
14
  import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy } from "./types";
13
15
 
14
16
  const DEFAULT_TICK_MS = 10_000;
@@ -208,7 +210,7 @@ export class LifecycleManager {
208
210
  this.emitState(state);
209
211
  return null;
210
212
  }
211
- const spawnRequestId = `sp_${crypto.randomUUID()}`;
213
+ const spawnRequestId = generateSpawnRequestId();
212
214
  const state = upsertManagedAgentState({
213
215
  policyName: policy.name,
214
216
  status: "starting",
@@ -230,7 +232,7 @@ export class LifecycleManager {
230
232
  reason,
231
233
  },
232
234
  });
233
- this.emitCommand(command);
235
+ emitCommandEvent(command, "command.requested");
234
236
  return command;
235
237
  }
236
238
 
@@ -270,7 +272,7 @@ export class LifecycleManager {
270
272
  requestedAt: this.now(),
271
273
  },
272
274
  });
273
- this.emitCommand(command);
275
+ emitCommandEvent(command, "command.requested");
274
276
  return command;
275
277
  }
276
278
 
@@ -278,7 +280,7 @@ export class LifecycleManager {
278
280
  const orch = getOrchestrator(policy.orchestratorId);
279
281
  if (!orch) return null;
280
282
  const state = getManagedAgentState(policy.name);
281
- const restartRequestId = `sp_${crypto.randomUUID()}`;
283
+ const restartRequestId = generateSpawnRequestId();
282
284
  const restartSpawn = buildManagedSpawnParams(policy, restartRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() });
283
285
  const restarted = upsertManagedAgentState({
284
286
  policyName: policy.name,
@@ -314,7 +316,7 @@ export class LifecycleManager {
314
316
  requestedAt: this.now(),
315
317
  },
316
318
  });
317
- this.emitCommand(command);
319
+ emitCommandEvent(command, "command.requested");
318
320
  return command;
319
321
  }
320
322
 
@@ -406,7 +408,7 @@ export class LifecycleManager {
406
408
  requestedAt: this.now(),
407
409
  },
408
410
  });
409
- this.emitCommand(command);
411
+ emitCommandEvent(command, "command.requested");
410
412
  return command;
411
413
  }
412
414
 
@@ -564,15 +566,6 @@ export class LifecycleManager {
564
566
  });
565
567
  }
566
568
 
567
- private emitCommand(command: Command): void {
568
- emitRelayEvent({
569
- type: "command.requested",
570
- source: command.source,
571
- subject: command.id,
572
- data: { command },
573
- });
574
- }
575
-
576
569
  // When an agent disappears, its isolated worktrees would otherwise sit
577
570
  // `active` on disk forever. Dispatch a workspace.reconcile command to the
578
571
  // owning orchestrator, which probes the worktree and either removes it (no
@@ -593,7 +586,7 @@ export class LifecycleManager {
593
586
  const orchestrators = listOrchestrators();
594
587
  for (const ws of candidates) {
595
588
  const orch = orchestrators.find(
596
- (candidate) => candidate.status === "online" && pathWithinBaseDir(ws.sourceCwd, candidate.baseDir),
589
+ (candidate) => candidate.status === "online" && isPathWithinBase(ws.sourceCwd, candidate.baseDir),
597
590
  );
598
591
  if (!orch) continue;
599
592
  const command = createCommand({
@@ -614,17 +607,11 @@ export class LifecycleManager {
614
607
  requestedAt: this.now(),
615
608
  },
616
609
  });
617
- this.emitCommand(command);
610
+ emitCommandEvent(command, "command.requested");
618
611
  }
619
612
  }
620
613
  }
621
614
 
622
- function pathWithinBaseDir(path: string | undefined, baseDir: string | undefined): boolean {
623
- if (!path || !baseDir) return false;
624
- const rel = relative(resolve(baseDir), resolve(path));
625
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
626
- }
627
-
628
615
  let singleton: LifecycleManager | null = null;
629
616
 
630
617
  export function getLifecycleManager(): LifecycleManager {
@@ -33,6 +33,8 @@ import {
33
33
  } from "./db";
34
34
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
35
  import { requestWorkspaceMerge } from "./workspace-merge";
36
+ import { workspaceActiveClaim } from "./workspace-claim";
37
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
36
38
  import { getStewardConfig } from "./config-store";
37
39
  import { ensureRepoSteward } from "./steward";
38
40
  import { emitRelayEvent } from "./events";
@@ -392,7 +394,7 @@ const definitions: MaintenanceJobDefinition[] = [
392
394
  {
393
395
  id: "workspace-auto-merge",
394
396
  title: "Workspace auto-merge",
395
- description: "Auto-merge clean fast-forward review_requested worktrees into base under the per-repo lease; conflicts and diverged bases are left for the steward.",
397
+ description: "Auto-merge any non-conflicting review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
396
398
  intervalMs: WORKSPACE_AUTO_MERGE_INTERVAL_MS,
397
399
  runOnStart: false,
398
400
  timeoutMs: 60 * 1000,
@@ -421,7 +423,7 @@ async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord)
421
423
  if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
422
424
  const headers: Record<string, string> = {};
423
425
  const token = process.env.AGENT_RELAY_TOKEN;
424
- if (token) headers["X-Agent-Relay-Token"] = token;
426
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
425
427
  try {
426
428
  const res = await fetch(`${apiUrl}/api/workspace/merge-preview?${query.toString()}`, { headers, signal: AbortSignal.timeout(8_000) });
427
429
  if (!res.ok) return null;
@@ -535,7 +537,7 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
535
537
  to: `policy:${policyName}`,
536
538
  kind: "system",
537
539
  subject: `Steward: ${ws.status} workspace needs attention`,
538
- body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, then land it via POST /api/workspaces/${ws.id}/actions {"action":"merge","strategy":"rebase-ff"} — or escalate if you can't.`,
540
+ body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
539
541
  payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
540
542
  });
541
543
  emitNewMessage(msg);
@@ -655,14 +657,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
655
657
  return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards };
656
658
  }
657
659
 
658
- // Deterministic auto-land (Layer 0, issue #167). Walk the "ready to land" queue
659
- // (`review_requested` isolated worktrees) and, for any whose work is a strict
660
- // clean fast-forward (no conflict, base hasn't moved, real commits ahead), land
661
- // it via the shared merge helper the same lease-serialized path the merge route
662
- // uses. Conflicts and diverged bases (`behind>0`, even if cleanly rebasable) are
663
- // deliberately left for the steward (a human or, later, the managed steward
664
- // agent): per the chosen "Clean FF immediate" gate, anything needing a rebase or
665
- // conflict reasoning is not auto-landed. No agent in the loop for the easy case.
660
+ // Deterministic auto-land (Layer 0, issue #167 / #207). Walk the "ready to land"
661
+ // queue (`review_requested` isolated worktrees) and land any whose merge is
662
+ // predicted conflict-free, via the shared lease-serialized merge helper even
663
+ // when the base moved on (behind>0): mergeRebaseFf rebases onto the current base
664
+ // before fast-forwarding. Only a predicted conflict or an unknown merge state is
665
+ // left for the steward; clean parallel work lands with no agent in the loop.
666
666
  async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
667
667
  if (process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE === "0") return { skipped: "disabled" };
668
668
  const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
@@ -674,10 +674,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
674
674
  const stewardEnabled = getStewardConfig().enabled;
675
675
  const merged: string[] = [];
676
676
  const heldByLease: string[] = [];
677
+ const heldByClaim: string[] = [];
677
678
  const leftForSteward: string[] = [];
678
679
  const wokeStewards: string[] = [];
679
680
 
680
681
  for (const ws of candidates) {
682
+ // A claimed workspace is being validated by a steward — don't race it (#208).
683
+ if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
681
684
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
682
685
  if (!orch?.apiUrl) continue;
683
686
  const preview = await fetchHostMergePreview(orch.apiUrl, ws);
@@ -686,14 +689,17 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
686
689
  if (p.error || p.missing) continue;
687
690
 
688
691
  const ahead = p.unmergedAhead ?? p.ahead ?? 0;
689
- const cleanFF = p.cleanFastForward === true && p.conflict !== true && (p.behind ?? 0) === 0 && ahead > 0;
690
- if (!cleanFF) {
691
- // Base moved on (behind>0) or conflict needs reasoning/rebase, which is the
692
- // steward's job. Wake the managed steward when enabled (cooldown-guarded);
693
- // otherwise leave it for conflict-scan's legacy ping / human review.
692
+ const behind = p.behind ?? 0;
693
+ // Land anything that won't conflict — including a base that moved on (behind>0)
694
+ // but rebases cleanly. The merge-tree prediction already accounts for the moved
695
+ // base, and mergeRebaseFf rebases then aborts-to-conflict on a real replay
696
+ // conflict, so a too-optimistic prediction degrades safely to review_requested.
697
+ // Only a predicted conflict (true) or unknown state (undefined) goes to the steward.
698
+ const canLand = p.conflict === false && ahead > 0;
699
+ if (!canLand) {
694
700
  leftForSteward.push(ws.id);
695
701
  if (stewardEnabled) {
696
- const woke = wakeRepoSteward(ws, (p.behind ?? 0) > 0 ? "base moved on (behind>0)" : "conflict");
702
+ const woke = wakeRepoSteward(ws, p.conflict === true ? "conflict" : "merge state unknown");
697
703
  if (woke) wokeStewards.push(woke);
698
704
  }
699
705
  continue;
@@ -710,8 +716,8 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
710
716
  createActivityEvent({
711
717
  clientId: `workspace-auto-merge-${ws.id}-${Date.now()}`,
712
718
  kind: "state",
713
- title: "Workspace auto-merging (clean fast-forward)",
714
- body: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead, clean)`,
719
+ title: behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
720
+ body: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
715
721
  meta: ws.branch ?? ws.id,
716
722
  icon: "ti-git-merge",
717
723
  view: "orchestrators",
@@ -719,7 +725,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
719
725
  });
720
726
  }
721
727
 
722
- return { scanned: candidates.length, merged, heldByLease, leftForSteward, wokeStewards };
728
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, leftForSteward, wokeStewards };
723
729
  }
724
730
 
725
731
  // Send a system DM, swallowing failures (a stale/missing/misconfigured target
@@ -1,6 +1,6 @@
1
- import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
2
1
  import { getAgentProfile } from "./config-store";
3
2
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
3
+ import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
4
4
  import type { SpawnPolicy, WorkspaceMode } from "./types";
5
5
 
6
6
  export function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
@@ -20,23 +20,6 @@ export function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): Worksp
20
20
  return policy.binding?.type === "channel" ? "shared" : "inherit";
21
21
  }
22
22
 
23
- function resolvedModelParams(policy: SpawnPolicy): Record<string, string> {
24
- if (!policy.model && !policy.effort) return {};
25
- try {
26
- const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
27
- return {
28
- ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
29
- ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
30
- ...(selection.effort ? { effort: selection.effort } : {}),
31
- };
32
- } catch {
33
- return {
34
- ...(policy.model ? { model: policy.model } : {}),
35
- ...(policy.effort ? { effort: policy.effort } : {}),
36
- };
37
- }
38
- }
39
-
40
23
  export interface ManagedSpawnContext {
41
24
  createdBy: string;
42
25
  requestedAt?: number;
@@ -46,22 +29,21 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
46
29
  const providerArgs = managedPolicyProviderArgs(policy);
47
30
  const prompt = managedPolicyLaunchPrompt(policy);
48
31
  const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
49
- return {
50
- action: "spawn",
32
+ return buildSpawnCommand({
51
33
  provider: policy.provider,
52
34
  cwd: policy.cwd,
53
35
  workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
54
- ...(policy.rig ? { rig: policy.rig } : {}),
55
- ...resolvedModelParams(policy),
56
- ...(policy.profile ? { profile: policy.profile } : {}),
57
- ...(agentProfile ? { agentProfile } : {}),
36
+ rig: policy.rig || undefined,
37
+ modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort, { onError: "passthrough", skipDefaultWhenEmpty: true }),
38
+ profile: policy.profile || undefined,
39
+ agentProfile,
58
40
  label: policy.label,
59
41
  tags: policy.tags,
60
42
  capabilities: policy.capabilities,
61
43
  approvalMode: policy.permissionMode,
62
44
  permissionMode: policy.permissionMode,
63
45
  providerArgs,
64
- ...(prompt ? { prompt } : {}),
46
+ prompt: prompt || undefined,
65
47
  headless: true,
66
48
  policyName: policy.name,
67
49
  spawnRequestId: requestId,
@@ -76,5 +58,5 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
76
58
  }),
77
59
  requestedBy: ctx.createdBy,
78
60
  requestedAt: ctx.requestedAt ?? Date.now(),
79
- };
61
+ });
80
62
  }