agent-relay-server 0.117.1 → 0.118.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.117.1",
4
+ "version": "0.118.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,36 @@
1
+ import { getArtifactStorage } from "./artifact-storage";
2
+ import { bytesToStream } from "./http-body";
3
+ import { createArtifact, upsertArtifactBlob } from "./db/artifacts";
4
+ import type { ArtifactKind } from "./types";
5
+
6
+ /**
7
+ * Persist a UTF-8 text blob as a relay artifact and return its id, composing the
8
+ * same store→blob→record pipeline the MCP upload tool uses. Used by the land-gate
9
+ * pipeline (#902) to stream a failing gate's FULL output to an artifact so the
10
+ * worker/coordinator notification carries only the tail + this ref — never the
11
+ * flood of full output.
12
+ */
13
+ export async function storeTextArtifact(input: {
14
+ text: string;
15
+ filename: string;
16
+ createdBy: string;
17
+ kind?: ArtifactKind;
18
+ mediaType?: string;
19
+ metadata?: Record<string, unknown>;
20
+ }): Promise<{ id: string; size: number }> {
21
+ const bytes = new TextEncoder().encode(input.text);
22
+ const mediaType = input.mediaType ?? "text/plain; charset=utf-8";
23
+ const storage = getArtifactStorage();
24
+ const stored = await storage.store(bytesToStream(bytes), { mediaType, size: bytes.byteLength });
25
+ upsertArtifactBlob({ digest: stored.digest, storageUri: stored.storageUri, mediaType, size: stored.size });
26
+ const artifact = createArtifact({
27
+ blobDigest: stored.digest,
28
+ mediaType,
29
+ kind: input.kind ?? "document",
30
+ filename: input.filename,
31
+ size: stored.size,
32
+ createdBy: input.createdBy,
33
+ metadata: input.metadata,
34
+ });
35
+ return { id: artifact.id, size: stored.size };
36
+ }
@@ -0,0 +1,136 @@
1
+ // #901 — resolver shim + configurable per-type work-shape config for internal auto-spawns.
2
+ // PURE ADDITIONS — zero behavior change at existing call sites. The dispatch brain
3
+ // (dispatchTask) is reused verbatim; this module only drives it with the configured
4
+ // work-shape for each InternalSpawnType and the #901 degrade-never-refuse flag.
5
+ import { getConfig, setConfig } from "./config-store";
6
+ import { dispatchTask, type DispatchDecision, type DispatchPin } from "./task-dispatcher";
7
+ import {
8
+ INTERNAL_SPAWN_TYPES,
9
+ TASK_DISPATCH_COMPLEXITIES,
10
+ TASK_DISPATCH_CRITICALITIES,
11
+ TASK_DISPATCH_FOOTPRINTS,
12
+ TASK_DISPATCH_TYPES,
13
+ isRecord,
14
+ } from "agent-relay-sdk";
15
+ import type {
16
+ AutoSpawnDispatchConfig,
17
+ InternalSpawnType,
18
+ TaskDispatch,
19
+ TaskDispatchCriticality,
20
+ WorkShapeProfile,
21
+ } from "agent-relay-sdk";
22
+
23
+ export const AUTO_SPAWN_DISPATCH_NAMESPACE = "auto-spawn-dispatch";
24
+ export const AUTO_SPAWN_DISPATCH_KEY = "default";
25
+
26
+ // Re-export so callers only need this module.
27
+ export type { InternalSpawnType } from "agent-relay-sdk";
28
+
29
+ const ALL_AUTO_PROFILE: WorkShapeProfile = {
30
+ complexity: "auto",
31
+ type: "auto",
32
+ contextFootprint: "auto",
33
+ criticality: "auto",
34
+ };
35
+
36
+ export const DEFAULT_AUTO_SPAWN_DISPATCH_CONFIG: AutoSpawnDispatchConfig = {
37
+ schemaVersion: 1,
38
+ profiles: {
39
+ "landing-steward": { ...ALL_AUTO_PROFILE },
40
+ "file-size-ratchet-heal": { ...ALL_AUTO_PROFILE },
41
+ "spawn-smoke": { ...ALL_AUTO_PROFILE },
42
+ },
43
+ };
44
+
45
+ // Built-in criticality for each spawn type when the profile's criticality dimension is "auto".
46
+ // landing-steward is high because it is the critical-infra auto-land path; the degrade-never-refuse
47
+ // safety (mustResolveAvailable) is therefore always on for it unless the operator overrides.
48
+ const BUILT_IN_CRITICALITY: Record<InternalSpawnType, TaskDispatchCriticality> = {
49
+ "landing-steward": "high",
50
+ "file-size-ratchet-heal": "normal",
51
+ "spawn-smoke": "low",
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Config store
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export function getAutoSpawnDispatchConfig(): AutoSpawnDispatchConfig {
59
+ const entry = getConfig<AutoSpawnDispatchConfig>(AUTO_SPAWN_DISPATCH_NAMESPACE, AUTO_SPAWN_DISPATCH_KEY);
60
+ return entry ? normalizeAutoSpawnDispatchConfig(entry.value) : { ...DEFAULT_AUTO_SPAWN_DISPATCH_CONFIG };
61
+ }
62
+
63
+ export function setAutoSpawnDispatchConfig(value: unknown, opts: { updatedBy?: string } = {}): AutoSpawnDispatchConfig {
64
+ const normalized = normalizeAutoSpawnDispatchConfig(value);
65
+ const current = getAutoSpawnDispatchConfig();
66
+ if (JSON.stringify(normalized) === JSON.stringify(current)) return normalized;
67
+ setConfig(AUTO_SPAWN_DISPATCH_NAMESPACE, AUTO_SPAWN_DISPATCH_KEY, normalized, opts.updatedBy);
68
+ return normalized;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Normalizer — coerces arbitrary input into a valid AutoSpawnDispatchConfig.
73
+ // "auto" is the safe default for every dimension, so a partial config is always valid.
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export function normalizeAutoSpawnDispatchConfig(value: unknown): AutoSpawnDispatchConfig {
77
+ const input = isRecord(value) ? value : {};
78
+ const rawProfiles = isRecord(input.profiles) ? input.profiles : {};
79
+ const profiles = {} as AutoSpawnDispatchConfig["profiles"];
80
+ for (const type of INTERNAL_SPAWN_TYPES) {
81
+ const raw = isRecord(rawProfiles[type]) ? rawProfiles[type] : {};
82
+ profiles[type] = normalizeWorkShapeProfile(raw);
83
+ }
84
+ return { schemaVersion: 1, profiles };
85
+ }
86
+
87
+ export function validateAutoSpawnDispatchConfig(value: unknown): AutoSpawnDispatchConfig {
88
+ return normalizeAutoSpawnDispatchConfig(value);
89
+ }
90
+
91
+ function normalizeWorkShapeProfile(raw: Record<string, unknown>): WorkShapeProfile {
92
+ return {
93
+ complexity: normalizeAutoDim(raw.complexity, TASK_DISPATCH_COMPLEXITIES),
94
+ type: normalizeAutoDim(raw.type, TASK_DISPATCH_TYPES),
95
+ contextFootprint: normalizeAutoDim(raw.contextFootprint, TASK_DISPATCH_FOOTPRINTS),
96
+ criticality: normalizeAutoDim(raw.criticality, TASK_DISPATCH_CRITICALITIES),
97
+ };
98
+ }
99
+
100
+ function normalizeAutoDim<T extends string>(value: unknown, allowed: readonly T[]): "auto" | T {
101
+ if (value === "auto") return "auto";
102
+ if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T;
103
+ return "auto";
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Resolver shim
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function effectiveCriticality(type: InternalSpawnType, profile: WorkShapeProfile): TaskDispatchCriticality {
111
+ return profile.criticality === "auto" ? BUILT_IN_CRITICALITY[type] : profile.criticality;
112
+ }
113
+
114
+ /**
115
+ * Resolve a concrete spawn target for an internal auto-spawn. Reads the configured work-shape
116
+ * for `type`, maps each "auto" dimension → undefined (wildcard), and calls dispatchTask.
117
+ * Returns the full DispatchDecision so callers can log WHY and surface the rationale.
118
+ *
119
+ * For high-criticality types (landing-steward by default) the degrade-never-refuse flag is set,
120
+ * guaranteeing a provider is returned even when all quota pools are blocked.
121
+ */
122
+ export function resolveInternalSpawnTarget(type: InternalSpawnType, pin?: DispatchPin): DispatchDecision {
123
+ const config = getAutoSpawnDispatchConfig();
124
+ const profile = config.profiles[type];
125
+
126
+ // Map "auto" → undefined; a concrete value is passed through to constrain the dispatch match.
127
+ const shape: TaskDispatch = {
128
+ ...(profile.complexity !== "auto" ? { complexity: profile.complexity } : {}),
129
+ ...(profile.type !== "auto" ? { type: profile.type } : {}),
130
+ ...(profile.contextFootprint !== "auto" ? { contextFootprint: profile.contextFootprint } : {}),
131
+ ...(profile.criticality !== "auto" ? { criticality: profile.criticality } : {}),
132
+ };
133
+
134
+ const mustResolveAvailable = effectiveCriticality(type, profile) === "high";
135
+ return dispatchTask(shape, pin ?? {}, { mustResolveAvailable });
136
+ }
@@ -1,4 +1,4 @@
1
- import { errMessage, type BaseWorktreeSyncResult } from "agent-relay-sdk";
1
+ import { errMessage, type BaseWorktreeSyncResult, type LandGateRunResult } from "agent-relay-sdk";
2
2
  import { emitRelayEvent } from "./events";
3
3
  import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
4
4
  import { registerDefaultAudienceRules, audiencePredicates } from "./notification-rules";
@@ -357,6 +357,132 @@ export function notifyBranchLandFailed(input: {
357
357
  });
358
358
  }
359
359
 
360
+ function gateOutcomeLabel(gate: LandGateRunResult): string {
361
+ if (gate.timedOut) return "timed out";
362
+ if (gate.exitCode === null) return "could not run";
363
+ return `exited ${gate.exitCode}`;
364
+ }
365
+
366
+ /**
367
+ * #902 — a REQUIRED land gate failed against the candidate merged tree, so the land was
368
+ * aborted WITHOUT advancing `main` (ref unchanged). This is a CORRECTNESS gate, orthogonal
369
+ * to merge conflicts: it NEVER routes through the steward/conflict path. Wake the worker
370
+ * (owner) AND its coordinator with the failing gate name + a bounded output TAIL + the relay
371
+ * artifact ref holding the full output, so the worker fixes the gate and re-lands cleanly.
372
+ * Durable (ttl null): the owner is parked idle after `ready` and must still learn why its
373
+ * land bounced. The full gate output lives in the artifact — the notice carries only the tail,
374
+ * so a noisy gate never floods relay.
375
+ */
376
+ export function notifyLandGateFailed(input: {
377
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
378
+ gate: LandGateRunResult;
379
+ /** Relay artifact id holding the full gate output, when the upload succeeded. */
380
+ artifactId?: string;
381
+ }): void {
382
+ const { workspace, gate } = input;
383
+ const base = workspace.baseRef ?? "base";
384
+ const owner = workspace.ownerAgentId;
385
+ const coordinator = resolveCoordinatorAgentId(owner ?? undefined);
386
+ const fallback = stewardFallbackTarget();
387
+ const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
388
+ const reasonLabel = gateOutcomeLabel(gate);
389
+ const artifactLabel = input.artifactId ? ` Full output: artifact \`${input.artifactId}\`.` : "";
390
+ const tail = gate.outputTail?.trim() ? `\n\n\`\`\`\n${gate.outputTail.trim()}\n\`\`\`` : "";
391
+ const body = `🚦 ${branchLabel} did NOT land: required land gate \`${gate.name}\` ${reasonLabel} against the candidate merge into \`${base}\`. \`${base}\` was NOT advanced — fix the gate and re-land (\`agent-relay workspace ready\`).${artifactLabel}${tail}`;
392
+
393
+ const payload = {
394
+ kind: "workspace.land-gate-failed",
395
+ workspaceId: workspace.id,
396
+ repoRoot: workspace.repoRoot,
397
+ branch: workspace.branch,
398
+ base,
399
+ author: owner,
400
+ gateName: gate.name,
401
+ gateCommand: gate.command,
402
+ exitCode: gate.exitCode,
403
+ timedOut: gate.timedOut,
404
+ durationMs: gate.durationMs,
405
+ artifactId: input.artifactId,
406
+ };
407
+
408
+ emitRelayEvent({ type: "workspace.land_gate_failed", source: "server", subject: workspace.id, data: payload });
409
+
410
+ createActivityEvent({
411
+ clientId: `workspace-land-gate-failed-${workspace.id}-${Date.now()}`,
412
+ kind: "state",
413
+ title: "Land blocked by gate failure",
414
+ body: `${workspace.branch ?? workspace.id} → ${base}: required gate "${gate.name}" ${reasonLabel} — ${base} not advanced`,
415
+ meta: workspace.branch ?? workspace.id,
416
+ icon: "ti-alert-octagon",
417
+ view: "orchestrators",
418
+ metadata: { source: "server", maintenanceJobId: "workspace-land-gate", workspaceId: workspace.id, repoRoot: workspace.repoRoot, gateName: gate.name, ...(input.artifactId ? { artifactId: input.artifactId } : {}) },
419
+ });
420
+
421
+ // Worker + coordinator (spec). Fall back to the configured steward target only when no
422
+ // coordinator resolves (a teamless land) so the bounce always reaches a human/agent.
423
+ const recipients = [...new Set([owner, coordinator ?? fallback].filter((r): r is string => Boolean(r)))];
424
+ if (!recipients.length) return;
425
+ routeNotification({
426
+ type: "workspace.land_gate_failed",
427
+ scope: { author: owner, parent: coordinator ?? undefined, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
428
+ recipients,
429
+ ttlMs: null,
430
+ message: {
431
+ subject: `Land blocked: gate "${gate.name}" failed`,
432
+ body,
433
+ payload: { ...payload, notification: "land-gate-failed" },
434
+ replyExpected: false,
435
+ },
436
+ });
437
+ }
438
+
439
+ /**
440
+ * #902 — OPTIONAL land gates that failed but did NOT block the land (the land succeeded and
441
+ * `main` advanced). Surface a single low-priority warning to the worker with the gate names
442
+ * + the artifact ref holding the combined output, so the warn signal isn't lost but doesn't
443
+ * bounce a successful land. Informational TTL: a stale warning shouldn't wake a late joiner.
444
+ */
445
+ export function notifyLandGateWarnings(input: {
446
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
447
+ warnings: LandGateRunResult[];
448
+ artifactId?: string;
449
+ }): void {
450
+ const { workspace, warnings } = input;
451
+ if (warnings.length === 0) return;
452
+ const base = workspace.baseRef ?? "base";
453
+ const owner = workspace.ownerAgentId;
454
+ const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
455
+ const names = warnings.map((w) => `\`${w.name}\` (${gateOutcomeLabel(w)})`).join(", ");
456
+ const artifactLabel = input.artifactId ? ` Full output: artifact \`${input.artifactId}\`.` : "";
457
+
458
+ const payload = {
459
+ kind: "workspace.land-gate-warning",
460
+ workspaceId: workspace.id,
461
+ repoRoot: workspace.repoRoot,
462
+ branch: workspace.branch,
463
+ base,
464
+ author: owner,
465
+ gateNames: warnings.map((w) => w.name),
466
+ artifactId: input.artifactId,
467
+ };
468
+
469
+ emitRelayEvent({ type: "workspace.land_gate_warning", source: "server", subject: workspace.id, data: payload });
470
+
471
+ if (!owner) return;
472
+ routeNotification({
473
+ type: "workspace.land_gate_warning",
474
+ scope: { author: owner, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
475
+ recipients: [owner],
476
+ ttlMs: INFORMATIONAL_TTL_MS,
477
+ message: {
478
+ subject: `Land gate warning${warnings.length > 1 ? "s" : ""} (landed anyway)`,
479
+ body: `⚠️ ${branchLabel} landed to \`${base}\`, but optional land gate${warnings.length > 1 ? "s" : ""} failed: ${names}. Not blocking — review when convenient.${artifactLabel}`,
480
+ payload: { ...payload, notification: "land-gate-warning" },
481
+ replyExpected: false,
482
+ },
483
+ });
484
+ }
485
+
360
486
  /**
361
487
  * Steward-handoff awareness wake (#641/#642). When the relay can't auto-merge a branch and
362
488
  * hands the workspace to a steward, the two ends used to be mutually blind: the branch agent
@@ -68,5 +68,9 @@ export function runDbMaintenance(options: { vacuum?: boolean } = {}): {
68
68
  }
69
69
 
70
70
  // Shared error types thrown across query domains and unwrapped at call sites.
71
- export class ValidationError extends Error {}
71
+ // `ValidationError` now lives in the SDK (agent-relay-sdk) as the single canonical
72
+ // home shared with the orchestrator package; re-exported here so the ~120 server
73
+ // call sites (and #892's `toThrow(ValidationError)` tests) stay byte-unchanged and
74
+ // keep the same class identity.
75
+ export { ValidationError } from "agent-relay-sdk";
72
76
  export class ClaimError extends Error {}
package/src/land-gates.ts CHANGED
@@ -1,80 +1,8 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
2
- import { readFileSync } from "node:fs";
3
- import { isRecord } from "agent-relay-sdk";
4
- import type { LandGate } from "agent-relay-sdk";
5
- import { ValidationError } from "./db/connection.ts";
6
- import { cleanString } from "./validation.ts";
7
- import { parseJson, isPathWithinBase } from "./utils.ts";
8
-
9
- const LAND_GATES_FILE = ".agent-relay/land-gates.json";
10
-
11
- export function cleanLandGate(value: unknown, index: number, worktreePath?: string): LandGate {
12
- if (!isRecord(value)) throw new ValidationError(`gates[${index}] must be an object`);
13
- const name = cleanString(value.name, `gates[${index}].name`, { required: true, max: 120 });
14
- if (!name) throw new ValidationError(`gates[${index}].name required`);
15
- const command = cleanString(value.command, `gates[${index}].command`, { required: true, max: 4096 });
16
- if (!command) throw new ValidationError(`gates[${index}].command required`);
17
-
18
- let cwd: string | undefined;
19
- if (value.cwd !== undefined && value.cwd !== null) {
20
- const rawCwd = cleanString(value.cwd, `gates[${index}].cwd`, { max: 1000 });
21
- if (rawCwd) {
22
- if (worktreePath) {
23
- const resolved = resolve(worktreePath, rawCwd);
24
- if (!isPathWithinBase(resolved, worktreePath)) {
25
- throw new ValidationError(`gates[${index}].cwd must be within the worktree`);
26
- }
27
- cwd = relative(worktreePath, resolved) || ".";
28
- } else {
29
- if (isAbsolute(rawCwd)) throw new ValidationError(`gates[${index}].cwd must be a relative path when no worktreePath is provided`);
30
- cwd = rawCwd;
31
- }
32
- }
33
- }
34
-
35
- if (value.timeoutMs !== undefined && value.timeoutMs !== null) {
36
- if (typeof value.timeoutMs !== "number" || !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs <= 0) {
37
- throw new ValidationError(`gates[${index}].timeoutMs must be a positive integer`);
38
- }
39
- }
40
- if (value.optional !== undefined && value.optional !== null) {
41
- if (typeof value.optional !== "boolean") {
42
- throw new ValidationError(`gates[${index}].optional must be a boolean`);
43
- }
44
- }
45
-
46
- return {
47
- name,
48
- command,
49
- ...(cwd !== undefined ? { cwd } : {}),
50
- ...(typeof value.timeoutMs === "number" ? { timeoutMs: value.timeoutMs as number } : {}),
51
- ...(typeof value.optional === "boolean" ? { optional: value.optional as boolean } : {}),
52
- };
53
- }
54
-
55
- /** Parse + validate a raw `.agent-relay/land-gates.json` object. */
56
- export function cleanLandGatesConfig(value: unknown, worktreePath?: string): LandGate[] {
57
- if (!isRecord(value)) throw new ValidationError("land-gates config must be an object");
58
- const { gates } = value;
59
- if (!Array.isArray(gates)) throw new ValidationError("gates must be an array");
60
- if (gates.length > 50) throw new ValidationError("gates: at most 50 gates allowed");
61
- return gates.map((g, i) => cleanLandGate(g, i, worktreePath));
62
- }
63
-
64
- /**
65
- * Read + validate `.agent-relay/land-gates.json` from a worktree path.
66
- * Missing file → empty list (opt-in, zero regression).
67
- */
68
- export function loadRepoLandGates(worktreePath: string): LandGate[] {
69
- const filePath = resolve(worktreePath, LAND_GATES_FILE);
70
- let raw: string;
71
- try {
72
- raw = readFileSync(filePath, "utf8");
73
- } catch (err: unknown) {
74
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
75
- throw err;
76
- }
77
- const parsed = parseJson<unknown>(raw, null);
78
- if (parsed === null) throw new ValidationError(`${LAND_GATES_FILE}: invalid JSON`);
79
- return cleanLandGatesConfig(parsed, worktreePath);
80
- }
1
+ // The land-gates config loader (#892) now lives in the SDK so the orchestrator
2
+ // package which advances `refs/heads/main` and therefore runs the gates — can
3
+ // import the SAME implementation across the package boundary (it can only depend
4
+ // on agent-relay-sdk, not this server package). Re-exported here so #892's
5
+ // importers and co-located tests stay byte-unchanged. The canonical home is
6
+ // `agent-relay-sdk/land-gates` (a non-barrel subpath: it reads from disk, so it
7
+ // stays out of browser bundles).
8
+ export { cleanLandGate, cleanLandGatesConfig, loadRepoLandGates } from "agent-relay-sdk/land-gates";
@@ -40,6 +40,8 @@ export type NotificationType =
40
40
  | "workspace.steward_reassigned"
41
41
  | "workspace.stranded_escalation"
42
42
  | "workspace.base_checkout_strand"
43
+ | "workspace.land_gate_failed"
44
+ | "workspace.land_gate_warning"
43
45
  | "workspace.auto_abandoned"
44
46
  | "workspace.review_request"
45
47
  | "workspace.review_changes_requested"
@@ -136,6 +138,13 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
136
138
  "workspace.steward_reassigned": null,
137
139
  "workspace.stranded_escalation": null,
138
140
  "workspace.base_checkout_strand": null,
141
+ // #902 — a required land gate blocked a land; the worker is parked idle after `ready` and
142
+ // must learn why its land bounced, so this is durable (about the recipient's own work).
143
+ "workspace.land_gate_failed": null,
144
+ // Optional-gate warning on an already-successful land — only useful while fresh, so it
145
+ // expires like other bystander/informational notices (= INFORMATIONAL_TTL_MS, inlined to
146
+ // avoid referencing that const before its declaration below).
147
+ "workspace.land_gate_warning": 30 * MINUTES,
139
148
  "workspace.auto_abandoned": null,
140
149
  "workspace.review_request": null,
141
150
  "workspace.review_changes_requested": null,
@@ -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
  }