agent-relay-server 0.117.1 → 0.118.1

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.
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
- import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
2
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, INTERNAL_SPAWN_TYPES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
5
5
  import { ValidationError, listAgents } from "../db";
@@ -15,6 +15,7 @@ import { authContextFromRequest } from "../services/auth-context";
15
15
  import { listHostDirectories } from "../agent-spawn";
16
16
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
17
17
  import { quotaStateFromUnknown } from "../quota";
18
+ import { resolveInternalSpawnTarget, type InternalSpawnType } from "../auto-spawn-dispatch";
18
19
  import { type AgentKind, type AgentLifecycle, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMode } from "../types";
19
20
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
20
21
 
@@ -118,8 +119,36 @@ export const postAgentSpawn: Handler = async (req) => {
118
119
  if (!parsed.ok) return error(parsed.error, parsed.status);
119
120
  try {
120
121
  if (!isRecord(parsed.body)) return error("provider required");
121
- const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider | undefined;
122
- if (!provider) return error("provider required");
122
+
123
+ // #901 server-side internalSpawnType: when present, the resolver picks provider/model/effort
124
+ // from routing policy + quota bands so no internal caller needs to hard-code a raw model alias.
125
+ // Explicit caller model/effort are forwarded as pins so they still win over the resolver.
126
+ // Spawns that omit internalSpawnType are byte-identical to before (normal user/coordinator spawns).
127
+ const internalSpawnType = cleanString(parsed.body.internalSpawnType, "internalSpawnType", { max: 80 });
128
+ let resolvedProvider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider | undefined;
129
+ let resolvedModel = cleanString(parsed.body.model, "model", { max: 120 });
130
+ let resolvedEffort = cleanString(parsed.body.effort, "effort", { max: 120 }) as ProviderEffort | undefined;
131
+
132
+ if (internalSpawnType) {
133
+ if (!(INTERNAL_SPAWN_TYPES as readonly string[]).includes(internalSpawnType)) {
134
+ return error(`internalSpawnType must be one of: ${INTERNAL_SPAWN_TYPES.join(", ")}`);
135
+ }
136
+ // Fold explicit caller pins into the resolver so "explicit pin wins" applies here too —
137
+ // a provider:"codex" pin with internalSpawnType (no model) must not assemble codex +
138
+ // the resolver's claude-specific model.
139
+ const callerPin = {
140
+ ...(resolvedProvider ? { provider: resolvedProvider } : {}),
141
+ ...(resolvedModel ? { model: resolvedModel } : {}),
142
+ ...(resolvedEffort ? { effort: resolvedEffort } : {}),
143
+ };
144
+ const decision = resolveInternalSpawnTarget(internalSpawnType as InternalSpawnType, callerPin);
145
+ resolvedProvider = decision.target.provider;
146
+ resolvedModel = decision.target.model;
147
+ resolvedEffort = decision.target.effort as ProviderEffort | undefined;
148
+ }
149
+
150
+ if (!resolvedProvider) return error("provider required");
151
+
123
152
  // Thin transport: parse wire → AuthContext → spawnAgent service → serialize. The gate
124
153
  // (command:spawn scope, #221 no-grandchild + quota, #323 resource), caller-context
125
154
  // inheritance (#328/#331/#324), profile validation, lineage stamping, host selection, and
@@ -127,11 +156,11 @@ export const postAgentSpawn: Handler = async (req) => {
127
156
  // (epic #342, #349). The dashboard authenticates with an admin `*` token, so the tightened
128
157
  // command:spawn requirement is transparent to it.
129
158
  const input: SpawnAgentInput = {
130
- provider,
159
+ provider: resolvedProvider,
131
160
  orchestratorId: cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 }),
132
161
  cwd: cleanString(parsed.body.cwd, "cwd", { max: 500 }),
133
- model: cleanString(parsed.body.model, "model", { max: 120 }),
134
- effort: cleanString(parsed.body.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
162
+ model: resolvedModel,
163
+ effort: resolvedEffort,
135
164
  approvalMode: optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
136
165
  workspaceMode: optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
137
166
  lifecycle: optionalEnum(parsed.body.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined,
@@ -8,12 +8,13 @@ import { clearActiveMemories, injectAlwaysReloadMemories } from "../memory-servi
8
8
  import { getCommand, listCommands, updateCommand } from "../commands-db";
9
9
  import { emitOrchestratorStatus } from "../sse";
10
10
  import { getCompactionWatch } from "../compaction-watch";
11
- import { isRecord } from "agent-relay-sdk";
11
+ import { isRecord, sanitizeFsName, type LandGateRunResult } from "agent-relay-sdk";
12
+ import { storeTextArtifact } from "../artifact-text";
12
13
  import { authContextFromRequest } from "../services/auth-context";
13
14
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
15
  import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
15
16
  import { ServiceAuthError } from "../services/errors";
16
- import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
17
+ import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded, notifyLandGateFailed, notifyLandGateWarnings } from "../branch-landed";
17
18
  import { applyIdleRefreshResult } from "../maintenance/workspaces";
18
19
  import { seedProviderConfigs } from "../provider-config-store";
19
20
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
@@ -24,6 +25,50 @@ import { type Command, type CommandStatus, type CreateCommandInput, type Workspa
24
25
 
25
26
  const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
26
27
 
28
+ // #902 — normalize a land-gate run result that round-tripped through the command bus
29
+ // (JSON, loosely typed) back into a LandGateRunResult. Returns undefined when the value
30
+ // isn't a gate result, so the caller falls back to the generic land-failed path.
31
+ function normalizeGateRunResult(value: unknown): LandGateRunResult | undefined {
32
+ if (!isRecord(value)) return undefined;
33
+ const name = typeof value.name === "string" ? value.name : undefined;
34
+ const command = typeof value.command === "string" ? value.command : "";
35
+ if (!name) return undefined;
36
+ return {
37
+ name,
38
+ command,
39
+ optional: value.optional === true,
40
+ passed: value.passed === true,
41
+ exitCode: typeof value.exitCode === "number" ? value.exitCode : null,
42
+ timedOut: value.timedOut === true,
43
+ durationMs: typeof value.durationMs === "number" ? value.durationMs : 0,
44
+ outputTail: typeof value.outputTail === "string" ? value.outputTail : "",
45
+ output: typeof value.output === "string" ? value.output : (typeof value.outputTail === "string" ? value.outputTail : ""),
46
+ };
47
+ }
48
+
49
+ function normalizeGateWarnings(value: unknown): LandGateRunResult[] {
50
+ if (!Array.isArray(value)) return [];
51
+ return value.map(normalizeGateRunResult).filter((g): g is LandGateRunResult => Boolean(g));
52
+ }
53
+
54
+ // Upload a gate's full output to a relay artifact; returns its id, or undefined on any
55
+ // failure (artifact upload is best-effort — the tail still rides in the notification).
56
+ async function uploadGateOutputArtifact(workspaceId: string, gate: LandGateRunResult): Promise<string | undefined> {
57
+ try {
58
+ const safeName = sanitizeFsName(gate.name, { replacement: "-", maxLen: 60, trimEdge: true, fallback: "gate" });
59
+ const stored = await storeTextArtifact({
60
+ text: gate.output || gate.outputTail || "(no output)",
61
+ filename: `land-gate-${safeName}-${workspaceId}.log`,
62
+ createdBy: "server",
63
+ metadata: { kind: "land-gate-output", workspaceId, gateName: gate.name, gateCommand: gate.command, exitCode: gate.exitCode, timedOut: gate.timedOut },
64
+ });
65
+ return stored.id;
66
+ } catch (error) {
67
+ console.warn(`land-gate artifact upload failed for ${workspaceId} gate "${gate.name}": ${String(error)}`);
68
+ return undefined;
69
+ }
70
+ }
71
+
27
72
  function auditCommandOutcome(command: Command): void {
28
73
  if (command.status !== "succeeded" && command.status !== "failed") return;
29
74
  if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
@@ -284,6 +329,27 @@ export const patchCommand: Handler = async (req, params) => {
284
329
  pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
285
330
  });
286
331
  }
332
+ // #902 — OPTIONAL land gates that failed on a SUCCESSFUL land (merged:true, ref
333
+ // advanced). Warn the worker (tail + combined-output artifact) without blocking.
334
+ // Best-effort + isolated so a warn-notify throw can't undo the completed land.
335
+ const gateWarnings = command.result.merged === true ? normalizeGateWarnings(command.result.gateWarnings) : [];
336
+ if (gateWarnings.length && landedWorkspace) {
337
+ const warnWorkspace = landedWorkspace;
338
+ void (async () => {
339
+ const combined = gateWarnings.map((g) => `### ${g.name} (${g.command}) — ${g.timedOut ? "timed out" : g.exitCode === null ? "could not run" : `exited ${g.exitCode}`}\n${g.output || g.outputTail || "(no output)"}`).join("\n\n");
340
+ let artifactId: string | undefined;
341
+ try {
342
+ artifactId = (await storeTextArtifact({ text: combined, filename: `land-gate-warnings-${warnWorkspace.id}.log`, createdBy: "server", metadata: { kind: "land-gate-warnings", workspaceId: warnWorkspace.id, gateNames: gateWarnings.map((g) => g.name) } })).id;
343
+ } catch (error) {
344
+ console.warn(`land-gate-warnings artifact upload failed for ${warnWorkspace.id}: ${String(error)}`);
345
+ }
346
+ try {
347
+ notifyLandGateWarnings({ workspace: warnWorkspace, warnings: gateWarnings, artifactId });
348
+ } catch (error) {
349
+ console.warn(`land-gate-warnings notify threw for ${warnWorkspace.id}: ${String(error)}`);
350
+ }
351
+ })();
352
+ }
287
353
  }
288
354
  } else if (command.status === "failed" && command.correlationId) {
289
355
  // Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
@@ -319,11 +385,29 @@ export const patchCommand: Handler = async (req, params) => {
319
385
  },
320
386
  });
321
387
  }
322
- notifyBranchLandFailed({
323
- workspace: current,
324
- commandId: command.id,
325
- reason: command.error ?? "merge failed",
326
- });
388
+ const gateFailure = isRecord(command.result) ? normalizeGateRunResult(command.result.gateFailure) : undefined;
389
+ if (gateFailure) {
390
+ // #902 — a REQUIRED land gate failed: the orchestrator aborted WITHOUT advancing
391
+ // the ref and settled status "review_requested" (not "conflict", so the steward
392
+ // path above is never taken). Send the richer worker+coordinator notice (gate name
393
+ // + output tail + full-output artifact) INSTEAD of the generic land-failed wake.
394
+ // Best-effort + isolated: the land already (didn't) happen; a notify/upload throw
395
+ // must not break command settling.
396
+ void (async () => {
397
+ const artifactId = await uploadGateOutputArtifact(current.id, gateFailure);
398
+ try {
399
+ notifyLandGateFailed({ workspace: current, gate: gateFailure, artifactId });
400
+ } catch (error) {
401
+ console.warn(`land-gate-failed notify threw for ${current.id}: ${String(error)}`);
402
+ }
403
+ })();
404
+ } else {
405
+ notifyBranchLandFailed({
406
+ workspace: current,
407
+ commandId: command.id,
408
+ reason: command.error ?? "merge failed",
409
+ });
410
+ }
327
411
  }
328
412
  }
329
413
  }
@@ -16,31 +16,42 @@ export function parseOwnerRepoFromRemote(url: string | undefined): string | unde
16
16
  }
17
17
 
18
18
  let remoteRepoCache: Map<string, string | undefined> | undefined;
19
+ let remoteUrlCache: Map<string, string | undefined> | undefined;
19
20
 
20
- /**
21
- * `owner/repo` from a repo root's `origin` remote, cached per root. Works from any
22
- * subdirectory of the repo (`git -C` walks up). Returns undefined when the dir isn't a
23
- * git repo, has no `origin`, or the URL doesn't parse.
24
- */
25
- export function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
21
+ export function remoteUrlFromGitRemote(repoRoot: string | undefined): string | undefined {
26
22
  if (!repoRoot) return undefined;
27
- if (!remoteRepoCache) remoteRepoCache = new Map();
28
- if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
29
- let repo: string | undefined;
23
+ if (!remoteUrlCache) remoteUrlCache = new Map();
24
+ if (remoteUrlCache.has(repoRoot)) return remoteUrlCache.get(repoRoot);
25
+ let url: string | undefined;
30
26
  try {
31
27
  const proc = Bun.spawnSync(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
32
28
  stdin: "ignore",
33
29
  stdout: "pipe",
34
30
  stderr: "ignore",
35
31
  });
36
- if (proc.exitCode === 0) repo = parseOwnerRepoFromRemote(Buffer.from(proc.stdout).toString("utf8"));
32
+ if (proc.exitCode === 0) url = Buffer.from(proc.stdout).toString("utf8").trim() || undefined;
37
33
  } catch {
38
- repo = undefined;
34
+ url = undefined;
39
35
  }
36
+ remoteUrlCache.set(repoRoot, url);
37
+ return url;
38
+ }
39
+
40
+ /**
41
+ * `owner/repo` from a repo root's `origin` remote, cached per root. Works from any
42
+ * subdirectory of the repo (`git -C` walks up). Returns undefined when the dir isn't a
43
+ * git repo, has no `origin`, or the URL doesn't parse.
44
+ */
45
+ export function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
46
+ if (!repoRoot) return undefined;
47
+ if (!remoteRepoCache) remoteRepoCache = new Map();
48
+ if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
49
+ const repo = parseOwnerRepoFromRemote(remoteUrlFromGitRemote(repoRoot));
40
50
  remoteRepoCache.set(repoRoot, repo);
41
51
  return repo;
42
52
  }
43
53
 
44
54
  export function __resetRemoteRepoCacheForTests(): void {
45
55
  remoteRepoCache = undefined;
56
+ remoteUrlCache = undefined;
46
57
  }
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { addProjectRoot, ensureProject, resolveProjectForCwd, rootsForProject, setProjectIssueRepo } from "../db";
4
4
  import { ingestClaudeMd } from "../project-ingest";
5
- import { repoFromGitRemote } from "./git-remote";
5
+ import { remoteUrlFromGitRemote, repoFromGitRemote } from "./git-remote";
6
6
  import type { Project, ProjectRoot } from "../types";
7
7
 
8
8
  interface EnsureProjectRootInput {
@@ -13,6 +13,10 @@ interface EnsureProjectRootInput {
13
13
  tenantId?: string;
14
14
  /** Explicit `owner/repo` override (#533); wins over the git-remote-derived default. */
15
15
  issueRepo?: string;
16
+ /** Explicit clone URL for host acquisition (#410); auto-derived from origin when omitted. */
17
+ remoteUrl?: string;
18
+ /** Optional branch/tag/ref for host acquisition (#410). */
19
+ ref?: string;
16
20
  }
17
21
 
18
22
  interface EnsureProjectRootResult {
@@ -38,7 +42,8 @@ export function ensureProjectRoot(input: EnsureProjectRootInput): EnsureProjectR
38
42
  ...(input.tenantId !== undefined ? { tenantId: input.tenantId } : {}),
39
43
  ...(input.issueRepo !== undefined ? { issueRepo: input.issueRepo } : {}),
40
44
  });
41
- const roots = addProjectRoot(project.id, input.rootPath);
45
+ const remoteUrl = input.remoteUrl ?? remoteUrlFromGitRemote(input.rootPath);
46
+ const roots = addProjectRoot(project.id, input.rootPath, { remoteUrl, ref: input.ref });
42
47
  // Auto-derive issueRepo from the root's git remote (#533) when not explicitly provided.
43
48
  // `onlyIfNull` ensures an explicit override (or earlier derivation) is never clobbered.
44
49
  if (input.issueRepo === undefined && !project.issueRepo) {
@@ -86,6 +86,30 @@ function assertIsolatedSpawnHasGitRepo(cwd: string): void {
86
86
  throw new ValidationError(`workspaceMode "isolated" requires a git repo, but ${cwd} is not one. Pass an explicit cwd under a repo, or use workspaceMode "shared"/"inherit".`);
87
87
  }
88
88
 
89
+ function acquisitionManifestFor(projectId: string, cwd: string): {
90
+ mode: "project-root";
91
+ projectId: string;
92
+ rootPath: string;
93
+ cwd: string;
94
+ remoteUrl: string;
95
+ ref?: string;
96
+ sync: "ff-only";
97
+ } | undefined {
98
+ const root = rootsForProject(projectId)
99
+ .filter((candidate) => candidate.remoteUrl && isPathWithinBase(cwd, candidate.rootPath))
100
+ .sort((a, b) => b.rootPath.length - a.rootPath.length)[0];
101
+ if (!root?.remoteUrl) return undefined;
102
+ return {
103
+ mode: "project-root",
104
+ projectId,
105
+ rootPath: root.rootPath,
106
+ cwd,
107
+ remoteUrl: root.remoteUrl,
108
+ ...(root.ref ? { ref: root.ref } : {}),
109
+ sync: "ff-only",
110
+ };
111
+ }
112
+
89
113
  function taskLinkedRepoCwd(taskId: number, callerCwd?: string): string {
90
114
  const task = getTaskDetail(taskId);
91
115
  if (!task) throw new ValidationError(`task ${taskId} not found or not a deliverable task`);
@@ -389,9 +413,10 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
389
413
  // #838 — spawn-with-task hydration. When a taskId is given, relay assembles the worker's
390
414
  // first-turn context FROM the durable Task (intent + linked-issue spec/acceptance + recorded
391
415
  // decisions + history + latest steer), resolved server-side ONCE, and projects it onto the
392
- // provider-appropriate channel (Claude → systemPromptAppend; Codex/other → first message). The
393
- // caller-supplied systemPromptAppend/prompt are preserved. Absent (no taskId) = byte-identical
394
- // to today: `resolvedSystemPromptAppend`/`resolvedPrompt` stay the raw inputs.
416
+ // provider-appropriate channel (Claude → systemPromptAppend; Codex/other → first message).
417
+ // Caller prompt is not re-appended after hydration: auto-scaffolded prompt is already the Task
418
+ // intent, and explicit task spawns should use taskId + steer as their single instruction source.
419
+ // Absent (no taskId) = byte-identical to today: raw prompt/system append pass through.
395
420
  let resolvedSystemPromptAppend = input.systemPromptAppend;
396
421
  let resolvedPrompt = input.prompt;
397
422
  let hydratedTask: Awaited<ReturnType<typeof hydrateTaskContext>> | undefined;
@@ -441,8 +466,9 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
441
466
  // branch unless explicitly narrowed.
442
467
  const workspaceMode = input.workspaceMode ?? (callerId || input.taskId !== undefined ? ("isolated" as WorkspaceMode) : undefined);
443
468
  const lifecycle = input.lifecycle ?? "persistent";
469
+ const acquisition = callerProject ? acquisitionManifestFor(callerProject.id, resolvedCwd) : undefined;
444
470
  if (workspaceMode === "isolated" && (input.taskId !== undefined || input.workspaceMode === "isolated")) {
445
- assertIsolatedSpawnHasGitRepo(resolvedCwd);
471
+ if (!acquisition) assertIsolatedSpawnHasGitRepo(resolvedCwd);
446
472
  }
447
473
 
448
474
  const spawnRequestId = input.spawnRequestId ?? generateSpawnRequestId();
@@ -580,6 +606,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
580
606
  reviewer: input.reviewer,
581
607
  taskId: input.taskId,
582
608
  resumeWorkspace: input.resumeWorkspace,
609
+ acquisition,
583
610
  ...(expectReply ? { expectReply } : {}),
584
611
  policyName: input.policyName,
585
612
  spawnRequestId,
@@ -333,9 +333,12 @@ function joinBlocks(...parts: Array<string | undefined>): string | undefined {
333
333
 
334
334
  /**
335
335
  * PURE projection: map the hydration brief onto the provider-contract spawn channel.
336
- * Caller-supplied systemPromptAppend/prompt are preserved (appended after the relay material).
337
- * The steer used as a first-message kick is sentinel-stripped so it cannot forge the
338
- * data fence in the (instruction-bearing) systemPromptAppend brief.
336
+ * Once a Task brief is hydrated, the caller prompt is not re-appended: for auto-scaffolded
337
+ * spawns it is already the Task intent, and for explicit task spawns the Task + steer are the
338
+ * single instruction source. Caller system append is preserved unless it already embeds the
339
+ * hydrated brief, which avoids delivering the same brief through both provider channels.
340
+ * The steer used as a first-message kick is sentinel-stripped so it cannot forge the data fence
341
+ * in the (instruction-bearing) systemPromptAppend brief.
339
342
  */
340
343
  export function projectTaskHydration(
341
344
  provider: SpawnProvider | string,
@@ -344,18 +347,29 @@ export function projectTaskHydration(
344
347
  callerSystemPromptAppend: string | undefined,
345
348
  callerPrompt: string | undefined,
346
349
  ): TaskHydrationProjection {
350
+ void callerPrompt;
351
+ const callerAppend = removeDuplicateContext(context, callerSystemPromptAppend);
347
352
  if (taskHydrationChannel(provider) === "append-system-prompt") {
348
353
  return {
349
- systemPromptAppend: joinBlocks(context, callerSystemPromptAppend),
350
- prompt: joinBlocks(steer ? stripSentinels(steer) : undefined, callerPrompt),
354
+ systemPromptAppend: joinBlocks(context, callerAppend),
355
+ prompt: joinBlocks(steer ? stripSentinels(steer) : undefined),
351
356
  };
352
357
  }
353
358
  return {
354
- systemPromptAppend: joinBlocks(callerSystemPromptAppend),
355
- prompt: joinBlocks(context, callerPrompt),
359
+ systemPromptAppend: joinBlocks(callerAppend),
360
+ prompt: joinBlocks(context),
356
361
  };
357
362
  }
358
363
 
364
+ function removeDuplicateContext(context: string, value: string | undefined): string | undefined {
365
+ const ctx = context.trim();
366
+ const raw = value?.trim();
367
+ if (!raw) return undefined;
368
+ if (!ctx) return raw;
369
+ const cleaned = raw.split(ctx).map((part) => part.trim()).filter(Boolean).join("\n\n");
370
+ return cleaned || undefined;
371
+ }
372
+
359
373
  function taskHydrationChannel(provider: SpawnProvider | string): "prompt" | "append-system-prompt" {
360
374
  return getManifest(provider)?.launch?.taskHydration?.channel ?? "prompt";
361
375
  }
@@ -121,6 +121,16 @@ interface BuildSpawnCommandOptions {
121
121
  requestedVia?: string;
122
122
  /** #635 — relay-enriched resume-workspace target. Relayed verbatim to the orchestrator. */
123
123
  resumeWorkspace?: { branch: string; mode: "attach" | "branch-from"; worktreePath?: string; workspaceId?: string; baseRef?: string; baseSha?: string };
124
+ /** #410 — host-side lazy clone/sync manifest, applied before worktree prep. */
125
+ acquisition?: {
126
+ mode: "project-root";
127
+ projectId: string;
128
+ rootPath: string;
129
+ cwd: string;
130
+ remoteUrl: string;
131
+ ref?: string;
132
+ sync: "ff-only";
133
+ };
124
134
 
125
135
  /** Extra params merged last (e.g. Claude-resume fields). */
126
136
  extra?: Record<string, unknown>;
@@ -172,6 +182,7 @@ export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string
172
182
  ...def(opts.requestedVia, "requestedVia"),
173
183
  ...def(opts.spawnRequestId, "spawnRequestId"),
174
184
  ...def(opts.resumeWorkspace, "resumeWorkspace"),
185
+ ...def(opts.acquisition, "acquisition"),
175
186
  env: opts.env,
176
187
  requestedBy: opts.requestedBy,
177
188
  requestedAt: opts.requestedAt ?? Date.now(),
package/src/steward.ts CHANGED
@@ -5,6 +5,9 @@ import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
5
5
  import { listOrchestrators } from "./db";
6
6
  import { getLifecycleManager } from "./lifecycle-manager";
7
7
  import { renderTemplate } from "./prompt-resolver";
8
+ import { resolveInternalSpawnTarget } from "./auto-spawn-dispatch";
9
+ import type { DispatchPin } from "./task-dispatcher";
10
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
8
11
  import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
9
12
  import { isPathWithinBase } from "./utils";
10
13
 
@@ -39,16 +42,27 @@ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: Ste
39
42
  // repo and those worktrees, and its provider-agent token's cwdPrefixes derive
40
43
  // from this cwd — baseDir is their common ancestor, so the merge action against a
41
44
  // worktree path passes authorization. The prompt scopes it to the specific repo.
45
+
46
+ // #901 — resolve via the auto-spawn dispatcher so routing policy + quota bands pick
47
+ // the model. StewardConfig.provider/model/effort are OPTIONAL EXPLICIT PINS: when set
48
+ // by the operator they fold into the DispatchPin and win over the router's choice.
49
+ const pin: DispatchPin = {
50
+ provider: config.provider,
51
+ ...(config.model ? { model: config.model } : {}),
52
+ ...(config.effort ? { effort: config.effort } : {}),
53
+ };
54
+ const resolved = resolveInternalSpawnTarget("landing-steward", pin);
55
+
42
56
  return {
43
57
  name: repoStewardPolicyName(repoRoot),
44
58
  description: `Autonomous repo steward for ${repoRoot} (issue #167).`,
45
59
  enabled: true,
46
60
  orchestratorId: owner.id,
47
61
  cwd: owner.baseDir,
48
- provider: config.provider,
62
+ provider: resolved.target.provider,
49
63
  workspaceMode: "inherit",
50
- ...(config.model ? { model: config.model } : {}),
51
- ...(config.effort ? { effort: config.effort } : {}),
64
+ ...(resolved.target.model ? { model: resolved.target.model } : {}),
65
+ ...(resolved.target.effort ? { effort: resolved.target.effort } : {}),
52
66
  providerArgs: [],
53
67
  prompt: buildStewardPrompt(repoRoot),
54
68
  tags: ["steward"],
@@ -65,18 +79,36 @@ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: Ste
65
79
 
66
80
  // Fields whose change means the persisted policy should be refreshed. Avoids
67
81
  // churning the config version (and the lifecycle reconcile) every sweep.
82
+ //
83
+ // Churn guard (#901): model-only diffs are suppressed when the stored model is still
84
+ // enabled+available — quota-band oscillation would otherwise change the resolved model on
85
+ // every sweep and trigger a lifecycle reconcile each time. Provider changes ARE compared —
86
+ // a genuine provider switch must always propagate.
87
+ // Self-heal exception: if the stored model is explicitly disabled/unavailable in the
88
+ // effective catalog, allow the diff so the steward refreshes onto an enabled model.
68
89
  function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): boolean {
69
- return (
90
+ if (
70
91
  existing.enabled !== desired.enabled ||
71
92
  existing.provider !== desired.provider ||
72
- existing.model !== desired.model ||
73
93
  existing.effort !== desired.effort ||
74
94
  existing.permissionMode !== desired.permissionMode ||
75
95
  existing.cwd !== desired.cwd ||
76
96
  existing.orchestratorId !== desired.orchestratorId ||
77
97
  existing.prompt !== desired.prompt ||
78
98
  existing.onDemand?.keepaliveSeconds !== desired.onDemand?.keepaliveSeconds
79
- );
99
+ ) return true;
100
+
101
+ // Model-only diff: suppress (anti-thrash) unless the stored model is disabled/unavailable.
102
+ if (existing.model !== desired.model && existing.model) {
103
+ const catalog = effectiveProviderCatalogList();
104
+ const providerEntry = catalog.find((c) => c.provider === existing.provider);
105
+ const storedModel = providerEntry?.models.find((m) => m.alias === existing.model || m.providerModel === existing.model);
106
+ // Only trigger refresh when the model is EXPLICITLY disabled/unavailable in the catalog.
107
+ // If it's simply absent (incomplete catalog data), suppress to avoid false churn.
108
+ if (storedModel && (storedModel.disabled || storedModel.unavailable)) return true;
109
+ }
110
+
111
+ return false;
80
112
  }
81
113
 
82
114
  /**