agent-relay-server 0.39.0 → 0.41.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.
Files changed (70) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-b8kUF4hR.js → activity-DAz3DcDA.js} +2 -2
  4. package/public/assets/{activity-b8kUF4hR.js.map → activity-DAz3DcDA.js.map} +1 -1
  5. package/public/assets/{agents-C_ZSRXRa.js → agents-BJ7qRWxt.js} +2 -2
  6. package/public/assets/{agents-C_ZSRXRa.js.map → agents-BJ7qRWxt.js.map} +1 -1
  7. package/public/assets/{analytics-3JbZ6M41.js → analytics-MNCS2qnT.js} +2 -2
  8. package/public/assets/{analytics-3JbZ6M41.js.map → analytics-MNCS2qnT.js.map} +1 -1
  9. package/public/assets/{automation-CVu2C3sk.js → automation-COp2Kb3h.js} +2 -2
  10. package/public/assets/{automation-CVu2C3sk.js.map → automation-COp2Kb3h.js.map} +1 -1
  11. package/public/assets/chat-D_O8VqMR.js +2 -0
  12. package/public/assets/chat-D_O8VqMR.js.map +1 -0
  13. package/public/assets/display-ConJ9cJB.js.map +1 -1
  14. package/public/assets/{formatted-body-impl-CSAGn8zM.js → formatted-body-impl-BbMHqkCy.js} +2 -2
  15. package/public/assets/{formatted-body-impl-CSAGn8zM.js.map → formatted-body-impl-BbMHqkCy.js.map} +1 -1
  16. package/public/assets/{index-n5wxGWSq.js → index-DgJOEApM.js} +5 -5
  17. package/public/assets/{index-n5wxGWSq.js.map → index-DgJOEApM.js.map} +1 -1
  18. package/public/assets/{index-Bins8N_5.css → index-Dop-uXiy.css} +1 -1
  19. package/public/assets/{maintenance-CCcvsBbm.js → maintenance-BHv90kXZ.js} +2 -2
  20. package/public/assets/{maintenance-CCcvsBbm.js.map → maintenance-BHv90kXZ.js.map} +1 -1
  21. package/public/assets/{managed-agents-DHfO5gVD.js → managed-agents-BAlvkxfo.js} +2 -2
  22. package/public/assets/{managed-agents-DHfO5gVD.js.map → managed-agents-BAlvkxfo.js.map} +1 -1
  23. package/public/assets/{markdown-preview-impl-C1jNXAEL.js → markdown-preview-impl-6k_FWjmd.js} +2 -2
  24. package/public/assets/{markdown-preview-impl-C1jNXAEL.js.map → markdown-preview-impl-6k_FWjmd.js.map} +1 -1
  25. package/public/assets/{memory-VmjJ-YZ8.js → memory-DBjqdVpg.js} +2 -2
  26. package/public/assets/{memory-VmjJ-YZ8.js.map → memory-DBjqdVpg.js.map} +1 -1
  27. package/public/assets/{messages-DXN6Rkjt.js → messages-axoaJY2N.js} +2 -2
  28. package/public/assets/{messages-DXN6Rkjt.js.map → messages-axoaJY2N.js.map} +1 -1
  29. package/public/assets/{orchestrators-CLyYvfn1.js → orchestrators-DMfHZ44H.js} +2 -2
  30. package/public/assets/{orchestrators-CLyYvfn1.js.map → orchestrators-DMfHZ44H.js.map} +1 -1
  31. package/public/assets/{overview-DZmVU2Fh.js → overview-DqxYWpUT.js} +2 -2
  32. package/public/assets/{overview-DZmVU2Fh.js.map → overview-DqxYWpUT.js.map} +1 -1
  33. package/public/assets/{pairs-BeEORfZF.js → pairs-D3wnzC1V.js} +2 -2
  34. package/public/assets/{pairs-BeEORfZF.js.map → pairs-D3wnzC1V.js.map} +1 -1
  35. package/public/assets/{security-DIe-a7rD.js → security-KWrb7PKj.js} +2 -2
  36. package/public/assets/{security-DIe-a7rD.js.map → security-KWrb7PKj.js.map} +1 -1
  37. package/public/assets/{settings-BeFPCFUZ.js → settings-PXMEzNAm.js} +2 -2
  38. package/public/assets/{settings-BeFPCFUZ.js.map → settings-PXMEzNAm.js.map} +1 -1
  39. package/public/assets/{tasks-BVXiBzUI.js → tasks-CFYShBCz.js} +2 -2
  40. package/public/assets/{tasks-BVXiBzUI.js.map → tasks-CFYShBCz.js.map} +1 -1
  41. package/public/assets/{terminal-viewer-impl-CN311GDZ.js → terminal-viewer-impl-BwPYZlWI.js} +2 -2
  42. package/public/assets/{terminal-viewer-impl-CN311GDZ.js.map → terminal-viewer-impl-BwPYZlWI.js.map} +1 -1
  43. package/public/assets/{work-queue-BnQftDAS.js → work-queue-VQ_6QDJc.js} +2 -2
  44. package/public/assets/{work-queue-BnQftDAS.js.map → work-queue-VQ_6QDJc.js.map} +1 -1
  45. package/public/index.html +2 -2
  46. package/runner/src/adapter.ts +12 -3
  47. package/src/bus.ts +2 -0
  48. package/src/config-store.ts +20 -8
  49. package/src/context-advisory.ts +110 -0
  50. package/src/context-threshold-config.ts +40 -0
  51. package/src/db/agents.ts +21 -5
  52. package/src/db/continuation-archives.ts +179 -0
  53. package/src/db/continuations.ts +151 -0
  54. package/src/db/index.ts +2 -0
  55. package/src/db/messages.ts +36 -2
  56. package/src/db/migrations.ts +34 -0
  57. package/src/db/schema.ts +24 -0
  58. package/src/lifecycle-manager.ts +2 -2
  59. package/src/maintenance.ts +11 -17
  60. package/src/mcp.ts +77 -0
  61. package/src/routes/commands.ts +24 -0
  62. package/src/routes/continuation-archives.ts +37 -0
  63. package/src/routes/index.ts +2 -0
  64. package/src/security.ts +2 -0
  65. package/src/self-resume-config.ts +44 -0
  66. package/src/self-resume.ts +232 -0
  67. package/src/services/send-message.ts +4 -0
  68. package/src/workspace-pr-completion.ts +121 -0
  69. package/public/assets/chat-BiCWbMPO.js +0 -2
  70. package/public/assets/chat-BiCWbMPO.js.map +0 -1
package/src/mcp.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  listAgents,
25
25
  listWorkspaces,
26
26
  ownedIsolatedWorkspace,
27
+ searchContinuationArchives,
27
28
  searchAgents,
28
29
  type AgentSearchFilter,
29
30
  type AgentSearchSort,
@@ -35,6 +36,7 @@ import {
35
36
  import { resolveCallerAgentId, type DeliveryReceipt } from "./agent-ref";
36
37
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "./services/shutdown-agent";
37
38
  import { emitRelayEvent } from "./events";
39
+ import { compactAndResume } from "./self-resume";
38
40
  import {
39
41
  getComponentAuth,
40
42
  getIntegrationAuth,
@@ -246,6 +248,52 @@ const TOOLS: ToolDefinition[] = [
246
248
  additionalProperties: false,
247
249
  },
248
250
  },
251
+ {
252
+ name: "relay_compact_and_resume",
253
+ description: "Self-resume this same agent in place: Relay stores the continuation envelope, compacts or clears context, then resumes with the relay-built continuation. Use at a clean seam after a context advisory.",
254
+ requiredScopes: ["command:write"],
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ workingState: { type: "string", description: "Current replaceable working state for the next generation." },
259
+ ruledOut: {
260
+ type: "array",
261
+ items: {
262
+ oneOf: [
263
+ { type: "string" },
264
+ {
265
+ type: "object",
266
+ properties: {
267
+ ruledOut: { type: "string" },
268
+ why: { type: "string" },
269
+ },
270
+ required: ["ruledOut"],
271
+ additionalProperties: false,
272
+ },
273
+ ],
274
+ },
275
+ },
276
+ mode: { type: "string", enum: ["clear-inject", "native"] },
277
+ objective: { type: "string", description: "Seed-only objective. Used only on the first call when no spawn prompt seeded the envelope." },
278
+ },
279
+ required: ["workingState"],
280
+ additionalProperties: false,
281
+ },
282
+ },
283
+ {
284
+ name: "relay_recall",
285
+ description: "Recall this same agent's archived pre-compaction transcript segments by keyword/BM25 search. Self-only: caller identity comes from the MCP token; no agentId parameter is accepted.",
286
+ requiredScopes: ["memory:read"],
287
+ inputSchema: {
288
+ type: "object",
289
+ properties: {
290
+ query: { type: "string", description: "Keyword query over archived compact/clear segments." },
291
+ limit: { type: "integer", minimum: 1, maximum: 20 },
292
+ },
293
+ required: ["query"],
294
+ additionalProperties: false,
295
+ },
296
+ },
249
297
  {
250
298
  name: "relay_spawn_agent",
251
299
  description: "Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via `prompt` in the same call. Defaults to your own host (override with orchestratorId) and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
@@ -506,6 +554,8 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
506
554
  else if (name === "relay_agent_status") result = relayAgentStatus(args);
507
555
  else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
508
556
  else if (name === "relay_whoami") result = relayWhoami(auth);
557
+ else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
558
+ else if (name === "relay_recall") result = relayRecall(auth, args);
509
559
  else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
510
560
  else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
511
561
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
@@ -557,6 +607,33 @@ function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
557
607
  };
558
608
  }
559
609
 
610
+ function relayCompactAndResume(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
611
+ const agentId = callerAgentId(auth);
612
+ if (!agentId) throw new McpAuthError("relay_compact_and_resume requires a single caller agent identity");
613
+ return compactAndResume({
614
+ agentId,
615
+ workingState: stringField(args.workingState, "workingState", { required: true, maxBytes: MAX_BODY_BYTES }),
616
+ ruledOut: args.ruledOut,
617
+ mode: optionalEnum(args.mode, "mode", ["clear-inject", "native"] as const),
618
+ objective: optionalString(args.objective, "objective", 64_000),
619
+ }) as unknown as Record<string, unknown>;
620
+ }
621
+
622
+ function relayRecall(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
623
+ const agentId = callerAgentId(auth);
624
+ if (!agentId) throw new McpAuthError("relay_recall requires a single caller agent identity");
625
+ const matches = searchContinuationArchives({
626
+ agentId,
627
+ query: stringField(args.query, "query", { required: true, max: 1000 }),
628
+ limit: optionalPositiveInt(args.limit, "limit"),
629
+ });
630
+ return {
631
+ query: stringField(args.query, "query", { required: true, max: 1000 }),
632
+ matches,
633
+ count: matches.length,
634
+ };
635
+ }
636
+
560
637
  // MCP transport adapter over the shared send service (epic #342). The service owns from-resolution
561
638
  // (token identity via ctx.callerAgentId), reply routing, target resolution + the converged
562
639
  // store-ahead policy, authorization, persistence, and emit. This adapter only builds the input,
@@ -15,6 +15,7 @@ import { ServiceAuthError } from "../services/errors";
15
15
  import { notifyBranchLanded } from "../branch-landed";
16
16
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
17
17
  import { handleReviewerMergeResult } from "../reviewer-pipeline";
18
+ import { enqueueContinuationInjectionAfterClear } from "../self-resume";
18
19
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
19
20
 
20
21
  const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
@@ -173,6 +174,7 @@ export const patchCommand: Handler = async (req, params) => {
173
174
  // hook so a command that landed in the wrong context gets flagged.
174
175
  getCompactionWatch().noteCommandSucceeded(command.type, command.id, command.target);
175
176
  }
177
+ enqueueContinuationInjectionAfterClear(command);
176
178
  if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
177
179
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
178
180
  if (workspaceId) {
@@ -285,6 +287,28 @@ export const patchCommand: Handler = async (req, params) => {
285
287
  }
286
288
  }
287
289
  }
290
+ if (command.type === "workspace.pr-merge") {
291
+ const resultWorkspaceId = isRecord(command.result) ? cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 }) : undefined;
292
+ const workspaceId = resultWorkspaceId ?? command.correlationId;
293
+ if (workspaceId && command.status === "succeeded" && isRecord(command.result) && command.result.relayMerged === true) {
294
+ patchWorkspaceMetadata(workspaceId, {
295
+ relayMerged: true,
296
+ relayMergeCommandId: command.id,
297
+ relayMergedAt: Date.now(),
298
+ relayMergeResult: command.result,
299
+ relayMergeError: undefined,
300
+ relayMergeFailedAt: undefined,
301
+ });
302
+ } else if (workspaceId && (command.status === "failed" || command.status === "timed_out" || command.status === "rejected" || command.status === "canceled")) {
303
+ patchWorkspaceMetadata(workspaceId, {
304
+ relayMerged: false,
305
+ relayMergeCommandId: undefined,
306
+ relayMergeError: command.error ?? "PR merge failed",
307
+ relayMergeFailedAt: Date.now(),
308
+ relayMergeResult: command.result,
309
+ });
310
+ }
311
+ }
288
312
  if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
289
313
  // Record the outcome on the row without touching status (#51) — observability
290
314
  // for the dashboard; the CLI reads the result straight off the command.
@@ -0,0 +1,37 @@
1
+ // Auto-split from routes.ts (#299). Domain: continuation archives.
2
+ import { isRecord } from "agent-relay-sdk";
3
+ import { resolveCallerAgentId } from "../agent-ref";
4
+ import { archiveContinuationSegment, appendContinuationArchiveRefs, getAgent, listAgents, ValidationError } from "../db";
5
+ import { getComponentAuth } from "../security";
6
+ import { cleanEpoch, cleanPositiveId, cleanString } from "../validation";
7
+ import { error, json, parseBody, type Handler } from "./_shared";
8
+
9
+ export const postContinuationArchiveRoute: Handler = async (req) => {
10
+ const parsed = await parseBody<unknown>(req);
11
+ if (!parsed.ok) return error(parsed.error, parsed.status);
12
+ if (!isRecord(parsed.body)) return error("JSON object body required", 400);
13
+ try {
14
+ const agentId = cleanString(parsed.body.agentId, "agentId", { required: true, max: 240 })!;
15
+ const denied = authorizeArchiveWriter(req, agentId);
16
+ if (denied) return denied;
17
+ if (!getAgent(agentId)) return error("agent not found", 404);
18
+ const archive = archiveContinuationSegment({
19
+ agentId,
20
+ segment: cleanString(parsed.body.segment, "segment", { required: true, max: 64_000 })!,
21
+ generation: cleanPositiveId(parsed.body.generation, "generation"),
22
+ now: cleanEpoch(parsed.body.occurredAt, "occurredAt"),
23
+ });
24
+ const envelope = appendContinuationArchiveRefs(agentId, [archive.ref]);
25
+ return json({ archive, envelope }, 201);
26
+ } catch (e) {
27
+ if (e instanceof ValidationError) return error(e.message, 400);
28
+ throw e;
29
+ }
30
+ };
31
+
32
+ function authorizeArchiveWriter(req: Request, agentId: string): Response | null {
33
+ const component = getComponentAuth(req);
34
+ if (!component) return null;
35
+ const caller = resolveCallerAgentId(component.constraints, listAgents);
36
+ return caller === agentId ? null : error("forbidden", 403);
37
+ }
@@ -20,6 +20,7 @@ import { getApiDocs, getApiSpec } from "./spec";
20
20
  import { getChannelBindings, getChannels, getIntegrations, postChannelActivity, postChannelBinding, postChannelEvent, postIntegrationEvent, putIntegrationRegistryRoute } from "./integrations";
21
21
  import { getCommandById, getCommands, patchCommand, postCommand } from "./commands";
22
22
  import { getConnectorById, getConnectorConfig, getConnectors, postConnectorAction, postConnectorCall, postConnectorRegister, putConnectorConfig } from "./connectors";
23
+ import { postContinuationArchiveRoute } from "./continuation-archives";
23
24
  import { getEvents } from "./sse";
24
25
  import { getHostDirectories, postAgent, postAgentSpawn, postRouteAdvice } from "./agents-spawn";
25
26
  import { getInsightsConfigRoute, getInsightsObservationsRoute, postInsightsObservationRoute, putInsightsConfigRoute } from "./insights";
@@ -170,6 +171,7 @@ const routes: Route[] = [
170
171
  route("PUT", "/api/insights/config", putInsightsConfigRoute),
171
172
  route("GET", "/api/insights/observations", getInsightsObservationsRoute),
172
173
  route("POST", "/api/insights/observations", postInsightsObservationRoute),
174
+ route("POST", "/api/continuation-archives", postContinuationArchiveRoute),
173
175
  route("GET", "/api/config/:namespace", getConfigNamespace),
174
176
  route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
175
177
  route("GET", "/api/config/:namespace/:key", getConfigKey),
package/src/security.ts CHANGED
@@ -207,6 +207,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
207
207
  // Insights config (the feature toggle) stays admin-only via the default; only the
208
208
  // mechanical observation feed is writable by lower-privilege callers.
209
209
  if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
210
+ if (pathname === "/api/continuation-archives") return "insights:write";
210
211
  if (pathname.startsWith("/api/system/")) return "system:admin";
211
212
  return null;
212
213
  }
@@ -281,6 +282,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
281
282
  // below and every observation was 403-dropped — the whole Insights feed stayed empty.
282
283
  // Config (the feature toggle) intentionally keeps falling through to system:admin.
283
284
  if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
285
+ if (pathname === "/api/continuation-archives") return "insights:write";
284
286
  if (pathname.startsWith("/api/system/")) return "system:admin";
285
287
  return "system:admin";
286
288
  }
@@ -0,0 +1,44 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { ValidationError } from "./db/connection.ts";
3
+ import type { SelfResumeConfig } from "./types";
4
+
5
+ export const DEFAULT_SELF_RESUME_CONFIG: SelfResumeConfig = {
6
+ defaultMode: "clear-inject",
7
+ budget: {
8
+ maxGenerations: 8,
9
+ maxWallClockMs: 24 * 60 * 60 * 1000,
10
+ maxTokens: 2_000_000,
11
+ },
12
+ };
13
+
14
+ const VALID_SELF_RESUME_MODES = ["clear-inject", "native"] as const;
15
+
16
+ export function validateSelfResumeConfig(value: unknown): SelfResumeConfig {
17
+ if (!isRecord(value)) throw new ValidationError("self-resume config value must be an object");
18
+ const budget = isRecord(value.budget) ? value.budget : {};
19
+ return {
20
+ defaultMode: value.defaultMode === undefined || value.defaultMode === null
21
+ ? DEFAULT_SELF_RESUME_CONFIG.defaultMode
22
+ : cleanMode(value.defaultMode, "defaultMode"),
23
+ budget: {
24
+ maxGenerations: cleanPositiveInt(budget.maxGenerations, "budget.maxGenerations", DEFAULT_SELF_RESUME_CONFIG.budget.maxGenerations),
25
+ maxWallClockMs: cleanPositiveInt(budget.maxWallClockMs, "budget.maxWallClockMs", DEFAULT_SELF_RESUME_CONFIG.budget.maxWallClockMs),
26
+ maxTokens: cleanPositiveInt(budget.maxTokens, "budget.maxTokens", DEFAULT_SELF_RESUME_CONFIG.budget.maxTokens),
27
+ },
28
+ };
29
+ }
30
+
31
+ function cleanMode(value: unknown, field: string): SelfResumeConfig["defaultMode"] {
32
+ if (typeof value !== "string" || !VALID_SELF_RESUME_MODES.includes(value as any)) {
33
+ throw new ValidationError(`${field} must be one of: ${VALID_SELF_RESUME_MODES.join(", ")}`);
34
+ }
35
+ return value as SelfResumeConfig["defaultMode"];
36
+ }
37
+
38
+ function cleanPositiveInt(value: unknown, field: string, fallback: number): number {
39
+ if (value === undefined || value === null) return fallback;
40
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
41
+ throw new ValidationError(`${field} must be a positive integer`);
42
+ }
43
+ return value;
44
+ }
@@ -0,0 +1,232 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { createCommand, listCommands } from "./commands-db";
3
+ import { emitCommandEvent } from "./command-events";
4
+ import { getSelfResumeConfig } from "./config-store";
5
+ import {
6
+ archiveContinuationSegment,
7
+ ensureContinuationEnvelope,
8
+ getAgent,
9
+ getContinuationEnvelope,
10
+ updateContinuationAfterCompact,
11
+ ValidationError,
12
+ } from "./db";
13
+ import { emitRelayEvent } from "./events";
14
+ import { notifySystemMessage } from "./notify";
15
+ import type { AgentCard, Command, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
16
+
17
+ export interface CompactAndResumeInput {
18
+ agentId: string;
19
+ workingState: string;
20
+ ruledOut?: unknown;
21
+ mode?: SelfResumeMode;
22
+ objective?: string;
23
+ }
24
+
25
+ export interface CompactAndResumeResult {
26
+ ok: boolean;
27
+ mode: SelfResumeMode;
28
+ envelope: ContinuationEnvelope;
29
+ continuation?: string;
30
+ command: Command;
31
+ exhausted?: boolean;
32
+ reason?: string;
33
+ }
34
+
35
+ const VALID_RESUME_MODES = ["clear-inject", "native"] as const;
36
+
37
+ export function compactAndResume(input: CompactAndResumeInput): CompactAndResumeResult {
38
+ const agent = getAgent(input.agentId);
39
+ if (!agent) throw new ValidationError("agent not found");
40
+ const config = getSelfResumeConfig();
41
+ const mode = input.mode ?? config.defaultMode;
42
+ if (!VALID_RESUME_MODES.includes(mode)) throw new ValidationError(`mode must be one of: ${VALID_RESUME_MODES.join(", ")}`);
43
+ assertResumeSupported(agent, mode);
44
+
45
+ const now = Date.now();
46
+ const envelope = ensureContinuationEnvelope({
47
+ agentId: agent.id,
48
+ objective: input.objective,
49
+ config,
50
+ now,
51
+ });
52
+ const exhausted = budgetExhaustion(envelope, now);
53
+ if (exhausted) {
54
+ const command = createCleanStop(agent, exhausted);
55
+ notifySelfResumeBudgetExhausted(agent, envelope, exhausted);
56
+ return { ok: false, mode, envelope, command, exhausted: true, reason: exhausted };
57
+ }
58
+
59
+ const workingState = input.workingState.trim();
60
+ if (!workingState) throw new ValidationError("workingState required");
61
+ const generation = envelope.generation + 1;
62
+ const ledgerEntries = parseRuledOut(input.ruledOut, generation, now);
63
+ const archive = archiveContinuationSegment({
64
+ agentId: agent.id,
65
+ generation,
66
+ segment: workingState,
67
+ now,
68
+ });
69
+ const updated = updateContinuationAfterCompact({
70
+ agentId: agent.id,
71
+ generation,
72
+ ledgerEntries,
73
+ workingState,
74
+ archiveRefs: [archive.ref],
75
+ spentTokens: typeof agent.context?.tokensUsed === "number" ? agent.context.tokensUsed : 0,
76
+ now,
77
+ });
78
+ const continuation = renderContinuation(updated);
79
+ const command = mode === "native"
80
+ ? createNativeCompact(agent.id, generation, continuation)
81
+ : createClearThenInject(agent.id, generation, continuation);
82
+ emitCommandEvent(command, "command.requested");
83
+ return { ok: true, mode, envelope: updated, continuation, command };
84
+ }
85
+
86
+ export function enqueueContinuationInjectionAfterClear(command: Command): Command | null {
87
+ if (command.type !== "agent.clearContext" || command.status !== "succeeded") return null;
88
+ const resume = isRecord(command.params.selfResume) ? command.params.selfResume : undefined;
89
+ const continuation = typeof resume?.continuation === "string" ? resume.continuation : undefined;
90
+ if (!continuation) return null;
91
+ const generation = typeof resume?.generation === "number" ? resume.generation : undefined;
92
+ const existing = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
93
+ .find((item) => item.correlationId === command.id);
94
+ if (existing) return null;
95
+ const inject = createCommand({
96
+ type: "agent.injectContext",
97
+ source: "system",
98
+ target: command.target,
99
+ correlationId: command.id,
100
+ params: {
101
+ reason: "self-resume-continuation",
102
+ content: continuation,
103
+ selfResume: {
104
+ generation,
105
+ afterClearCommandId: command.id,
106
+ },
107
+ },
108
+ });
109
+ emitCommandEvent(inject, "command.requested");
110
+ return inject;
111
+ }
112
+
113
+ export function renderContinuation(envelope: ContinuationEnvelope): string {
114
+ const ledger = envelope.ledger.length
115
+ ? envelope.ledger.map((entry) => `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`).join("\n")
116
+ : "- none";
117
+ return [
118
+ `You have been resumed by Agent Relay — generation ${envelope.generation}.`,
119
+ "",
120
+ "Objective (verbatim, relay-owned):",
121
+ envelope.objective,
122
+ "",
123
+ "Ruled-out ledger (append-only):",
124
+ ledger,
125
+ "",
126
+ "Working state:",
127
+ envelope.workingState,
128
+ "",
129
+ "Archive refs:",
130
+ envelope.archiveRefs.length ? envelope.archiveRefs.join("\n") : "- none",
131
+ ].join("\n");
132
+ }
133
+
134
+ function assertResumeSupported(agent: AgentCard, mode: SelfResumeMode): void {
135
+ const context = agent.providerCapabilities?.context;
136
+ const resume = context?.resume ?? (context?.clear && context?.inject ? "clear-inject" : "none");
137
+ if (resume === "none") throw new ValidationError("self-resume unsupported, fresh-session handoff only");
138
+ if (mode === "native" && resume !== "native") throw new ValidationError("native self-resume unsupported for this provider");
139
+ if (mode === "clear-inject" && !(context?.clear && context?.inject)) {
140
+ throw new ValidationError("clear-inject self-resume unsupported for this provider");
141
+ }
142
+ }
143
+
144
+ function budgetExhaustion(envelope: ContinuationEnvelope, now: number): string | null {
145
+ if (envelope.generation >= envelope.budget.maxGenerations) return "max generations exhausted";
146
+ if (Math.max(0, now - envelope.createdAt) >= envelope.budget.maxWallClockMs) return "max wall clock exhausted";
147
+ if (envelope.budget.spent.tokens >= envelope.budget.maxTokens) return "max token budget exhausted";
148
+ return null;
149
+ }
150
+
151
+ function createNativeCompact(agentId: string, generation: number, continuation: string): Command {
152
+ return createCommand({
153
+ type: "agent.compact",
154
+ source: "system",
155
+ target: agentId,
156
+ params: {
157
+ reason: "self-resume",
158
+ instructions: continuation,
159
+ selfResume: { generation, mode: "native" },
160
+ },
161
+ });
162
+ }
163
+
164
+ function createClearThenInject(agentId: string, generation: number, continuation: string): Command {
165
+ return createCommand({
166
+ type: "agent.clearContext",
167
+ source: "system",
168
+ target: agentId,
169
+ params: {
170
+ reason: "self-resume",
171
+ selfResume: { generation, mode: "clear-inject", continuation },
172
+ },
173
+ });
174
+ }
175
+
176
+ function createCleanStop(agent: AgentCard, reason: string): Command {
177
+ const command = createCommand({
178
+ type: "agent.shutdown",
179
+ source: "system",
180
+ target: agent.id,
181
+ params: {
182
+ reason: `self-resume budget exhausted: ${reason}`,
183
+ graceful: true,
184
+ timeoutMs: 10_000,
185
+ preserveRegistration: true,
186
+ selfResume: { exhausted: true },
187
+ },
188
+ });
189
+ emitCommandEvent(command, "command.requested");
190
+ return command;
191
+ }
192
+
193
+ function notifySelfResumeBudgetExhausted(agent: AgentCard, envelope: ContinuationEnvelope, reason: string): void {
194
+ const payload = {
195
+ kind: "agent.resume_budget_exhausted",
196
+ agentId: agent.id,
197
+ parent: agent.spawnedBy,
198
+ reason,
199
+ generation: envelope.generation,
200
+ };
201
+ emitRelayEvent({ type: "agent.resume_budget_exhausted", source: "server", subject: agent.id, data: payload });
202
+ if (!agent.spawnedBy) return;
203
+ notifySystemMessage(agent.spawnedBy, {
204
+ subject: "Self-resume budget exhausted",
205
+ body: `Spawned agent \`${agent.id}\` stopped cleanly: ${reason}.`,
206
+ payload,
207
+ replyExpected: false,
208
+ });
209
+ }
210
+
211
+ function parseRuledOut(value: unknown, generation: number, now: number): ContinuationLedgerEntry[] {
212
+ if (value === undefined || value === null) return [];
213
+ if (!Array.isArray(value)) throw new ValidationError("ruledOut must be an array");
214
+ return value.map((item, index) => {
215
+ if (typeof item === "string") return ledgerEntry(item, "", generation, now);
216
+ if (!isRecord(item)) throw new ValidationError(`ruledOut[${index}] must be a string or object`);
217
+ const ruledOut = typeof item.ruledOut === "string" ? item.ruledOut.trim() : "";
218
+ const why = typeof item.why === "string" ? item.why.trim() : "";
219
+ if (!ruledOut) throw new ValidationError(`ruledOut[${index}].ruledOut required`);
220
+ return ledgerEntry(ruledOut, why, generation, now);
221
+ });
222
+ }
223
+
224
+ function ledgerEntry(ruledOut: string, why: string, generation: number, now: number): ContinuationLedgerEntry {
225
+ const cleaned = ruledOut.trim();
226
+ if (!cleaned) throw new ValidationError("ruledOut entries cannot be blank");
227
+ return { gen: generation, ruledOut: cleaned, why, at: now };
228
+ }
229
+
230
+ export function continuationForAgent(agentId: string): ContinuationEnvelope | null {
231
+ return getContinuationEnvelope(agentId);
232
+ }
@@ -172,6 +172,10 @@ export function sendMessageService(
172
172
  } else {
173
173
  emitNewMessage(result.message);
174
174
  }
175
+ } else if (result.updated) {
176
+ // Session-step upsert (#parity): the row changed in place (same DB id). Re-emit so the
177
+ // dashboard's id-keyed merge replaces the existing step live — no duplicate row.
178
+ emitNewMessage(result.message);
175
179
  }
176
180
  return { message: result.message, created: result.created, receipt };
177
181
  }
@@ -0,0 +1,121 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { createCommand, getCommand } from "./commands-db";
3
+ import { patchWorkspaceMetadata } from "./db";
4
+ import { handleReviewerPipelineScan, isAwaitingReviewerApproval } from "./reviewer-pipeline";
5
+ import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergePreview, WorkspaceRecord } from "./types";
6
+
7
+ const ACTIVE_COMMAND_STATUSES = new Set(["pending", "accepted", "running"]);
8
+ const PASSING_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
9
+ const PASSING_STATES = new Set(["SUCCESS"]);
10
+ const READY_MERGE_STATES = new Set(["CLEAN", "HAS_HOOKS"]);
11
+
12
+ function relayPrMergeAlreadyHandled(workspace: WorkspaceRecord): boolean {
13
+ const commandId = text(workspace.metadata.relayMergeCommandId);
14
+ if (commandId) {
15
+ const command = getCommand(commandId);
16
+ if (command && ACTIVE_COMMAND_STATUSES.has(command.status)) return true;
17
+ if (command?.status === "succeeded" && command.result?.relayMerged === true) return true;
18
+ }
19
+ return workspace.metadata.relayMerged === true && !commandId;
20
+ }
21
+
22
+ function nativeAutoMergeArmed(workspace: WorkspaceRecord): boolean {
23
+ if (isRecord(workspace.metadata.mergeResult) && workspace.metadata.mergeResult.autoMergeArmed === true) return true;
24
+ const commandId = text(workspace.metadata.autoMergeArmCommandId);
25
+ if (!commandId) return false;
26
+ const command = getCommand(commandId);
27
+ return command?.status === "succeeded" && command.result?.autoMergeArmed === true;
28
+ }
29
+
30
+ function prAutoMergePolicy(workspace: WorkspaceRecord, preview: WorkspaceMergePreview): WorkspaceAutoMergePolicy {
31
+ const commandId = text(workspace.metadata.mergeCommandId);
32
+ if (commandId) {
33
+ const command = getCommand(commandId);
34
+ const autoMerge = text(command?.params.autoMerge);
35
+ if (autoMerge === "on-green" || autoMerge === "on-approval" || autoMerge === "manual") return autoMerge;
36
+ }
37
+ if (workspace.metadata.awaitingApproval === true || (isRecord(workspace.metadata.mergeResult) && workspace.metadata.mergeResult.awaitingApproval === true)) return "on-approval";
38
+ return "on-green";
39
+ }
40
+
41
+ function prReadyForRelayMerge(workspace: WorkspaceRecord, preview: WorkspaceMergePreview): { ok: true; policy: WorkspaceAutoMergePolicy } | { ok: false; reason: string; policy?: WorkspaceAutoMergePolicy } {
42
+ if (preview.strategy !== "pr") return { ok: false, reason: "not-pr-strategy" };
43
+ if (preview.prMerged === true || preview.prState === "merged") return { ok: false, reason: "already-merged" };
44
+ if (!preview.prNumber && !preview.prUrl) return { ok: false, reason: "missing-pr" };
45
+ if (nativeAutoMergeArmed(workspace)) return { ok: false, reason: "native-auto-merge-armed" };
46
+ if (relayPrMergeAlreadyHandled(workspace)) return { ok: false, reason: "relay-merge-already-dispatched" };
47
+
48
+ const policy = prAutoMergePolicy(workspace, preview);
49
+ if (policy === "manual") return { ok: false, reason: "manual", policy };
50
+ if (policy === "on-approval" && preview.reviewDecision !== "APPROVED") return { ok: false, reason: "approval-required", policy };
51
+ if (!prMergeable(preview)) return { ok: false, reason: "not-mergeable", policy };
52
+ if (!statusChecksGreen(preview.statusCheckRollup)) return { ok: false, reason: "checks-not-green", policy };
53
+ return { ok: true, policy };
54
+ }
55
+
56
+ export function preparePrCompletionScan(workspace: WorkspaceRecord, preview: WorkspaceMergePreview, orchestratorAgentId: string): {
57
+ armCommand?: Command;
58
+ notifiedOwner?: string;
59
+ mergeCommand?: Command;
60
+ waitReason?: string;
61
+ stop?: boolean;
62
+ } {
63
+ if (isAwaitingReviewerApproval(workspace)) {
64
+ const action = handleReviewerPipelineScan(workspace, preview, orchestratorAgentId);
65
+ if (action.armCommand) return { armCommand: action.armCommand, stop: true };
66
+ if (action.notifiedOwner) return { notifiedOwner: action.notifiedOwner, stop: true };
67
+ }
68
+ const relayPr = prReadyForRelayMerge(workspace, preview);
69
+ if (relayPr.ok) return { mergeCommand: dispatchRelayPrMergeCommand(workspace, preview, orchestratorAgentId, relayPr.policy), stop: true };
70
+ return {
71
+ waitReason: preview.strategy === "pr" && relayPr.reason !== "not-pr-strategy" ? relayPr.reason : undefined,
72
+ stop: isAwaitingReviewerApproval(workspace),
73
+ };
74
+ }
75
+
76
+ function dispatchRelayPrMergeCommand(workspace: WorkspaceRecord, preview: WorkspaceMergePreview, orchestratorAgentId: string, policy: WorkspaceAutoMergePolicy): Command {
77
+ const now = Date.now();
78
+ const prNumber = preview.prNumber ?? (typeof workspace.metadata.prNumber === "number" ? workspace.metadata.prNumber : undefined);
79
+ const prUrl = preview.prUrl ?? text(workspace.metadata.prUrl);
80
+ const command = createCommand({
81
+ type: "workspace.pr-merge",
82
+ source: "system",
83
+ target: orchestratorAgentId,
84
+ correlationId: workspace.id,
85
+ params: {
86
+ action: "pr-merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath,
87
+ branch: workspace.branch, baseRef: workspace.baseRef, prNumber, prUrl, policy,
88
+ requestedBy: "relay-pr-completion", requestedAt: now,
89
+ },
90
+ });
91
+ patchWorkspaceMetadata(workspace.id, {
92
+ relayMerged: true, relayMergeCommandId: command.id, relayMergeDispatchedAt: now, relayMergePolicy: policy,
93
+ relayMergeError: undefined, relayMergeFailedAt: undefined,
94
+ });
95
+ return command;
96
+ }
97
+
98
+ function statusChecksGreen(rollup: unknown): boolean {
99
+ if (!Array.isArray(rollup) || rollup.length === 0) return false;
100
+ return rollup.every(statusNodeGreen);
101
+ }
102
+
103
+ function prMergeable(preview: WorkspaceMergePreview): boolean {
104
+ const mergeable = text(preview.mergeable)?.toUpperCase();
105
+ if (mergeable !== "MERGEABLE") return false;
106
+ const state = text(preview.mergeStateStatus)?.toUpperCase();
107
+ return state ? READY_MERGE_STATES.has(state) : true;
108
+ }
109
+
110
+ function statusNodeGreen(node: unknown): boolean {
111
+ if (!isRecord(node)) return false;
112
+ const conclusion = text(node.conclusion)?.toUpperCase();
113
+ if (conclusion) return PASSING_CONCLUSIONS.has(conclusion);
114
+ const state = text(node.state)?.toUpperCase();
115
+ if (state) return PASSING_STATES.has(state);
116
+ return false;
117
+ }
118
+
119
+ function text(value: unknown): string | undefined {
120
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
121
+ }