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.
- package/docs/openapi.json +1 -1
- package/package.json +3 -3
- package/public/assets/types-DSwhOFcJ.js.map +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/artifact-text.ts +36 -0
- package/src/auto-spawn-dispatch.ts +136 -0
- package/src/branch-landed.ts +127 -1
- package/src/db/connection.ts +5 -1
- package/src/db/migrations.ts +22 -2
- package/src/db/projects.ts +38 -6
- package/src/db/schema.ts +2 -0
- package/src/land-gates.ts +8 -80
- package/src/mcp-project-tools.ts +4 -0
- package/src/notification-types.ts +9 -0
- package/src/routes/agents-spawn.ts +35 -6
- package/src/routes/commands.ts +91 -7
- package/src/services/git-remote.ts +22 -11
- package/src/services/project.ts +7 -2
- package/src/services/spawn-agent.ts +31 -4
- package/src/services/task-context-hydration.ts +21 -7
- package/src/spawn-command.ts +11 -0
- package/src/steward.ts +38 -6
- package/src/task-dispatcher.ts +94 -7
- package/src/utils.ts +7 -38
- package/src/validation.ts +6 -25
|
@@ -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
|
-
|
|
122
|
-
|
|
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:
|
|
134
|
-
effort:
|
|
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,
|
package/src/routes/commands.ts
CHANGED
|
@@ -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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 (!
|
|
28
|
-
if (
|
|
29
|
-
let
|
|
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)
|
|
32
|
+
if (proc.exitCode === 0) url = Buffer.from(proc.stdout).toString("utf8").trim() || undefined;
|
|
37
33
|
} catch {
|
|
38
|
-
|
|
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
|
}
|
package/src/services/project.ts
CHANGED
|
@@ -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
|
|
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).
|
|
393
|
-
//
|
|
394
|
-
//
|
|
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
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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,
|
|
350
|
-
prompt: joinBlocks(steer ? stripSentinels(steer) : undefined
|
|
354
|
+
systemPromptAppend: joinBlocks(context, callerAppend),
|
|
355
|
+
prompt: joinBlocks(steer ? stripSentinels(steer) : undefined),
|
|
351
356
|
};
|
|
352
357
|
}
|
|
353
358
|
return {
|
|
354
|
-
systemPromptAppend: joinBlocks(
|
|
355
|
-
prompt: joinBlocks(context
|
|
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
|
}
|
package/src/spawn-command.ts
CHANGED
|
@@ -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:
|
|
62
|
+
provider: resolved.target.provider,
|
|
49
63
|
workspaceMode: "inherit",
|
|
50
|
-
...(
|
|
51
|
-
...(
|
|
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
|
-
|
|
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
|
/**
|