agent-relay-server 0.127.1 → 0.127.3

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.127.1",
5
+ "version": "0.127.3",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.127.1",
3
+ "version": "0.127.3",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "CONTRIBUTING.md"
38
38
  ],
39
39
  "dependencies": {
40
- "agent-relay-channels-host": "0.127.1",
40
+ "agent-relay-channels-host": "0.127.3",
41
41
  "agent-relay-providers": "0.104.4",
42
42
  "agent-relay-sdk": "0.2.118",
43
43
  "ajv": "^8.20.0"
@@ -54,9 +54,11 @@
54
54
  "build:dashboard": "bun run build:dashboard:bundle && bun run deploy:dashboard",
55
55
  "build:dashboard:bundle": "bun run build:sdk && cd dashboard && bun run build",
56
56
  "deploy:dashboard": "bun run scripts/deploy-dashboard.ts",
57
- "ci:land": "bun run build:dashboard:bundle && bun run build:claude-monitor:check && bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun run test",
57
+ "ci:land": "bun run build:dashboard:bundle && bun run build:claude-monitor:check && bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun test",
58
+ "ci:release": "bun run build:dashboard:bundle && bun run build:claude-monitor:check && bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun test --bail",
58
59
  "ci": "bun run ci:land",
59
- "test": "bun test",
60
+ "test": "bun run test:guards && bun test --bail",
61
+ "test:guards": "bun test --bail=1 src/file-size-ratchet.test.ts src/duplication-ratchet.test.ts src/env-access-ratchet.test.ts src/no-raw-control-bytes.test.ts src/transport-thinness-ratchet.test.ts src/commands-db-ttl-guard.test.ts scripts/release-deploy-guard.test.ts scripts/release-tree-guard.test.ts",
60
62
  "smoke:spawn": "bun run scripts/orchestrator-spawn-smoke.ts",
61
63
  "publish:smoke": "bun run scripts/publish-smoke.ts",
62
64
  "lint:provider-contract": "bun run scripts/check-provider-contract.ts",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.127.1",
4
+ "version": "0.127.3",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -320,6 +320,9 @@ function shouldShowReplyReminder(deliveryCount) {
320
320
  function latestReplyableMessage(messages) {
321
321
  return messages.filter((message) => isPersistedRelayMessage2(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false).at(-1);
322
322
  }
323
+ function isSelfResumeInjection(message) {
324
+ return isRecord(message.payload) && message.payload.resumeInjection === true;
325
+ }
323
326
  function formatMessage(message, relaySurface) {
324
327
  const subject = message.subject ? `Subject: ${message.subject}
325
328
  ` : "";
@@ -331,7 +334,7 @@ function formatMessage(message, relaySurface) {
331
334
  `) : undefined;
332
335
  if (isMemoryContext) {
333
336
  return [
334
- `[agent-relay context from ${message.from}]`,
337
+ `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
335
338
  providerAttachmentText(message),
336
339
  `${subject}${preview.body}`,
337
340
  truncationGuidance
@@ -327,6 +327,10 @@ function isMemoryInjection(message: Message): boolean {
327
327
  return isRecord(message.payload) && message.payload.memoryInjection === true;
328
328
  }
329
329
 
330
+ function isSelfResumeInjection(message: Message): boolean {
331
+ return isRecord(message.payload) && message.payload.resumeInjection === true;
332
+ }
333
+
330
334
  function isReactionNotification(message: Message): boolean {
331
335
  const event = isRecord(message.payload?.event) ? message.payload.event : undefined;
332
336
  return message.payload?.reactionNotification === true || event?.type === "message.reaction";
@@ -434,7 +438,7 @@ export function providerMessageText(messages: Message[]): string {
434
438
  : undefined;
435
439
  if (isMemoryContext) {
436
440
  return [
437
- `[agent-relay context from ${message.from}]`,
441
+ `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
438
442
  "Guide: agent-relay /guide",
439
443
  providerAttachmentText(message),
440
444
  `${subject}${preview.body}`,
@@ -69,6 +69,10 @@ function latestReplyableMessage(messages: Message[]): Message | undefined {
69
69
  .at(-1);
70
70
  }
71
71
 
72
+ function isSelfResumeInjection(message: Message): boolean {
73
+ return isRecord(message.payload) && message.payload.resumeInjection === true;
74
+ }
75
+
72
76
  function formatMessage(message: Message, relaySurface: boolean): string {
73
77
  const subject = message.subject ? `Subject: ${message.subject}\n` : "";
74
78
  const isMemoryContext = isMemoryInjection(message);
@@ -81,7 +85,7 @@ function formatMessage(message: Message, relaySurface: boolean): string {
81
85
 
82
86
  if (isMemoryContext) {
83
87
  return [
84
- `[agent-relay context from ${message.from}]`,
88
+ `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
85
89
  providerAttachmentText(message),
86
90
  `${subject}${preview.body}`,
87
91
  truncationGuidance,
@@ -67,7 +67,12 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
67
67
  }
68
68
  logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
69
69
  } catch (error) {
70
- if (isHttpStatusError(error, 409)) return;
70
+ if (isHttpStatusError(error, 409)) {
71
+ if (record.kind === "session-message-batch") {
72
+ logger.error("outbox", `relay reported duplicate session batch; acking row seq=${record.seq} batchKey=${record.idempotencyKey} itemKeys=${sessionBatchItemKeys(record).join(",")} error=${errMessage(error)}`);
73
+ }
74
+ return;
75
+ }
71
76
  if (isHttpAuthError(error)) input.recoverRuntimeTokenAfterAuthFailure("outbox");
72
77
  throw error;
73
78
  }
@@ -89,6 +94,13 @@ function isDeterministicValidationError(error: unknown): boolean {
89
94
  return status === 400 || status === 413 || status === 422;
90
95
  }
91
96
 
97
+ function sessionBatchItemKeys(record: OutboxRecord): string[] {
98
+ const messages = Array.isArray((record.payload as { messages?: unknown }).messages)
99
+ ? (record.payload as { messages: SendMessageInput[] }).messages
100
+ : [];
101
+ return messages.map((message, index) => message.idempotencyKey ?? `${record.idempotencyKey}:${index}`);
102
+ }
103
+
92
104
  export async function deliverBufferedMcpCall(input: {
93
105
  record: OutboxRecord;
94
106
  relayUrl: string;
@@ -842,17 +842,24 @@ export class AgentRunner {
842
842
  const content = typeof params.content === "string" ? params.content.trim() : "";
843
843
  if (!content) throw new Error("content required");
844
844
  const memoryIds = Array.isArray(params.memoryIds) ? params.memoryIds.filter((id): id is string => typeof id === "string") : [];
845
+ const reason = typeof params.reason === "string" ? params.reason : undefined;
846
+ const selfResume = params.selfResume !== null && typeof params.selfResume === "object" && !Array.isArray(params.selfResume)
847
+ ? params.selfResume as Record<string, unknown>
848
+ : undefined;
849
+ const isSelfResumeContinuation = reason === "self-resume-continuation" || Boolean(selfResume);
845
850
  await this.options.adapter.deliver(this.process!, [{
846
851
  id: 0,
847
852
  from: "system",
848
853
  to: this.agentId,
849
854
  kind: "system",
850
- subject: "Agent Relay memory context",
855
+ subject: isSelfResumeContinuation ? "Agent Relay self-resume continuation" : "Agent Relay memory context",
851
856
  body: content,
852
857
  payload: {
853
858
  memoryInjection: true,
854
- reason: typeof params.reason === "string" ? params.reason : undefined,
859
+ reason,
855
860
  memoryIds,
861
+ ...(isSelfResumeContinuation ? { resumeInjection: true } : {}),
862
+ ...(selfResume ? { selfResume } : {}),
856
863
  },
857
864
  readBy: [],
858
865
  createdAt: Date.now(),
@@ -1598,7 +1605,7 @@ export class AgentRunner {
1598
1605
  });
1599
1606
  this.sessionOutbox.enqueue({
1600
1607
  kind: "session-message-batch",
1601
- idempotencyKey: `session-batch:${this.agentId}:${++this.sessionBatchSeq}:${messages.length}`,
1608
+ idempotencyKey: `session-batch:${this.agentId}:${this.options.instanceId}:${this.options.startedAt}:${++this.sessionBatchSeq}:${messages.length}`,
1602
1609
  payload: { messages },
1603
1610
  });
1604
1611
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { hostname } from "node:os";
4
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
5
  import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
5
6
 
6
7
  type Orchestrator = {
@@ -33,14 +34,16 @@ let cwd = process.env.AGENT_RELAY_SMOKE_CWD || "";
33
34
  let timeoutMs = Number(process.env.AGENT_RELAY_SMOKE_TIMEOUT_MS || 90_000);
34
35
  let providers = (process.env.AGENT_RELAY_SMOKE_PROVIDERS || "codex,claude").split(",").filter(Boolean);
35
36
 
36
- for (let i = 0; i < args.length; i++) {
37
- const arg = args[i]!;
38
- if (arg === "--relay-url" && args[i + 1]) relayUrl = args[++i]!;
39
- else if (arg === "--orchestrator" && args[i + 1]) orchestratorId = args[++i]!;
40
- else if (arg === "--cwd" && args[i + 1]) cwd = args[++i]!;
41
- else if (arg === "--timeout" && args[i + 1]) timeoutMs = Number(args[++i]);
42
- else if (arg === "--providers" && args[i + 1]) providers = args[++i]!.split(",").map((p) => p.trim()).filter(Boolean);
43
- else throw new Error(`Unknown option: ${arg}`);
37
+ function parseArgs(): void {
38
+ for (let i = 0; i < args.length; i++) {
39
+ const arg = args[i]!;
40
+ if (arg === "--relay-url" && args[i + 1]) relayUrl = args[++i]!;
41
+ else if (arg === "--orchestrator" && args[i + 1]) orchestratorId = args[++i]!;
42
+ else if (arg === "--cwd" && args[i + 1]) cwd = args[++i]!;
43
+ else if (arg === "--timeout" && args[i + 1]) timeoutMs = Number(args[++i]);
44
+ else if (arg === "--providers" && args[i + 1]) providers = args[++i]!.split(",").map((p) => p.trim()).filter(Boolean);
45
+ else throw new Error(`Unknown option: ${arg}`);
46
+ }
44
47
  }
45
48
 
46
49
  relayUrl = relayUrl.replace(/\/+$/, "");
@@ -92,6 +95,68 @@ function localRepoRoot(): string | null {
92
95
  return result.stdout.trim() || null;
93
96
  }
94
97
 
98
+ function localRepoName(): string | null {
99
+ const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {
100
+ cwd: process.cwd(),
101
+ encoding: "utf8",
102
+ });
103
+ if (result.status !== 0) return null;
104
+ const remote = result.stdout.trim();
105
+ if (!remote) return null;
106
+ const name = basename(remote.replace(/\.git$/, ""));
107
+ return name || null;
108
+ }
109
+
110
+ function pathContainedBy(path: string, parent: string): boolean {
111
+ const rel = relative(resolve(parent), resolve(path));
112
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
113
+ }
114
+
115
+ function isGitRepo(path: string): boolean {
116
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
117
+ cwd: path,
118
+ encoding: "utf8",
119
+ });
120
+ return result.status === 0;
121
+ }
122
+
123
+ export function resolveDefaultSpawnCwd(
124
+ orchestrator: Pick<Orchestrator, "id" | "baseDir">,
125
+ deps: {
126
+ hostname?: () => string;
127
+ localRepoRoot?: () => string | null;
128
+ localRepoName?: () => string | null;
129
+ isGitRepo?: (path: string) => boolean;
130
+ } = {},
131
+ ): string {
132
+ const host = deps.hostname ?? hostname;
133
+ if (orchestrator.id !== host()) {
134
+ throw new Error(`orchestrator ${orchestrator.id} is remote; pass --cwd or set AGENT_RELAY_SMOKE_CWD to a git repo on that host`);
135
+ }
136
+ if (!orchestrator.baseDir) {
137
+ throw new Error(`orchestrator ${orchestrator.id} did not report a baseDir; pass --cwd or set AGENT_RELAY_SMOKE_CWD`);
138
+ }
139
+
140
+ const gitRoot = (deps.localRepoRoot ?? localRepoRoot)();
141
+ if (gitRoot && pathContainedBy(gitRoot, orchestrator.baseDir)) return gitRoot;
142
+
143
+ const repoName = (deps.localRepoName ?? localRepoName)();
144
+ const gitCheck = deps.isGitRepo ?? isGitRepo;
145
+ const candidates = [
146
+ repoName ? join(orchestrator.baseDir, "oss", repoName) : "",
147
+ repoName ? join(orchestrator.baseDir, repoName) : "",
148
+ orchestrator.baseDir,
149
+ ].filter(Boolean);
150
+ for (const candidate of candidates) {
151
+ if (pathContainedBy(candidate, orchestrator.baseDir) && gitCheck(candidate)) return candidate;
152
+ }
153
+
154
+ throw new Error(
155
+ `could not resolve a git repo inside orchestrator ${orchestrator.id} base dir ${orchestrator.baseDir}; ` +
156
+ "pass --cwd or set AGENT_RELAY_SMOKE_CWD",
157
+ );
158
+ }
159
+
95
160
  function findSpawnedAgent(agents: Agent[], provider: string, label: string, startedAt: number): Agent | null {
96
161
  return agents
97
162
  .filter((agent) => agent.ready && agent.status !== "offline")
@@ -127,6 +192,8 @@ function promptForCase(provider: string, approvalMode: ApprovalMode, label: stri
127
192
  };
128
193
  }
129
194
 
195
+ async function main(): Promise<void> {
196
+ parseArgs();
130
197
  const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
131
198
  // Default selection prefers the orchestrator on the same host as the relay (the
132
199
  // gate runs there): a local spawn is faster and avoids cross-host teardown
@@ -146,13 +213,7 @@ if (orchestrator.status !== "online") throw new Error(`orchestrator ${orchestrat
146
213
  // An explicit --cwd / env override still wins (and is subject to the
147
214
  // orchestrator's containment check).
148
215
  if (!cwd) {
149
- if (orchestrator.id !== hostname()) {
150
- throw new Error(`orchestrator ${orchestrator.id} is remote; pass --cwd or set AGENT_RELAY_SMOKE_CWD to a git repo on that host`);
151
- }
152
- cwd = localRepoRoot() || "";
153
- if (!cwd) {
154
- throw new Error("could not resolve the local git repo root; pass --cwd or set AGENT_RELAY_SMOKE_CWD");
155
- }
216
+ cwd = resolveDefaultSpawnCwd(orchestrator);
156
217
  }
157
218
  console.log(`using cwd ${cwd} on ${orchestrator.id} (base ${orchestrator.baseDir})`);
158
219
 
@@ -239,3 +300,8 @@ for (const provider of providers) {
239
300
  }
240
301
 
241
302
  console.log("spawn smoke passed");
303
+ }
304
+
305
+ if (import.meta.main) {
306
+ await main();
307
+ }
package/src/bus.ts CHANGED
@@ -3,9 +3,7 @@ import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta,
3
3
  import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
4
4
  import { projectAgentStatusData } from "./agent-card-projection";
5
5
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
6
- import { getCommand, updateCommand } from "./commands-db";
7
- import { emitCommandEvent } from "./command-events";
8
- import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
6
+ import { noteAgentTimelineEvent } from "./compaction-watch";
9
7
  import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit";
10
8
  import { bindSpawnedTaskToReadyAgent, registerAgent } from "./services/register-agent";
11
9
  import { authContextFromBus } from "./services/auth-context";
@@ -15,9 +13,8 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
15
13
  import { ServiceAuthError } from "./services/errors";
16
14
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
17
15
  import { flushQueuedDirectMessages } from "./services/managed-running";
16
+ import { updateCommandFromBus } from "./services/bus-command-update";
18
17
  import { isAgentUnavailable } from "./db/agent-predicates";
19
- import { applyCommandToRecipe } from "./recipe-runner";
20
- import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
21
18
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
22
19
  import { probeWorkspaceOnTurnEnd } from "./workspace-probe";
23
20
  import { busStaleGraceMs } from "./config";
@@ -31,7 +28,7 @@ import {
31
28
  } from "agent-relay-sdk/protocol";
32
29
  import { errMessage, isRecord } from "agent-relay-sdk";
33
30
  import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
34
- import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
31
+ import type { AgentCard, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
35
32
 
36
33
  interface BusSocketData {
37
34
  kind: "bus";
@@ -238,30 +235,9 @@ function handleCommandFrame(
238
235
  params: Record<string, unknown>,
239
236
  ): void {
240
237
  if (commandType === "command.update") {
241
- const command = getCommand(target);
242
- if (!command) {
243
- sendCommandResult(ws, frameId, "failed", undefined, "command not found");
244
- return;
245
- }
246
- if (!busCommandAuthorized(conn, command)) {
247
- sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
248
- return;
249
- }
250
- const status = typeof params.status === "string" ? params.status : undefined;
251
- const updated = updateCommand(command.id, {
252
- status: status as any,
253
- result: isRecord(params.result) ? params.result : undefined,
254
- error: typeof params.error === "string" ? params.error : undefined,
255
- });
256
- if (updated) {
257
- applyCommandToRecipe(updated);
258
- emitCommandEvent(updated, `command.${updated.status}`);
259
- // Production command completions arrive here over the bus, not the HTTP
260
- // PATCH route — arm the stall watch for finished compact/clear commands.
261
- noteCompactionCommandCompleted(updated.type, updated.status, updated.id, updated.target);
262
- enqueueContinuationInjectionAfterSelfResume(updated);
263
- }
264
- sendCommandResult(ws, frameId, "succeeded", updated ? { command: updated } : undefined);
238
+ void updateCommandFromBus({ target, params, componentAuth: conn.componentAuth })
239
+ .then((result) => sendCommandResult(ws, frameId, result.status, result.result, result.error))
240
+ .catch((error) => sendError(ws, frameId, "FRAME_FAILED", errMessage(error)));
265
241
  return;
266
242
  }
267
243
 
@@ -303,17 +279,6 @@ function handleCommandFrame(
303
279
  }
304
280
  }
305
281
 
306
- function busCommandAuthorized(
307
- conn: BusConnection,
308
- command: Pick<Command, "target" | "params">,
309
- ): boolean {
310
- if (!conn.componentAuth) return true;
311
- return isComponentAuthorizedFor(conn.componentAuth, {
312
- scope: "command:write",
313
- resource: commandAuthorizationResource(command),
314
- });
315
- }
316
-
317
282
  function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
318
283
  const payload = frame.payload;
319
284
  const runnerManaged = payload.meta?.runnerManaged === true || typeof payload.meta?.runnerId === "string";
@@ -147,35 +147,35 @@ const DEFAULT_PROMPT_TEMPLATES = [
147
147
  "Objective (verbatim, relay-owned):",
148
148
  "{{objective}}",
149
149
  "",
150
- "Ruled-out ledger (append-only):",
151
- "{{ruledOut}}",
152
- "",
153
- "Decision log (append-only):",
154
- "{{decisions}}",
155
- "",
156
150
  "Working state:",
157
151
  "{{workingState}}",
158
152
  "",
159
153
  "Archive refs:",
160
154
  "{{archiveRefs}}",
161
- ),
162
- defaultTemplate: lines(
163
- "{{intro}}",
164
- "",
165
- "Objective (verbatim, relay-owned):",
166
- "{{objective}}",
167
155
  "",
168
156
  "Ruled-out ledger (append-only):",
169
157
  "{{ruledOut}}",
170
158
  "",
171
159
  "Decision log (append-only):",
172
160
  "{{decisions}}",
161
+ ),
162
+ defaultTemplate: lines(
163
+ "{{intro}}",
164
+ "",
165
+ "Objective (verbatim, relay-owned):",
166
+ "{{objective}}",
173
167
  "",
174
168
  "Working state:",
175
169
  "{{workingState}}",
176
170
  "",
177
171
  "Archive refs:",
178
172
  "{{archiveRefs}}",
173
+ "",
174
+ "Ruled-out ledger (append-only):",
175
+ "{{ruledOut}}",
176
+ "",
177
+ "Decision log (append-only):",
178
+ "{{decisions}}",
179
179
  ),
180
180
  variables: ["intro", "generation", "objective", "ruledOut", "decisions", "workingState", "archiveRefs"],
181
181
  },
@@ -9,6 +9,7 @@ import {
9
9
  findMessageByIdempotencyKey,
10
10
  getAgent,
11
11
  getContinuationEnvelope,
12
+ sendMessage,
12
13
  updateContinuationAfterCompact,
13
14
  ValidationError,
14
15
  } from "./db";
@@ -31,6 +32,7 @@ interface CompactAndResumeResult {
31
32
  generation: number;
32
33
  commandId: string;
33
34
  continuationChars?: number;
35
+ continuationMessageId?: number;
34
36
  ledgerEntries: number;
35
37
  exhausted?: boolean;
36
38
  reason?: string;
@@ -91,10 +93,12 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
91
93
  now,
92
94
  });
93
95
  if (updated.droppedLedgerEntries.length) archiveDroppedLedgerEntries(agent.id, generation, updated.droppedLedgerEntries, now);
94
- const continuation = renderContinuation(updated.envelope);
96
+ const fullContinuation = renderContinuation(updated.envelope);
97
+ const continuationMessage = createContinuationRecoveryMessage(agent.id, generation, fullContinuation, now);
98
+ const continuation = insertRecoveryPointerAfterIntro(fullContinuation, continuationMessage.id);
95
99
  const command = mode === "native"
96
- ? createNativeCompact(agent.id, generation, continuation)
97
- : createClearThenInject(agent.id, generation, continuation);
100
+ ? createNativeCompact(agent.id, generation, continuation, continuationMessage.id)
101
+ : createClearThenInject(agent.id, generation, continuation, continuationMessage.id);
98
102
  emitCommandEvent(command, "command.requested");
99
103
  return {
100
104
  ok: true,
@@ -102,10 +106,45 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
102
106
  generation,
103
107
  commandId: command.id,
104
108
  continuationChars: continuation.length,
109
+ continuationMessageId: continuationMessage.id,
105
110
  ledgerEntries: updated.envelope.ledger.length,
106
111
  };
107
112
  }
108
113
 
114
+ function continuationRecoveryPointer(messageId: number): string {
115
+ return `Recovery pointer: use relay_get_message with message id ${messageId} to read your full prior context.`;
116
+ }
117
+
118
+ function insertRecoveryPointerAfterIntro(continuation: string, messageId: number): string {
119
+ const pointer = continuationRecoveryPointer(messageId);
120
+ const firstNewline = continuation.indexOf("\n");
121
+ if (firstNewline === -1) return `${continuation.trimEnd()}\n\n${pointer}`;
122
+ return `${continuation.slice(0, firstNewline).trimEnd()}\n${pointer}${continuation.slice(firstNewline)}`;
123
+ }
124
+
125
+ function createContinuationRecoveryMessage(agentId: string, generation: number, continuation: string, now: number) {
126
+ const idempotencyKey = `self-resume-continuation:${agentId}:${generation}`;
127
+ const existing = findMessageByIdempotencyKey("system", idempotencyKey);
128
+ if (existing) return existing;
129
+ return sendMessage({
130
+ from: "system",
131
+ to: agentId,
132
+ kind: "system",
133
+ subject: `Self-resume continuation generation ${generation}`,
134
+ body: continuation,
135
+ replyExpected: false,
136
+ idempotencyKey,
137
+ maxAgeSeconds: null,
138
+ occurredAt: now,
139
+ payload: {
140
+ kind: "agent.self_resume_continuation",
141
+ agentId,
142
+ generation,
143
+ fullEnvelope: true,
144
+ },
145
+ });
146
+ }
147
+
109
148
  // #1023 — the ruledOut ledger bound (see updateContinuationAfterCompact) drops the
110
149
  // oldest entries once the persisted ledger grows past its cap instead of letting a
111
150
  // long-running agent render a multi-megabyte continuation. Dropped entries are not
@@ -142,6 +181,7 @@ interface PendingInjection {
142
181
  continuation: string;
143
182
  mode: string;
144
183
  generation?: number;
184
+ fullContextMessageId?: number;
145
185
  }
146
186
 
147
187
  function extractPendingInjection(command: Command): PendingInjection | null {
@@ -158,7 +198,8 @@ function extractPendingInjection(command: Command): PendingInjection | null {
158
198
  : undefined;
159
199
  if (!continuation) return null;
160
200
  const generation = typeof resume?.generation === "number" ? resume.generation : undefined;
161
- return { continuation, mode, generation };
201
+ const fullContextMessageId = typeof resume?.fullContextMessageId === "number" ? resume.fullContextMessageId : undefined;
202
+ return { continuation, mode, generation, fullContextMessageId };
162
203
  }
163
204
 
164
205
  function buildInjectionParams(command: Command, injection: PendingInjection): Record<string, unknown> {
@@ -168,6 +209,7 @@ function buildInjectionParams(command: Command, injection: PendingInjection): Re
168
209
  selfResume: {
169
210
  generation: injection.generation,
170
211
  mode: injection.mode,
212
+ fullContextMessageId: injection.fullContextMessageId,
171
213
  ...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
172
214
  ...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
173
215
  },
@@ -376,7 +418,7 @@ function hardLimit(limit: number, multiplier: number): number {
376
418
  return Math.ceil(limit * multiplier);
377
419
  }
378
420
 
379
- function createNativeCompact(agentId: string, generation: number, continuation: string): Command {
421
+ function createNativeCompact(agentId: string, generation: number, continuation: string, fullContextMessageId: number): Command {
380
422
  return createCommand({
381
423
  type: "agent.compact",
382
424
  source: "system",
@@ -384,19 +426,19 @@ function createNativeCompact(agentId: string, generation: number, continuation:
384
426
  params: {
385
427
  reason: "self-resume",
386
428
  instructions: continuation,
387
- selfResume: { generation, mode: "native", continuation },
429
+ selfResume: { generation, mode: "native", continuation, fullContextMessageId },
388
430
  },
389
431
  });
390
432
  }
391
433
 
392
- function createClearThenInject(agentId: string, generation: number, continuation: string): Command {
434
+ function createClearThenInject(agentId: string, generation: number, continuation: string, fullContextMessageId: number): Command {
393
435
  return createCommand({
394
436
  type: "agent.clearContext",
395
437
  source: "system",
396
438
  target: agentId,
397
439
  params: {
398
440
  reason: "self-resume",
399
- selfResume: { generation, mode: "clear-inject", continuation },
441
+ selfResume: { generation, mode: "clear-inject", continuation, fullContextMessageId },
400
442
  },
401
443
  });
402
444
  }
@@ -0,0 +1,59 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { getCommand, updateCommand } from "../commands-db";
3
+ import { emitCommandEvent } from "../command-events";
4
+ import { noteCompactionCommandCompleted } from "../compaction-watch";
5
+ import { applyCommandToRecipe } from "../recipe-runner";
6
+ import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
7
+ import { isComponentAuthorizedFor } from "../security";
8
+ import type { Command, ComponentToken } from "../types";
9
+ import { commandAuthorizationResource } from "./dispatch-command";
10
+ import { prepareScheduledCommandResultForStorage, settleScheduledCommandTimer } from "./scheduler-command";
11
+
12
+ type BusCommandUpdateResult = {
13
+ status: "succeeded" | "failed" | "rejected";
14
+ result?: Record<string, unknown>;
15
+ error?: string;
16
+ };
17
+
18
+ export async function updateCommandFromBus(input: {
19
+ target: string;
20
+ params: Record<string, unknown>;
21
+ componentAuth?: ComponentToken;
22
+ }): Promise<BusCommandUpdateResult> {
23
+ const command = getCommand(input.target);
24
+ if (!command) return { status: "failed", error: "command not found" };
25
+ if (!busCommandAuthorized(input.componentAuth, command)) {
26
+ return { status: "rejected", error: "component token lacks command scope" };
27
+ }
28
+ const status = typeof input.params.status === "string" ? input.params.status : undefined;
29
+ let result = isRecord(input.params.result) ? input.params.result : undefined;
30
+ if (result && command.type === "scheduler.command") {
31
+ result = await prepareScheduledCommandResultForStorage(command, result);
32
+ }
33
+ const updated = updateCommand(command.id, {
34
+ status: status as any,
35
+ result,
36
+ error: typeof input.params.error === "string" ? input.params.error : undefined,
37
+ });
38
+ if (updated) {
39
+ applyCommandToRecipe(updated);
40
+ if (updated.type === "scheduler.command") {
41
+ await settleScheduledCommandTimer(updated);
42
+ }
43
+ emitCommandEvent(updated, `command.${updated.status}`);
44
+ noteCompactionCommandCompleted(updated.type, updated.status, updated.id, updated.target);
45
+ enqueueContinuationInjectionAfterSelfResume(updated);
46
+ }
47
+ return { status: "succeeded", result: updated ? { command: updated } : undefined };
48
+ }
49
+
50
+ function busCommandAuthorized(
51
+ auth: ComponentToken | undefined,
52
+ command: Pick<Command, "target" | "params">,
53
+ ): boolean {
54
+ if (!auth) return true;
55
+ return isComponentAuthorizedFor(auth, {
56
+ scope: "command:write",
57
+ resource: commandAuthorizationResource(command),
58
+ });
59
+ }
@@ -6,6 +6,7 @@ import {
6
6
  ownedIsolatedWorkspace,
7
7
  recordGate,
8
8
  recordTaskDeliverable,
9
+ setTaskStage,
9
10
  setTaskStatus,
10
11
  ValidationError,
11
12
  } from "../db";
@@ -62,7 +63,17 @@ function resolveTaskId(ctx: AuthContext, explicit: unknown): number {
62
63
  throw new ValidationError("caller is bound to multiple Tasks; pass taskId explicitly");
63
64
  }
64
65
 
65
- function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, taskId: number): WorkspaceRecord | undefined {
66
+ function canRebindToWorkspace(ctx: AuthContext, task: TaskDetail, ws: WorkspaceRecord): boolean {
67
+ const caller = ctx.callerAgentId;
68
+ if (!caller || ws.ownerAgentId !== caller) return false;
69
+ return task.ownerAgentId === caller || agentBoundTaskIds(caller).has(task.id);
70
+ }
71
+
72
+ function shouldRebindToWorkspace(task: TaskDetail, ws: WorkspaceRecord | undefined): ws is WorkspaceRecord {
73
+ return Boolean(task.branch && ws?.branch && task.branch !== ws.branch);
74
+ }
75
+
76
+ function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, taskId: number, opts: { allowRebind?: boolean } = {}): WorkspaceRecord | undefined {
66
77
  const caller = ctx.callerAgentId;
67
78
  const explicit = input.workspaceId?.trim();
68
79
  const ws = explicit ? getWorkspace(explicit) : caller ? ownedIsolatedWorkspace(caller) : undefined;
@@ -73,11 +84,26 @@ function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, tas
73
84
  const task = getTaskDetail(taskId);
74
85
  if (!task) throw new ValidationError("task not found");
75
86
  if (task.branch && ws.branch && task.branch !== ws.branch) {
87
+ if (opts.allowRebind && canRebindToWorkspace(ctx, task, ws)) return ws;
76
88
  throw new ValidationError(`task ${taskId} is bound to branch ${task.branch}, not workspace branch ${ws.branch}`);
77
89
  }
78
90
  return ws;
79
91
  }
80
92
 
93
+ function rebindTaskToWorkspaceIfNeeded(task: TaskDetail, ws: WorkspaceRecord | undefined, actor: string): TaskDetail {
94
+ if (!shouldRebindToWorkspace(task, ws)) return task;
95
+ setTaskStatus(task.id, {
96
+ status: "in_progress",
97
+ note: `fix-forward rebind to workspace ${ws.id} (${ws.branch})`,
98
+ actor,
99
+ });
100
+ return setTaskStage(task.id, {
101
+ deliverableStage: "committed-on-branch",
102
+ branch: ws.branch,
103
+ actor,
104
+ });
105
+ }
106
+
81
107
  function gateWrites(gates: TaskDeliveryGateReports | undefined, deliveryKey: string): GateWrite[] {
82
108
  const writes: GateWrite[] = [];
83
109
  if (gates?.typecheck) writes.push({ gate: "typecheck", ...gatePayload(gates.typecheck, `${deliveryKey}:typecheck`) });
@@ -207,10 +233,11 @@ export async function deliverTask(input: DeliverTaskInput, ctx: AuthContext): Pr
207
233
  if (gateProblem) throw new ValidationError(`complete delivery cannot land without passing gate block (${gateProblem})`);
208
234
  const reason = input.disposition === "blocked" || input.disposition === "failed" ? cleanReason(input.reason, input.disposition) : undefined;
209
235
  const blockedBy = input.disposition === "blocked" ? cleanBlockedBy(input.blockedBy) : input.blockedBy;
210
- const workspace = wantsLand || input.workspaceId ? resolveWorkspace(ctx, input, taskId) : undefined;
236
+ const workspace = wantsLand || input.workspaceId ? resolveWorkspace(ctx, input, taskId, { allowRebind: wantsLand }) : undefined;
211
237
  if (wantsLand && (!workspace || workspace.mode !== "isolated" || !workspace.worktreePath)) {
212
238
  throw new ValidationError("complete delivery requires a live isolated workspace");
213
239
  }
240
+ const currentTask = wantsLand ? rebindTaskToWorkspaceIfNeeded(before, workspace, actor) : before;
214
241
  const landMeta: TaskDeliverableEventMetadata["land"] | undefined = wantsLand && workspace
215
242
  ? { requested: true, triggered: false, reason: "pending", workspaceId: workspace.id, workspaceStatus: workspace.status }
216
243
  : undefined;
@@ -236,7 +263,7 @@ export async function deliverTask(input: DeliverTaskInput, ctx: AuthContext): Pr
236
263
  } else if (isVerdictOnlyComplete) {
237
264
  setTaskStatus(taskId, { status: "done", note: "complete delivery recorded without land", actor });
238
265
  }
239
- const detail = getTaskDetail(taskId);
266
+ const detail = getTaskDetail(taskId) ?? currentTask;
240
267
  if (detail) {
241
268
  emitTaskChanged(detail, "task.deliverable");
242
269
  notifyDelivery(taskId, delivery.event.id, eventMeta ?? (delivery.event.metadata as unknown as TaskDeliverableEventMetadata));