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