agent-relay-server 0.119.6 → 0.119.7
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-uVOXgvMT.js.map +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/commands-db.ts +72 -20
- package/src/db/workspaces.ts +41 -0
- package/src/routes/workspaces.ts +2 -4
- package/src/services/shutdown-agent.ts +6 -1
- package/src/services/spawn-agent.ts +50 -7
- package/src/services/transient-agent-reaper.ts +33 -19
- package/src/workspace-actions.ts +24 -13
package/src/commands-db.ts
CHANGED
|
@@ -26,19 +26,25 @@ interface CommandFilters {
|
|
|
26
26
|
limit?: number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const
|
|
29
|
+
const CLAIMED_ACTIVE_STATUSES: CommandStatus[] = ["accepted", "running"];
|
|
30
|
+
const SPAWN_IN_FLIGHT_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
|
|
30
31
|
const TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "failed", "canceled", "timed_out"];
|
|
31
32
|
|
|
32
|
-
// #930 —
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// the spawn, not from enqueue. Scoped to `agent.spawn`; every other command type
|
|
39
|
-
// keeps create-time TTL semantics.
|
|
33
|
+
// #930/#953 — command TTLs are execution leases, not delivery deadlines. The #880
|
|
34
|
+
// spawn cap and long inline orchestrator commands both introduce legitimate
|
|
35
|
+
// queuing before dispatch, so pending commands must not be swept before the host
|
|
36
|
+
// can claim them. Refresh expiring command types at dispatch-intent (claim →
|
|
37
|
+
// accepted, or slot-acquire → running) so the window measures from when the host
|
|
38
|
+
// committed to doing the work, not from enqueue.
|
|
40
39
|
const SPAWN_TTL_MS = 180_000;
|
|
41
|
-
const
|
|
40
|
+
const LIFECYCLE_TTL_MS = 30_000;
|
|
41
|
+
const DISPATCH_STATUSES: CommandStatus[] = ["accepted", "running"];
|
|
42
|
+
|
|
43
|
+
function commandTtlMs(type: string): number | undefined {
|
|
44
|
+
if (type === "agent.spawn") return SPAWN_TTL_MS;
|
|
45
|
+
if (type === "agent.shutdown" || type === "agent.restart" || type === "agent.reconnect" || type === "agent.kill" || type === "agent.compact" || type === "agent.clearContext" || type === "agent.injectContext") return LIFECYCLE_TTL_MS;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
42
48
|
|
|
43
49
|
function isTerminalStatus(status: CommandStatus): boolean {
|
|
44
50
|
return TERMINAL_STATUSES.includes(status);
|
|
@@ -123,6 +129,51 @@ export function listCommands(filters: CommandFilters = {}): Command[] {
|
|
|
123
129
|
return rows.map(rowToCommand);
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
export function findSpawnCommandByRequestId(spawnRequestId: string, statuses?: CommandStatus[]): Command | null {
|
|
133
|
+
const cleaned = spawnRequestId.trim();
|
|
134
|
+
if (!cleaned) return null;
|
|
135
|
+
const statusClause = statuses?.length
|
|
136
|
+
? `AND status IN (${statuses.map(() => "?").join(",")})`
|
|
137
|
+
: "";
|
|
138
|
+
const rows = getDb()
|
|
139
|
+
.query(`
|
|
140
|
+
SELECT * FROM commands
|
|
141
|
+
WHERE type = 'agent.spawn'
|
|
142
|
+
${statusClause}
|
|
143
|
+
AND (correlation_id = ? OR json_extract(params, '$.spawnRequestId') = ?)
|
|
144
|
+
ORDER BY created_at DESC
|
|
145
|
+
LIMIT 1
|
|
146
|
+
`)
|
|
147
|
+
.all(...(statuses ?? []), cleaned, cleaned) as CommandRow[];
|
|
148
|
+
return rows[0] ? rowToCommand(rows[0]) : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function countInFlightSpawnCommands(parentId: string): number {
|
|
152
|
+
const row = getDb()
|
|
153
|
+
.query(
|
|
154
|
+
`SELECT count(*) AS n
|
|
155
|
+
FROM commands
|
|
156
|
+
WHERE type = 'agent.spawn'
|
|
157
|
+
AND status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
|
|
158
|
+
AND json_extract(params, '$.spawnedBy') = ?`,
|
|
159
|
+
)
|
|
160
|
+
.get(...SPAWN_IN_FLIGHT_STATUSES, parentId) as { n: number };
|
|
161
|
+
return row.n;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createSpawnCommandWithinQuota(
|
|
165
|
+
input: CreateCommandInput,
|
|
166
|
+
quota: { parentId: string; maxSpawnedAgents: number; liveChildren: () => number },
|
|
167
|
+
): { command: Command | null; occupiedSlots: number } {
|
|
168
|
+
return getDb().transaction(() => {
|
|
169
|
+
const live = quota.liveChildren();
|
|
170
|
+
const inFlight = countInFlightSpawnCommands(quota.parentId);
|
|
171
|
+
const occupied = live + inFlight;
|
|
172
|
+
if (occupied >= quota.maxSpawnedAgents) return { command: null, occupiedSlots: occupied };
|
|
173
|
+
return { command: createCommand(input), occupiedSlots: occupied + 1 };
|
|
174
|
+
})();
|
|
175
|
+
}
|
|
176
|
+
|
|
126
177
|
export function updateCommand(id: string, input: UpdateCommandInput): Command | null {
|
|
127
178
|
const existing = getCommand(id);
|
|
128
179
|
if (!existing) return null;
|
|
@@ -145,12 +196,14 @@ export function updateCommand(id: string, input: UpdateCommandInput): Command |
|
|
|
145
196
|
return existing;
|
|
146
197
|
}
|
|
147
198
|
const now = Date.now();
|
|
148
|
-
// #930 — refresh the
|
|
149
|
-
//
|
|
150
|
-
//
|
|
199
|
+
// #930/#953 — refresh the lease from dispatch-intent when an expiring command is
|
|
200
|
+
// claimed or starts running. Pending commands are not swept before delivery, so a
|
|
201
|
+
// busy orchestrator must not inherit an already-expired lease when it finally gets
|
|
202
|
+
// back to the command loop.
|
|
203
|
+
const ttlMs = input.status && DISPATCH_STATUSES.includes(input.status) ? commandTtlMs(existing.type) : undefined;
|
|
151
204
|
const nextExpiresAt =
|
|
152
|
-
|
|
153
|
-
? now +
|
|
205
|
+
ttlMs !== undefined
|
|
206
|
+
? now + ttlMs
|
|
154
207
|
: existing.expiresAt ?? null;
|
|
155
208
|
getDb().query(`
|
|
156
209
|
UPDATE commands
|
|
@@ -179,16 +232,15 @@ export function deleteCommand(id: string): boolean {
|
|
|
179
232
|
|
|
180
233
|
export function expireCommands(now: number = Date.now()): Command[] {
|
|
181
234
|
const rows = getDb()
|
|
182
|
-
.query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${
|
|
183
|
-
.all(now, ...
|
|
235
|
+
.query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${CLAIMED_ACTIVE_STATUSES.map(() => "?").join(",")})`)
|
|
236
|
+
.all(now, ...CLAIMED_ACTIVE_STATUSES) as CommandRow[];
|
|
184
237
|
for (const row of rows) updateCommand(row.id, { status: "timed_out", error: "command timed out" });
|
|
185
238
|
return rows.map((row) => getCommand(row.id)).filter((command): command is Command => Boolean(command));
|
|
186
239
|
}
|
|
187
240
|
|
|
188
241
|
function defaultExpiresAt(type: string, now: number): number | undefined {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return undefined;
|
|
242
|
+
const ttlMs = commandTtlMs(type);
|
|
243
|
+
return ttlMs === undefined ? undefined : now + ttlMs;
|
|
192
244
|
}
|
|
193
245
|
|
|
194
246
|
function recordShutdownTerminalEventOnSuccess(previousStatus: CommandStatus, command: Command): void {
|
package/src/db/workspaces.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
|
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { getDb } from "./connection.ts";
|
|
18
18
|
import { resolveProjectsForCwds } from "./projects.ts";
|
|
19
|
+
import { workspaceActiveClaim } from "../workspace-claim";
|
|
19
20
|
import type {
|
|
20
21
|
AgentCard,
|
|
21
22
|
ActivityEvent,
|
|
@@ -511,6 +512,46 @@ export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown
|
|
|
511
512
|
return getWorkspace(id);
|
|
512
513
|
}
|
|
513
514
|
|
|
515
|
+
type WorkspaceClaimResult =
|
|
516
|
+
| { ok: true; workspace: WorkspaceRecord }
|
|
517
|
+
| { ok: false; reason: "not-found" | "claimed-by-other"; workspace?: WorkspaceRecord };
|
|
518
|
+
|
|
519
|
+
export function claimWorkspaceMetadata(
|
|
520
|
+
id: string,
|
|
521
|
+
claimant: string,
|
|
522
|
+
patch: Record<string, unknown>,
|
|
523
|
+
now: number = Date.now(),
|
|
524
|
+
): WorkspaceClaimResult {
|
|
525
|
+
const run = getDb().transaction((): WorkspaceClaimResult => {
|
|
526
|
+
const existing = getWorkspace(id);
|
|
527
|
+
if (!existing) return { ok: false, reason: "not-found" as const };
|
|
528
|
+
const active = workspaceActiveClaim(existing, now);
|
|
529
|
+
if (active?.by && active.by !== claimant) return { ok: false, reason: "claimed-by-other" as const, workspace: existing };
|
|
530
|
+
const next = { ...existing.metadata, ...patch };
|
|
531
|
+
getDb().query("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
|
|
532
|
+
return { ok: true as const, workspace: getWorkspace(id)! };
|
|
533
|
+
});
|
|
534
|
+
return run();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function releaseWorkspaceClaimMetadata(
|
|
538
|
+
id: string,
|
|
539
|
+
claimant: string,
|
|
540
|
+
patch: Record<string, unknown>,
|
|
541
|
+
now: number = Date.now(),
|
|
542
|
+
): WorkspaceClaimResult {
|
|
543
|
+
const run = getDb().transaction((): WorkspaceClaimResult => {
|
|
544
|
+
const existing = getWorkspace(id);
|
|
545
|
+
if (!existing) return { ok: false, reason: "not-found" as const };
|
|
546
|
+
const active = workspaceActiveClaim(existing, now);
|
|
547
|
+
if (active?.by && active.by !== claimant) return { ok: false, reason: "claimed-by-other" as const, workspace: existing };
|
|
548
|
+
const next = { ...existing.metadata, ...patch };
|
|
549
|
+
getDb().query("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
|
|
550
|
+
return { ok: true as const, workspace: getWorkspace(id)! };
|
|
551
|
+
});
|
|
552
|
+
return run();
|
|
553
|
+
}
|
|
554
|
+
|
|
514
555
|
// Re-elect stewards for every repo where an agent owns a live workspace. Called
|
|
515
556
|
// when an agent (re)registers so a dormant repo regains a steward on rejoin
|
|
516
557
|
// without a full unscoped sweep.
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -380,8 +380,7 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
380
380
|
// a destructive-action footgun. Now an explicit id narrows the candidate set.
|
|
381
381
|
const workspaceId = cleanString(body.workspaceId, "workspaceId", { max: 160 });
|
|
382
382
|
const dryRun = body.dryRun !== false; // safe by default
|
|
383
|
-
const landedOnly =
|
|
384
|
-
const offlineOwnerOnly = body.offlineOwnerOnly !== false;
|
|
383
|
+
const landedOnly = true;
|
|
385
384
|
|
|
386
385
|
const candidates = listWorkspaces().filter((ws) =>
|
|
387
386
|
ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status)
|
|
@@ -395,7 +394,6 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
395
394
|
const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
|
|
396
395
|
const ownerOnline = isOwnerAlive(ws.ownerAgentId);
|
|
397
396
|
if (ownerOnline) continue; // never clean a live owner's worktree
|
|
398
|
-
if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
|
|
399
397
|
if (workspaceActiveClaim(ws)) continue; // respect steward claims
|
|
400
398
|
const fetched = await fetchWorkspaceGitState(ws);
|
|
401
399
|
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
@@ -420,7 +418,7 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
420
418
|
}
|
|
421
419
|
rows.push(row);
|
|
422
420
|
}
|
|
423
|
-
return json({ dryRun, landedOnly,
|
|
421
|
+
return json({ dryRun, landedOnly, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
|
|
424
422
|
};
|
|
425
423
|
|
|
426
424
|
export const postWorkspaceAction: Handler = async (req, params) => {
|
|
@@ -79,6 +79,11 @@ function str(v: unknown): string | undefined {
|
|
|
79
79
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export function callerOwnsSpawnedAgent(ctx: AuthContext, target: AgentCard | null | undefined): boolean {
|
|
83
|
+
const callerId = ctx.callerAgentId;
|
|
84
|
+
return Boolean(callerId && target?.spawnedBy === callerId);
|
|
85
|
+
}
|
|
86
|
+
|
|
82
87
|
/** Build a ShutdownAgentInput from a WS-bus `agent.shutdown` command frame's target +
|
|
83
88
|
* params. Lives here (not in bus.ts) so the bus transport stays a thin dispatcher and the
|
|
84
89
|
* shutdown-input shape has one home. */
|
|
@@ -123,7 +128,7 @@ export function authorizeShutdown(ctx: AuthContext, input: ShutdownAgentInput):
|
|
|
123
128
|
const target = input.agentId
|
|
124
129
|
? getAgent(input.agentId)
|
|
125
130
|
: listAgents().find((a) => a.meta?.spawnRequestId === input.spawnRequestId);
|
|
126
|
-
if (!target || target
|
|
131
|
+
if (!target || !callerOwnsSpawnedAgent(ctx, target)) {
|
|
127
132
|
throw new ShutdownAuthError(
|
|
128
133
|
input.agentId
|
|
129
134
|
? `agent ${input.agentId} is not one of your spawned children`
|
|
@@ -28,7 +28,7 @@ import { DEFAULT_USER_ID, errMessage, isRecord, stringValue } from "agent-relay-
|
|
|
28
28
|
import { statSync } from "node:fs";
|
|
29
29
|
import { dirname, resolve } from "node:path";
|
|
30
30
|
import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, getTaskDetail, listAgents, listProjects, listWorkspaces, recordTaskSpawnRequested, resolveAgentOwnership, resolveProjectForCwd, resolveScopedAnchor, rootsForProject, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
|
|
31
|
-
import { createCommand } from "../commands-db";
|
|
31
|
+
import { countInFlightSpawnCommands, createCommand, createSpawnCommandWithinQuota, findSpawnCommandByRequestId } from "../commands-db";
|
|
32
32
|
import { emitCommandEvent } from "../command-events";
|
|
33
33
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
|
|
34
34
|
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
@@ -48,12 +48,14 @@ import { hasComponentScope, isComponentAuthorizedFor } from "../security";
|
|
|
48
48
|
import { isPathWithinBase } from "../utils";
|
|
49
49
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
50
50
|
import type { AuthContext } from "./auth-context";
|
|
51
|
-
import
|
|
51
|
+
import { callerOwnsSpawnedAgent } from "./shutdown-agent";
|
|
52
|
+
import type { AgentCard, AgentLifecycle, Command, CommandStatus, CreateCommandInput, SpawnApprovalMode, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode } from "../types";
|
|
52
53
|
import { isOwnerAlive, liveAgentCwds, worktreeInUseByLiveAgent } from "../workspace-merge";
|
|
53
54
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
54
55
|
|
|
55
56
|
/** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
|
|
56
57
|
const SPAWN_SCOPE = "command:spawn";
|
|
58
|
+
const ACTIVE_SPAWN_COMMAND_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
|
|
57
59
|
|
|
58
60
|
function hasGitMarker(dir: string): boolean {
|
|
59
61
|
try {
|
|
@@ -362,11 +364,14 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
362
364
|
throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
|
|
363
365
|
}
|
|
364
366
|
const quota = ctx.constraints?.maxSpawnedAgents ?? 0;
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
367
|
-
throw new ValidationError(`spawn quota reached (${
|
|
367
|
+
const occupied = countLiveSpawnedAgents(callerId) + countInFlightSpawnCommands(callerId);
|
|
368
|
+
if (occupied >= quota) {
|
|
369
|
+
throw new ValidationError(`spawn quota reached (${occupied}/${quota} live or in-flight children) — shut one down or wait for one to exit`);
|
|
368
370
|
}
|
|
369
371
|
}
|
|
372
|
+
const spawnQuota = callerId
|
|
373
|
+
? { parentId: callerId, maxSpawnedAgents: ctx.constraints?.maxSpawnedAgents ?? 0 }
|
|
374
|
+
: undefined;
|
|
370
375
|
// Gate the child spawn on the token's legit bounds: the target orchestrator AND the spawn cwd
|
|
371
376
|
// (`cwdPrefixes`/`cwd` constraint). Excludes the #323 footguns (spawnRequestIds/policies) — those
|
|
372
377
|
// are self-scoping per-spawn ids the CHILD gets fresh, so gating on them makes the quota
|
|
@@ -379,6 +384,31 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
379
384
|
// the raw token actor. Recorded in the command payload, the token mint, and the audit row.
|
|
380
385
|
const requestedBy = callerId ?? taskCreatorAgentId ?? input.requestedBy ?? ctx.actor.id;
|
|
381
386
|
|
|
387
|
+
const requestedSpawnRequestId = input.spawnRequestId?.trim();
|
|
388
|
+
if (requestedSpawnRequestId) {
|
|
389
|
+
const registeredAgent = listAgents().find((agent) => agent.meta?.spawnRequestId === requestedSpawnRequestId);
|
|
390
|
+
const activeCommand = findSpawnCommandByRequestId(requestedSpawnRequestId, ACTIVE_SPAWN_COMMAND_STATUSES);
|
|
391
|
+
const existingCommand = activeCommand ?? (registeredAgent ? findSpawnCommandByRequestId(requestedSpawnRequestId) : null);
|
|
392
|
+
if (existingCommand || registeredAgent) {
|
|
393
|
+
const existingParent = existingCommand ? stringValue(existingCommand.params.spawnedBy) : undefined;
|
|
394
|
+
if (callerId && existingParent !== callerId && !callerOwnsSpawnedAgent(ctx, registeredAgent)) {
|
|
395
|
+
throw new ValidationError(`spawnRequestId ${requestedSpawnRequestId} is already in use`);
|
|
396
|
+
}
|
|
397
|
+
if (!existingCommand) {
|
|
398
|
+
throw new ValidationError(`spawnRequestId ${requestedSpawnRequestId} is already registered to agent ${registeredAgent!.id}`);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
spawnRequestId: requestedSpawnRequestId,
|
|
403
|
+
orchestratorId: stringValue(existingCommand.params.orchestratorId) ?? orchestrator.id,
|
|
404
|
+
provider: (stringValue(existingCommand.params.provider) as SpawnProvider | undefined) ?? input.provider,
|
|
405
|
+
agentId: registeredAgent?.id ?? null,
|
|
406
|
+
registered: Boolean(registeredAgent),
|
|
407
|
+
command: existingCommand,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
382
412
|
// #405 — durable project binding: the project whose registered root contains the spawn cwd.
|
|
383
413
|
// Stamped on both the child token and an auto-scaffolded Task row so issueless spawned work
|
|
384
414
|
// stays filterable by project.
|
|
@@ -579,7 +609,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
579
609
|
// command payload.
|
|
580
610
|
const expectReply = input.expectReply === true;
|
|
581
611
|
|
|
582
|
-
const
|
|
612
|
+
const commandInput: CreateCommandInput = {
|
|
583
613
|
type: "agent.spawn",
|
|
584
614
|
source: "system",
|
|
585
615
|
target: orchestrator.agentId,
|
|
@@ -619,7 +649,20 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
619
649
|
// so there's no agent row to resolve `spawnedBy` from) can be routed back to it.
|
|
620
650
|
...(lineageAgentId ? { extra: { spawnedBy: lineageAgentId } } : {}),
|
|
621
651
|
}),
|
|
622
|
-
}
|
|
652
|
+
};
|
|
653
|
+
let command: Command;
|
|
654
|
+
if (spawnQuota) {
|
|
655
|
+
const quotaResult = createSpawnCommandWithinQuota(commandInput, {
|
|
656
|
+
...spawnQuota,
|
|
657
|
+
liveChildren: () => countLiveSpawnedAgents(spawnQuota.parentId),
|
|
658
|
+
});
|
|
659
|
+
if (!quotaResult.command) {
|
|
660
|
+
throw new ValidationError(`spawn quota reached (${quotaResult.occupiedSlots}/${spawnQuota.maxSpawnedAgents} live or in-flight children) — shut one down or wait for one to exit`);
|
|
661
|
+
}
|
|
662
|
+
command = quotaResult.command;
|
|
663
|
+
} else {
|
|
664
|
+
command = createCommand(commandInput);
|
|
665
|
+
}
|
|
623
666
|
emitCommandEvent(command, "command.requested");
|
|
624
667
|
|
|
625
668
|
// ONE spawn-request audit row, identical across transports (the MCP path previously emitted
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
1
2
|
import { createCommand } from "../commands-db";
|
|
2
3
|
import { emitRelayEvent } from "../events";
|
|
3
|
-
import { createActivityEvent, getAgent, listAgents, listWorkspaces, recordTerminalEvent } from "../db";
|
|
4
|
+
import { createActivityEvent, getAgent, listAgents, listOrchestrators, listWorkspaces, recordTerminalEvent } from "../db";
|
|
4
5
|
import { routeNotification } from "../notification-router";
|
|
5
6
|
import { isProviderBlocked } from "../provider-state";
|
|
7
|
+
import { relayToken } from "../config";
|
|
8
|
+
import { isPathWithinBase } from "../utils";
|
|
6
9
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
7
10
|
import { finalizeEphemeralTasksOnReap } from "./task-entity";
|
|
8
|
-
import type { AgentCard, Command, WorkspaceRecord } from "../types";
|
|
11
|
+
import type { AgentCard, Command, WorkspaceGitState, WorkspaceRecord } from "../types";
|
|
9
12
|
|
|
10
13
|
// The single home for "dispose a transient/one-shot agent" — the canonical `agent.shutdown`
|
|
11
14
|
// command + its events/terminal-marker/activity. Extracted so the idle-grace scan below AND
|
|
@@ -65,19 +68,30 @@ export function resetTransientTrackerForTests(): void {
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
// How many commits the agent's branch has ahead of its base. Returns null when
|
|
68
|
-
// the check can't run (missing fields,
|
|
71
|
+
// the check can't run (missing fields, host/probe failure) — callers treat null as
|
|
69
72
|
// "unknown" and preserve the safe #614 hold-pending bias: only 0 is fast-reaped.
|
|
70
|
-
function defaultCommitsAhead(ws: WorkspaceRecord): number | null {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
async function defaultCommitsAhead(ws: WorkspaceRecord): Promise<number | null> {
|
|
74
|
+
if (!ws.branch || !ws.worktreePath) return null;
|
|
75
|
+
const orch = listOrchestrators().find(
|
|
76
|
+
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(ws.sourceCwd, candidate.baseDir),
|
|
77
|
+
);
|
|
78
|
+
if (!orch?.apiUrl) return null;
|
|
79
|
+
const query = new URLSearchParams({ path: ws.worktreePath });
|
|
80
|
+
if (ws.baseRef) query.set("baseRef", ws.baseRef);
|
|
81
|
+
if (ws.baseSha) query.set("baseSha", ws.baseSha);
|
|
82
|
+
const headers: Record<string, string> = {};
|
|
83
|
+
const token = relayToken();
|
|
84
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
73
85
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
78
|
-
if (
|
|
79
|
-
const
|
|
80
|
-
|
|
86
|
+
const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, {
|
|
87
|
+
headers,
|
|
88
|
+
signal: AbortSignal.timeout(8_000),
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) return null;
|
|
91
|
+
const state = await res.json() as WorkspaceGitState;
|
|
92
|
+
if (state.missing || state.error || (state.dirtyCount ?? 0) > 0) return null;
|
|
93
|
+
const n = state.landed === true ? 0 : (state.unmergedAhead ?? state.ahead);
|
|
94
|
+
return typeof n === "number" && Number.isFinite(n) ? n : null;
|
|
81
95
|
} catch {
|
|
82
96
|
return null;
|
|
83
97
|
}
|
|
@@ -96,9 +110,9 @@ function reapProtected(agent: ReturnType<typeof listAgents>[number]): boolean {
|
|
|
96
110
|
return agent.status !== "idle" || !agent.ready || isProviderBlocked(agent);
|
|
97
111
|
}
|
|
98
112
|
|
|
99
|
-
let _commitsAheadFn: (ws: WorkspaceRecord) => number | null = defaultCommitsAhead;
|
|
113
|
+
let _commitsAheadFn: (ws: WorkspaceRecord) => number | null | Promise<number | null> = defaultCommitsAhead;
|
|
100
114
|
|
|
101
|
-
export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null): void {
|
|
115
|
+
export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null | Promise<number | null>): void {
|
|
102
116
|
_commitsAheadFn = fn;
|
|
103
117
|
}
|
|
104
118
|
export function resetCommitsAheadForTests(): void {
|
|
@@ -116,7 +130,7 @@ function workspaceProducedWork(ws: WorkspaceRecord | undefined): boolean {
|
|
|
116
130
|
return typeof ws.metadata.mergedAt === "number";
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
function transientLandComplete(agent: AgentCard): { complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string } {
|
|
133
|
+
async function transientLandComplete(agent: AgentCard): Promise<{ complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string }> {
|
|
120
134
|
const agentId = agent.id;
|
|
121
135
|
const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
|
|
122
136
|
if (!isolated.length) return { complete: true, producedWork: completedProviderTurns(agent) > 0, reason: "no-isolated-workspace" };
|
|
@@ -134,7 +148,7 @@ function transientLandComplete(agent: AgentCard): { complete: boolean; producedW
|
|
|
134
148
|
// instead of holding it pending a land that will never happen. Only fires when the
|
|
135
149
|
// branch is provably empty (count === 0); null (unknown/git error) falls through to
|
|
136
150
|
// the safe #614 hold-pending bias below.
|
|
137
|
-
if (live.status === "active" && _commitsAheadFn(live) === 0) {
|
|
151
|
+
if (live.status === "active" && await _commitsAheadFn(live) === 0) {
|
|
138
152
|
return { complete: true, producedWork: false, workspaceId: live.id, status: live.status, reason: "empty-branch" };
|
|
139
153
|
}
|
|
140
154
|
return { complete: false, producedWork: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
|
|
@@ -171,7 +185,7 @@ function notifyTransientHeld(agentId: string, workspaceId: string | undefined, s
|
|
|
171
185
|
}
|
|
172
186
|
}
|
|
173
187
|
|
|
174
|
-
export function reapTransientAgents(): Record<string, unknown
|
|
188
|
+
export async function reapTransientAgents(): Promise<Record<string, unknown>> {
|
|
175
189
|
const now = Date.now();
|
|
176
190
|
const grace = transientGraceMs();
|
|
177
191
|
const maxLandWait = transientMaxLandWaitMs();
|
|
@@ -193,7 +207,7 @@ export function reapTransientAgents(): Record<string, unknown> {
|
|
|
193
207
|
transientTracker.set(agent.id, entry);
|
|
194
208
|
const idleForMs = now - entry.firstIdleAt;
|
|
195
209
|
if (idleForMs < grace) { waiting.push(agent.id); continue; }
|
|
196
|
-
const land = transientLandComplete(agent);
|
|
210
|
+
const land = await transientLandComplete(agent);
|
|
197
211
|
if (!land.complete) {
|
|
198
212
|
if (idleForMs >= grace + maxLandWait) {
|
|
199
213
|
held.push(agent.id);
|
package/src/workspace-actions.ts
CHANGED
|
@@ -14,8 +14,10 @@ import { isPathWithinBase } from "./utils";
|
|
|
14
14
|
import {
|
|
15
15
|
createActivityEvent,
|
|
16
16
|
getWorkspace,
|
|
17
|
+
claimWorkspaceMetadata,
|
|
17
18
|
listOrchestrators,
|
|
18
19
|
patchWorkspaceMetadata,
|
|
20
|
+
releaseWorkspaceClaimMetadata,
|
|
19
21
|
updateWorkspaceStatus,
|
|
20
22
|
} from "./db";
|
|
21
23
|
import { emitActivityEvent } from "./sse";
|
|
@@ -148,8 +150,17 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
148
150
|
// so deterministic landing can't race a steward mid-validation. No status change.
|
|
149
151
|
if (action === "claim" || action === "release-claim") {
|
|
150
152
|
const release = action === "release-claim";
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
+
const claimant = agentId ?? "steward";
|
|
154
|
+
const result = release
|
|
155
|
+
? releaseWorkspaceClaimMetadata(workspace.id, claimant, claimMetadataPatch(true), Date.now())
|
|
156
|
+
: claimWorkspaceMetadata(workspace.id, claimant, claimMetadataPatch(false, claimant, input.purpose), Date.now());
|
|
157
|
+
if (!result.ok) {
|
|
158
|
+
if (result.reason === "not-found") return { ok: false, httpStatus: 404, error: "workspace not found" };
|
|
159
|
+
const active = result.workspace ? workspaceActiveClaim(result.workspace) : null;
|
|
160
|
+
const owner = active?.by ? ` by ${active.by}` : "";
|
|
161
|
+
return { ok: false, httpStatus: 409, error: `workspace ${workspace.id} is already claimed${owner}` };
|
|
162
|
+
}
|
|
163
|
+
const updated = result.workspace;
|
|
153
164
|
recordWorkspaceAudit({
|
|
154
165
|
action,
|
|
155
166
|
workspace: updated,
|
|
@@ -231,6 +242,14 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
231
242
|
};
|
|
232
243
|
}
|
|
233
244
|
|
|
245
|
+
let command: Command | undefined;
|
|
246
|
+
if (requiresCommand) {
|
|
247
|
+
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
248
|
+
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true });
|
|
249
|
+
if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
|
|
250
|
+
command = built.command;
|
|
251
|
+
}
|
|
252
|
+
|
|
234
253
|
const updated = updateWorkspaceStatus(workspace.id, nextStatus, {
|
|
235
254
|
...metadata,
|
|
236
255
|
...(detail ? { detail } : {}),
|
|
@@ -250,13 +269,6 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
250
269
|
// + lease-serialized; a non-landable row is a harmless no-op (autoMergeForRepo re-checks).
|
|
251
270
|
if (READY_TO_LAND_STATUSES.has(nextStatus)) scheduleRepoMerge(updated.repoRoot);
|
|
252
271
|
|
|
253
|
-
let command: Command | undefined;
|
|
254
|
-
if (requiresCommand) {
|
|
255
|
-
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
256
|
-
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true });
|
|
257
|
-
if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
|
|
258
|
-
command = built.command;
|
|
259
|
-
}
|
|
260
272
|
recordWorkspaceAudit({
|
|
261
273
|
action,
|
|
262
274
|
workspace: updated,
|
|
@@ -289,9 +301,8 @@ export function buildWorkspaceCleanupCommand(
|
|
|
289
301
|
error: `workspace ${workspace.id} owner is still online; pass force:true to clean it up intentionally`,
|
|
290
302
|
};
|
|
291
303
|
}
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
|
|
304
|
+
const owner = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
305
|
+
if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path; retry when the host is online or use DELETE /api/workspaces/:id to purge the record" };
|
|
295
306
|
const command = createCommand({
|
|
296
307
|
type: "workspace.cleanup",
|
|
297
308
|
source: "system",
|
|
@@ -308,7 +319,7 @@ export function buildWorkspaceCleanupCommand(
|
|
|
308
319
|
requestedBy,
|
|
309
320
|
requestedAt: Date.now(),
|
|
310
321
|
deleteBranch: true,
|
|
311
|
-
queued:
|
|
322
|
+
queued: false,
|
|
312
323
|
},
|
|
313
324
|
});
|
|
314
325
|
return { ok: true, command };
|