agent-relay-server 0.29.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/public/index.html +257 -86
- package/runner/src/adapter.ts +1 -1
- package/runner/src/config.ts +1 -3
- package/src/agent-branch-state.ts +36 -0
- package/src/agent-ref.ts +18 -1
- package/src/branch-landed.ts +16 -2
- package/src/bus.ts +3 -21
- package/src/cli.ts +5 -1
- package/src/db.ts +46 -9
- package/src/maintenance.ts +16 -1
- package/src/mcp.ts +3 -2
- package/src/routes.ts +9 -3
- package/src/sse.ts +15 -27
- package/src/upgrade.ts +15 -4
- package/src/workspace-phase.ts +69 -1
package/runner/src/adapter.ts
CHANGED
|
@@ -174,7 +174,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
|
|
|
174
174
|
|
|
175
175
|
export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present you can stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
|
|
176
176
|
|
|
177
|
-
const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
|
|
177
|
+
export const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
|
|
178
178
|
|
|
179
179
|
function attachmentRefs(message: Message): Record<string, unknown>[] {
|
|
180
180
|
const payloadRefs = message.payload?.attachments;
|
package/runner/src/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, hostname } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import { stringValue } from "agent-relay-sdk";
|
|
4
|
+
import { DEFAULT_RELAY_URL, stringValue } from "agent-relay-sdk";
|
|
5
5
|
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
6
|
import type { ProviderConfig } from "./adapter";
|
|
7
7
|
|
|
@@ -15,8 +15,6 @@ interface LoadedProviderConfig extends ProviderConfig {
|
|
|
15
15
|
path: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const DEFAULT_RELAY_URL = "http://127.0.0.1:4850";
|
|
19
|
-
|
|
20
18
|
function agentRelayHome(): string {
|
|
21
19
|
return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
|
|
22
20
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Branch-state enrichment for the agent payload (#236). The single server-side
|
|
2
|
+
// place that hangs the derived `branchState` (+ owning `branchWorkspaceId`) onto an
|
|
3
|
+
// AgentCard before it leaves over the API or SSE — so the dashboard never recomputes
|
|
4
|
+
// it and the projection can't drift between surfaces. The mapping itself lives in
|
|
5
|
+
// deriveBranchState (workspace-phase.ts, pure & CLI-importable); this module is just
|
|
6
|
+
// the agent↔workspace join.
|
|
7
|
+
|
|
8
|
+
import { listWorkspaces, ownedIsolatedWorkspace } from "./db";
|
|
9
|
+
import type { AgentCard, WorkspaceRecord } from "./types";
|
|
10
|
+
import { deriveBranchState, isLiveIsolatedWorkspace } from "./workspace-phase";
|
|
11
|
+
|
|
12
|
+
function withWorkspace(agent: AgentCard, workspace: WorkspaceRecord | undefined): AgentCard {
|
|
13
|
+
const branchState = deriveBranchState(workspace);
|
|
14
|
+
if (!branchState || !workspace) return agent;
|
|
15
|
+
return { ...agent, branchState, branchWorkspaceId: workspace.id };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Enrich a single agent with its branch-state badge fields (one workspace query). */
|
|
19
|
+
export function attachBranchState(agent: AgentCard): AgentCard {
|
|
20
|
+
return withWorkspace(agent, ownedIsolatedWorkspace(agent.id));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Batched enrichment for list endpoints: one workspace scan, mapped by owner, so a
|
|
25
|
+
* fleet of N agents costs a single query instead of N. listWorkspaces is ORDER BY
|
|
26
|
+
* updated_at DESC, so the first live isolated workspace seen per owner is the most
|
|
27
|
+
* recent one — same selection as ownedIsolatedWorkspace.
|
|
28
|
+
*/
|
|
29
|
+
export function attachBranchStates(agents: AgentCard[]): AgentCard[] {
|
|
30
|
+
const byOwner = new Map<string, WorkspaceRecord>();
|
|
31
|
+
for (const ws of listWorkspaces()) {
|
|
32
|
+
if (!ws.ownerAgentId || !isLiveIsolatedWorkspace(ws)) continue;
|
|
33
|
+
if (!byOwner.has(ws.ownerAgentId)) byOwner.set(ws.ownerAgentId, ws);
|
|
34
|
+
}
|
|
35
|
+
return agents.map((agent) => withWorkspace(agent, byOwner.get(agent.id)));
|
|
36
|
+
}
|
package/src/agent-ref.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// never silently picks among several — it reports candidates instead.
|
|
13
13
|
|
|
14
14
|
import { STALE_TTL_MS } from "./config";
|
|
15
|
-
import type { AgentCard } from "./types";
|
|
15
|
+
import type { AgentCard, Message } from "./types";
|
|
16
16
|
|
|
17
17
|
interface ResolveOptions {
|
|
18
18
|
/** Exclude this agent id from matches (e.g. the requester, when pairing). */
|
|
@@ -121,6 +121,23 @@ export function resolveAgentRef(ref: string, agents: AgentCard[], opts: ResolveO
|
|
|
121
121
|
return { status: "not_found", offlineMatches: matches };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
export function targetMatchesAgent(target: string, agentId: string, agent?: AgentCard | null): boolean {
|
|
125
|
+
if (!agent) return false;
|
|
126
|
+
if (target === agentId || target === "broadcast") return true;
|
|
127
|
+
if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
|
|
128
|
+
if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
|
|
129
|
+
if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function messageMatchesAgent(msg: Message, agentId: string, agent?: AgentCard | null): boolean {
|
|
134
|
+
if (!agent) return false;
|
|
135
|
+
if (msg.claimable && msg.claimedBy && msg.claimedBy !== agentId) return false;
|
|
136
|
+
if (msg.resolvedToAgent === agentId) return true;
|
|
137
|
+
if (msg.to === agentId || msg.from === agentId) return true;
|
|
138
|
+
return targetMatchesAgent(msg.to, agentId, agent);
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
// --- messaging send planning -------------------------------------------------
|
|
125
142
|
|
|
126
143
|
export interface DeliveryReceipt {
|
package/src/branch-landed.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface BranchLandedInput {
|
|
|
18
18
|
subject?: string;
|
|
19
19
|
/** Fresh branch the worktree was recycled onto (land-and-continue), if any. */
|
|
20
20
|
newBranch?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the advanced base was pushed to its remote. `false` means the land is
|
|
23
|
+
* local-only (push disabled, or the base has no upstream) and has NOT reached
|
|
24
|
+
* `origin` yet — the notice says so instead of implying a published merge (#285).
|
|
25
|
+
* `undefined` from older orchestrators that don't report it → message stays generic.
|
|
26
|
+
*/
|
|
27
|
+
pushed?: boolean;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
|
@@ -47,6 +54,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
47
54
|
subject: input.subject,
|
|
48
55
|
author: workspace.ownerAgentId,
|
|
49
56
|
newBranch: input.newBranch,
|
|
57
|
+
pushed: input.pushed,
|
|
50
58
|
},
|
|
51
59
|
});
|
|
52
60
|
|
|
@@ -65,8 +73,14 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
65
73
|
sha: input.mergedSha,
|
|
66
74
|
author,
|
|
67
75
|
newBranch: input.newBranch,
|
|
76
|
+
pushed: input.pushed,
|
|
68
77
|
};
|
|
69
78
|
|
|
79
|
+
// A local-only land (push disabled, or base has no upstream) has NOT reached the
|
|
80
|
+
// remote — say so rather than implying a published merge an agent would report as
|
|
81
|
+
// shipped (#285). `undefined` (older orchestrators) keeps the message generic.
|
|
82
|
+
const publishLabel = input.pushed === false ? ` Not yet pushed to \`origin/${base}\` — local only.` : "";
|
|
83
|
+
|
|
70
84
|
// The branch author cares most — push regardless of online (store-ahead delivers it on
|
|
71
85
|
// next poll if they've moved on, #234). They land-and-continue onto the recycled branch.
|
|
72
86
|
if (author) {
|
|
@@ -76,7 +90,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
76
90
|
: " Worktree reclaimed.";
|
|
77
91
|
notifySystemMessage(author, {
|
|
78
92
|
subject: "Your branch landed",
|
|
79
|
-
body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${continueLabel}`,
|
|
93
|
+
body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}${continueLabel}`,
|
|
80
94
|
payload,
|
|
81
95
|
replyExpected: false,
|
|
82
96
|
});
|
|
@@ -92,7 +106,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
92
106
|
for (const agent of agentsOnMain(workspace.repoRoot, author)) {
|
|
93
107
|
notifySystemMessage(agent.id, {
|
|
94
108
|
subject: `Merged to ${base}`,
|
|
95
|
-
body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}
|
|
109
|
+
body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
|
|
96
110
|
payload,
|
|
97
111
|
replyExpected: false,
|
|
98
112
|
});
|
package/src/bus.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { emitCommandEvent } from "./command-events";
|
|
|
7
7
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
8
8
|
import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
|
|
9
9
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
10
|
+
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
10
11
|
import {
|
|
11
12
|
BusProtocolError,
|
|
12
13
|
parseBusFrame,
|
|
@@ -465,11 +466,11 @@ function broadcastRelayEvent(event: RelayEvent): void {
|
|
|
465
466
|
function connectionWantsEvent(conn: BusConnection, event: RelayEvent): boolean {
|
|
466
467
|
if (!matchesSubscription(conn.subscriptions, event.type)) return false;
|
|
467
468
|
if (event.type === "message.new" && conn.agentId) {
|
|
468
|
-
return messageMatchesAgent(event.data as unknown as Message, conn.agentId);
|
|
469
|
+
return messageMatchesAgent(event.data as unknown as Message, conn.agentId, getAgent(conn.agentId));
|
|
469
470
|
}
|
|
470
471
|
if (event.type.startsWith("task.") && conn.agentId) {
|
|
471
472
|
const target = typeof event.data.target === "string" ? event.data.target : "";
|
|
472
|
-
return targetMatchesAgent(target, conn.agentId);
|
|
473
|
+
return targetMatchesAgent(target, conn.agentId, getAgent(conn.agentId));
|
|
473
474
|
}
|
|
474
475
|
if (event.type.startsWith("command.")) {
|
|
475
476
|
const command = isRecord(event.data.command) ? event.data.command : undefined;
|
|
@@ -490,25 +491,6 @@ function matchesSubscription(subscriptions: Set<string>, eventType: string): boo
|
|
|
490
491
|
return false;
|
|
491
492
|
}
|
|
492
493
|
|
|
493
|
-
function messageMatchesAgent(msg: Message, agentId: string): boolean {
|
|
494
|
-
const agent = getAgent(agentId);
|
|
495
|
-
if (!agent) return false;
|
|
496
|
-
if (msg.claimable && msg.claimedBy && msg.claimedBy !== agentId) return false;
|
|
497
|
-
if (msg.resolvedToAgent === agentId) return true;
|
|
498
|
-
if (msg.to === agentId || msg.from === agentId) return true;
|
|
499
|
-
return targetMatchesAgent(msg.to, agentId, agent);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function targetMatchesAgent(target: string, agentId: string, knownAgent?: AgentCard): boolean {
|
|
503
|
-
const agent = knownAgent ?? getAgent(agentId);
|
|
504
|
-
if (!agent) return false;
|
|
505
|
-
if (target === agentId || target === "broadcast") return true;
|
|
506
|
-
if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
|
|
507
|
-
if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
|
|
508
|
-
if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
|
|
509
|
-
return false;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
494
|
function outboxToPayload(event: BusEvent) {
|
|
513
495
|
return {
|
|
514
496
|
seq: event.seq,
|
package/src/cli.ts
CHANGED
|
@@ -143,7 +143,8 @@ Upgrade options:
|
|
|
143
143
|
--providers LIST Provider integrations to upgrade: auto, all, codex, claude, orchestrator
|
|
144
144
|
--host ID Upgrade a remote orchestrator host over the relay (repeatable). Skips the local upgrade
|
|
145
145
|
--all-hosts Upgrade this host, then fan out to every connected remote host that is behind
|
|
146
|
-
--no-restart Do not restart agent-relay.service
|
|
146
|
+
--no-restart Do not restart agent-relay.service (warns you to restart it manually)
|
|
147
|
+
--restart-deferred Like --no-restart, but the caller restarts the services itself; suppresses the manual-restart warning (used by the release script)
|
|
147
148
|
--dry-run Print detected state and planned commands
|
|
148
149
|
--yes Skip confirmation prompts
|
|
149
150
|
|
|
@@ -402,6 +403,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
|
|
|
402
403
|
let targetVersion: string | undefined;
|
|
403
404
|
let dryRun = false;
|
|
404
405
|
let noRestart = false;
|
|
406
|
+
let restartDeferred = false;
|
|
405
407
|
let yes = false;
|
|
406
408
|
let json = false;
|
|
407
409
|
let runtimePrefix: string | undefined;
|
|
@@ -425,6 +427,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
|
|
|
425
427
|
else if (arg === "--all") providers.push("all");
|
|
426
428
|
else if (arg === "--dry-run") dryRun = true;
|
|
427
429
|
else if (arg === "--no-restart") noRestart = true;
|
|
430
|
+
else if (arg === "--restart-deferred") restartDeferred = true;
|
|
428
431
|
else if (arg === "--yes" || arg === "-y") yes = true;
|
|
429
432
|
else if (arg === "--json") json = true;
|
|
430
433
|
else throw new Error(`Unknown upgrade option "${arg}"`);
|
|
@@ -449,6 +452,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
|
|
|
449
452
|
...(runtimePrefix ? { runtimePrefix } : {}),
|
|
450
453
|
providers,
|
|
451
454
|
noRestart,
|
|
455
|
+
restartDeferred,
|
|
452
456
|
});
|
|
453
457
|
|
|
454
458
|
if (json) {
|
package/src/db.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
|
|
4
4
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./config.ts";
|
|
5
5
|
import { parseJson } from "./utils";
|
|
6
|
+
import { isLiveIsolatedWorkspace } from "./workspace-phase";
|
|
6
7
|
import {
|
|
7
8
|
CONTRACT_REQUIREMENTS,
|
|
8
9
|
contractCompatibility,
|
|
@@ -5281,20 +5282,24 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
|
|
|
5281
5282
|
// preserved and metadata is merged, not replaced.
|
|
5282
5283
|
const existing = getWorkspace(workspace.id);
|
|
5283
5284
|
const preserveStatus = existing != null && existing.status !== "active";
|
|
5284
|
-
// The branch
|
|
5285
|
-
//
|
|
5286
|
-
// runner keeps re-reporting its spawn-time
|
|
5287
|
-
// recycle returns status to "active" — so
|
|
5288
|
-
// the repoint back to the original branch,
|
|
5289
|
-
// branch and strands the work (vent #62 follow-up).
|
|
5290
|
-
//
|
|
5285
|
+
// The branch and base change ONLY via the relay's own land-and-continue recycle
|
|
5286
|
+
// (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha; base_ref is fixed
|
|
5287
|
+
// at spawn and never re-targeted). The runner keeps re-reporting its spawn-time
|
|
5288
|
+
// branch/base on every heartbeat, and the recycle returns status to "active" — so
|
|
5289
|
+
// without this the next heartbeat clobbers the repoint back to the original branch,
|
|
5290
|
+
// and the next land targets a deleted branch and strands the work (vent #62 follow-up).
|
|
5291
|
+
// baseRef needs the same pin: a heartbeat carrying a stale/wrong base (e.g. a recycled
|
|
5292
|
+
// `-N` branch when the agent was spawned from inside a managed worktree) would otherwise
|
|
5293
|
+
// overwrite the true base, and the next land advances a local-only branch that never
|
|
5294
|
+
// reaches origin/main (#285). Trust the existing row's branch/base over registration;
|
|
5295
|
+
// only a brand-new row takes the runner's values.
|
|
5291
5296
|
return upsertWorkspace({
|
|
5292
5297
|
id: workspace.id,
|
|
5293
5298
|
repoRoot: workspace.repoRoot,
|
|
5294
5299
|
sourceCwd: workspace.sourceCwd ?? agent.cwd,
|
|
5295
5300
|
worktreePath: workspace.worktreePath,
|
|
5296
5301
|
branch: existing?.branch ?? workspace.branch,
|
|
5297
|
-
baseRef: workspace.baseRef,
|
|
5302
|
+
baseRef: existing?.baseRef ?? workspace.baseRef,
|
|
5298
5303
|
baseSha: existing?.baseSha ?? workspace.baseSha,
|
|
5299
5304
|
mode: workspace.mode,
|
|
5300
5305
|
requestedMode: workspace.requestedMode,
|
|
@@ -5394,6 +5399,14 @@ export function deleteWorkspace(id: string): boolean {
|
|
|
5394
5399
|
return db.query("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
|
|
5395
5400
|
}
|
|
5396
5401
|
|
|
5402
|
+
// The agent's current branch worktree: its most recent live (non-terminal) isolated
|
|
5403
|
+
// workspace, or undefined. SINGLE HOME for the agent→workspace link — the MCP
|
|
5404
|
+
// owner-resolver and the #236 branch-state badge both go through here. listWorkspaces
|
|
5405
|
+
// is ORDER BY updated_at DESC, so `.find` returns the most recently active one.
|
|
5406
|
+
export function ownedIsolatedWorkspace(agentId: string): WorkspaceRecord | undefined {
|
|
5407
|
+
return listWorkspaces({ ownerAgentId: agentId }).find(isLiveIsolatedWorkspace);
|
|
5408
|
+
}
|
|
5409
|
+
|
|
5397
5410
|
// Shared-mode rows are pure occupancy markers (no worktree on disk) that only
|
|
5398
5411
|
// mean something while their owner is online. Deletion is normally driven by
|
|
5399
5412
|
// the reaper's onAgentDisappeared hook, but agents also leave via clean
|
|
@@ -5416,6 +5429,26 @@ export function pruneOrphanedSharedWorkspaces(): string[] {
|
|
|
5416
5429
|
})();
|
|
5417
5430
|
}
|
|
5418
5431
|
|
|
5432
|
+
// Late-bound listeners for "a workspace row changed in a way that may change its
|
|
5433
|
+
// owner's branch-state badge" (#236). A hook, not a direct emit, because db.ts is
|
|
5434
|
+
// the lowest layer — sse.ts imports db, so db can't import sse without a cycle. sse
|
|
5435
|
+
// registers a listener at startup that re-emits the owner agent's status over SSE.
|
|
5436
|
+
type WorkspaceChangeListener = (workspace: WorkspaceRecord) => void;
|
|
5437
|
+
const workspaceChangeListeners = new Set<WorkspaceChangeListener>();
|
|
5438
|
+
export function onWorkspaceChange(listener: WorkspaceChangeListener): void {
|
|
5439
|
+
workspaceChangeListeners.add(listener);
|
|
5440
|
+
}
|
|
5441
|
+
export function emitWorkspaceChange(workspace: WorkspaceRecord | null | undefined): void {
|
|
5442
|
+
if (!workspace) return;
|
|
5443
|
+
for (const listener of workspaceChangeListeners) {
|
|
5444
|
+
try {
|
|
5445
|
+
listener(workspace);
|
|
5446
|
+
} catch {
|
|
5447
|
+
// A badge-refresh listener must never break a workspace write.
|
|
5448
|
+
}
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
|
|
5419
5452
|
export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metadata: Record<string, unknown> = {}): WorkspaceRecord | null {
|
|
5420
5453
|
const existing = getWorkspace(id);
|
|
5421
5454
|
if (!existing) return null;
|
|
@@ -5434,7 +5467,11 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
|
|
|
5434
5467
|
id,
|
|
5435
5468
|
);
|
|
5436
5469
|
electWorkspaceStewards(existing.repoRoot);
|
|
5437
|
-
|
|
5470
|
+
const updated = getWorkspace(id);
|
|
5471
|
+
// Every status transition can flip the owner's badge (active→ready→steward→merged),
|
|
5472
|
+
// so refresh it regardless of caller. Fires on the row's CURRENT owner.
|
|
5473
|
+
emitWorkspaceChange(updated);
|
|
5474
|
+
return updated;
|
|
5438
5475
|
}
|
|
5439
5476
|
|
|
5440
5477
|
// Repoint a workspace row at a recycled branch after a land-and-continue merge
|
package/src/maintenance.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
pruneOfflineAgents,
|
|
20
20
|
pruneOldMessages,
|
|
21
21
|
deleteWorkspace,
|
|
22
|
+
emitWorkspaceChange,
|
|
22
23
|
pruneOrphanedSharedWorkspaces,
|
|
23
24
|
reapStaleAgents,
|
|
24
25
|
reapStaleOrchestrators,
|
|
@@ -34,7 +35,7 @@ import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./
|
|
|
34
35
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
35
36
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
36
37
|
import { reapOrphanedWorktrees } from "./workspace-orphans";
|
|
37
|
-
import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
38
|
+
import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
38
39
|
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
39
40
|
import { getStewardConfig } from "./config-store";
|
|
40
41
|
import { ensureRepoSteward } from "./steward";
|
|
@@ -583,6 +584,20 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
583
584
|
|
|
584
585
|
const meta = ws.metadata as Record<string, unknown>;
|
|
585
586
|
|
|
587
|
+
// #236: stash the latest ahead/dirty counts so deriveBranchState can tell idle
|
|
588
|
+
// (⚪) from changes (🟡) for an active worktree — the relay isn't in the git
|
|
589
|
+
// path, so this scan is its only window onto the tree. Only write (and refresh
|
|
590
|
+
// the owner's badge) when the derived state actually flips, to avoid SSE churn
|
|
591
|
+
// every 2 min on unchanged workspaces. Status-changing branches below own their
|
|
592
|
+
// own badge refresh via updateWorkspaceStatus.
|
|
593
|
+
const nextAhead = p.ahead ?? 0;
|
|
594
|
+
const nextDirty = p.dirtyCount ?? 0;
|
|
595
|
+
if (meta.gitAhead !== nextAhead || meta.gitDirtyCount !== nextDirty) {
|
|
596
|
+
const before = deriveBranchState(ws);
|
|
597
|
+
const patched = patchWorkspaceMetadata(ws.id, { gitAhead: nextAhead, gitDirtyCount: nextDirty, gitProbedAt: Date.now() });
|
|
598
|
+
if (patched && deriveBranchState(patched) !== before) emitWorkspaceChange(patched);
|
|
599
|
+
}
|
|
600
|
+
|
|
586
601
|
// Landing wins over everything else. Once the work is in base — whether the
|
|
587
602
|
// PR was squash/cherry-pick merged on GitHub or fast-forwarded locally — the
|
|
588
603
|
// workspace is done, even if `git merge-tree` still predicts a textual
|
package/src/mcp.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
linkArtifact,
|
|
24
24
|
listAgents,
|
|
25
25
|
listWorkspaces,
|
|
26
|
+
ownedIsolatedWorkspace,
|
|
26
27
|
searchAgents,
|
|
27
28
|
type AgentSearchFilter,
|
|
28
29
|
type AgentSearchSort,
|
|
@@ -45,7 +46,7 @@ import {
|
|
|
45
46
|
} from "./security";
|
|
46
47
|
import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
47
48
|
import { applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
48
|
-
import { describeWorkspacePhase, landReceipt, readyContract,
|
|
49
|
+
import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
|
|
49
50
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
50
51
|
import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
|
|
51
52
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
@@ -964,7 +965,7 @@ function resolveWorkspaceForCaller(auth: McpAuthContext, args: Record<string, un
|
|
|
964
965
|
function callerIsolatedWorkspace(auth: McpAuthContext): WorkspaceRecord | undefined {
|
|
965
966
|
const caller = callerAgentId(auth);
|
|
966
967
|
if (!caller) return undefined;
|
|
967
|
-
return
|
|
968
|
+
return ownedIsolatedWorkspace(caller);
|
|
968
969
|
}
|
|
969
970
|
|
|
970
971
|
async function relayWorkspaceStatus(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
package/src/routes.ts
CHANGED
|
@@ -160,6 +160,7 @@ import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES
|
|
|
160
160
|
import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
|
|
161
161
|
import { listHostDirectories } from "./agent-spawn";
|
|
162
162
|
import { planSend } from "./agent-ref";
|
|
163
|
+
import { attachBranchState, attachBranchStates } from "./agent-branch-state";
|
|
163
164
|
import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
|
|
164
165
|
import type { ProviderConfig } from "../runner/src/adapter";
|
|
165
166
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
@@ -1327,7 +1328,7 @@ const getAgents: Handler = (req) => {
|
|
|
1327
1328
|
const tag = url.searchParams.get("tag") ?? undefined;
|
|
1328
1329
|
const machine = url.searchParams.get("machine") ?? undefined;
|
|
1329
1330
|
const status = url.searchParams.get("status") ?? undefined;
|
|
1330
|
-
return json(listAgents({ tag, machine, status }));
|
|
1331
|
+
return json(attachBranchStates(listAgents({ tag, machine, status })));
|
|
1331
1332
|
};
|
|
1332
1333
|
|
|
1333
1334
|
const findAgents: Handler = (req) => {
|
|
@@ -1361,7 +1362,7 @@ const postRouteAdvice: Handler = async (req) => {
|
|
|
1361
1362
|
|
|
1362
1363
|
const getAgentById: Handler = (_req, params) => {
|
|
1363
1364
|
const agent = getAgent(params.id!);
|
|
1364
|
-
return agent ? json(agent) : error("agent not found", 404);
|
|
1365
|
+
return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
|
|
1365
1366
|
};
|
|
1366
1367
|
|
|
1367
1368
|
const getAgentReplyObligations: Handler = (_req, params) => {
|
|
@@ -4495,8 +4496,12 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4495
4496
|
// Repoint the row so the next merge targets the live branch, not the deleted one.
|
|
4496
4497
|
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
4497
4498
|
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
4499
|
+
// base_sha tracks the tip the recycled workspace forks from. On a no-ff land
|
|
4500
|
+
// (#287) that's the merge commit (result.baseSha), not the preserved landed
|
|
4501
|
+
// commit (mergedSha); fall back to mergedSha for older orchestrators.
|
|
4502
|
+
const baseSha = cleanString(command.result.baseSha, "result.baseSha", { max: 64 });
|
|
4498
4503
|
if (newBranch) {
|
|
4499
|
-
setWorkspaceBranch(workspaceId, newBranch, mergedSha);
|
|
4504
|
+
setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
|
|
4500
4505
|
}
|
|
4501
4506
|
// #239 — push the author a "your branch landed" notice (no polling). Only on a
|
|
4502
4507
|
// real land; a no-op resolution (#230) merged nothing, so it earns no notice.
|
|
@@ -4506,6 +4511,7 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4506
4511
|
mergedSha,
|
|
4507
4512
|
subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
|
|
4508
4513
|
newBranch,
|
|
4514
|
+
pushed: typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
|
|
4509
4515
|
});
|
|
4510
4516
|
}
|
|
4511
4517
|
}
|
package/src/sse.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { getAgent, getOrchestrator } from "./db";
|
|
1
|
+
import { getAgent, getOrchestrator, onWorkspaceChange } from "./db";
|
|
2
|
+
import { attachBranchState } from "./agent-branch-state";
|
|
2
3
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
4
|
+
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
3
5
|
import type { ActivityEvent, AgentCard, Message, RelayNotification, Task } from "./types";
|
|
4
6
|
import { isRecord } from "agent-relay-sdk";
|
|
5
7
|
|
|
@@ -68,18 +70,6 @@ function send(conn: Connection, event: string, data: unknown) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
function messageMatchesAgent(msg: Message, agentId: string): boolean {
|
|
72
|
-
const agent = getAgent(agentId);
|
|
73
|
-
if (!agent) return false;
|
|
74
|
-
if (msg.resolvedToAgent === agentId) return true;
|
|
75
|
-
if (msg.to === agentId || msg.from === agentId) return true;
|
|
76
|
-
if (msg.to === "broadcast") return true;
|
|
77
|
-
if (msg.to.startsWith("tag:") && agent.tags.includes(msg.to.slice(4))) return true;
|
|
78
|
-
if (msg.to.startsWith("cap:") && agent.capabilities.includes(msg.to.slice(4))) return true;
|
|
79
|
-
if (msg.to.startsWith("label:") && agent.label === msg.to.slice(6)) return true;
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
73
|
function fanout(event: RelayEvent): void {
|
|
84
74
|
if (event.type === "message.new") {
|
|
85
75
|
sendNewMessage(event.data as unknown as Message);
|
|
@@ -100,8 +90,7 @@ function sendNewMessage(msg: Message): void {
|
|
|
100
90
|
send(conn, "message.new", msg);
|
|
101
91
|
continue;
|
|
102
92
|
}
|
|
103
|
-
if (!messageMatchesAgent(msg, conn.agentId)) continue;
|
|
104
|
-
if (msg.claimable && msg.claimedBy && msg.claimedBy !== conn.agentId) continue;
|
|
93
|
+
if (!messageMatchesAgent(msg, conn.agentId, getAgent(conn.agentId))) continue;
|
|
105
94
|
send(conn, "message.new", msg);
|
|
106
95
|
}
|
|
107
96
|
}
|
|
@@ -114,12 +103,21 @@ export function emitNewMessage(msg: Message) {
|
|
|
114
103
|
|
|
115
104
|
export function emitAgentStatus(agentId: string) {
|
|
116
105
|
const agent = getAgent(agentId);
|
|
117
|
-
|
|
106
|
+
// Enrich with the branch-state badge fields (#236) so the dashboard's live agent
|
|
107
|
+
// updates carry the same projection as GET /api/agents.
|
|
108
|
+
const data = agent ? attachBranchState(agent) : { id: agentId, status: "offline" };
|
|
118
109
|
emitRelayEvent({ type: "agent.status", source: "server", subject: agentId, data: data as unknown as Record<string, unknown> });
|
|
119
110
|
const notification = agent ? notificationForAgentStatus(agent) : undefined;
|
|
120
111
|
if (notification) emitNotificationCreated(notification);
|
|
121
112
|
}
|
|
122
113
|
|
|
114
|
+
// A workspace row changed (status transition or an idle↔changes git snapshot) →
|
|
115
|
+
// re-emit its owner's agent.status so the branch-state badge updates live (#236).
|
|
116
|
+
// Registered against the db hook to avoid a db→sse import cycle.
|
|
117
|
+
onWorkspaceChange((workspace) => {
|
|
118
|
+
if (workspace.ownerAgentId) emitAgentStatus(workspace.ownerAgentId);
|
|
119
|
+
});
|
|
120
|
+
|
|
123
121
|
export function emitAgentRemoved(agentId: string) {
|
|
124
122
|
emitRelayEvent({ type: "agent.removed", source: "server", subject: agentId, data: { id: agentId } });
|
|
125
123
|
}
|
|
@@ -165,7 +163,7 @@ export function emitMessageReactionUpdated(msg: Message) {
|
|
|
165
163
|
|
|
166
164
|
function sendTaskChanged(task: Task, eventType = "task.updated"): void {
|
|
167
165
|
for (const conn of connections.values()) {
|
|
168
|
-
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
|
|
166
|
+
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, getAgent(conn.agentId))) continue;
|
|
169
167
|
send(conn, eventType, task);
|
|
170
168
|
}
|
|
171
169
|
}
|
|
@@ -194,16 +192,6 @@ export function getConnectionCount(): number {
|
|
|
194
192
|
return connections.size;
|
|
195
193
|
}
|
|
196
194
|
|
|
197
|
-
function targetMatchesAgent(target: string, agentId: string): boolean {
|
|
198
|
-
const agent = getAgent(agentId);
|
|
199
|
-
if (!agent) return false;
|
|
200
|
-
if (target === agentId || target === "broadcast") return true;
|
|
201
|
-
if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
|
|
202
|
-
if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
|
|
203
|
-
if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
195
|
export function emitOrchestratorStatus(orchestratorId: string) {
|
|
208
196
|
const orch = getOrchestrator(orchestratorId);
|
|
209
197
|
const data = orch ?? { id: orchestratorId, status: "offline" };
|
package/src/upgrade.ts
CHANGED
|
@@ -13,6 +13,13 @@ type UpgradeOptions = {
|
|
|
13
13
|
targetVersion?: string;
|
|
14
14
|
providers?: UpgradeProvider[];
|
|
15
15
|
noRestart?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Like `noRestart` (no restart action → no premature post-restart verify),
|
|
18
|
+
* but the caller restarts the services itself right after install (the
|
|
19
|
+
* release script does). Suppresses the "restart manually" warning, which is
|
|
20
|
+
* a false alarm in that flow — only the caller knows the restart is coming.
|
|
21
|
+
*/
|
|
22
|
+
restartDeferred?: boolean;
|
|
16
23
|
runtimePrefix?: string;
|
|
17
24
|
};
|
|
18
25
|
|
|
@@ -206,10 +213,14 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
206
213
|
warnings.push("Agent Relay orchestrator not detected; skipping orchestrator package upgrade.");
|
|
207
214
|
}
|
|
208
215
|
|
|
216
|
+
// `restartDeferred` means a caller restarts for us — skip the restart action
|
|
217
|
+
// (and thus the post-restart verify) exactly like `noRestart`, but without the
|
|
218
|
+
// "restart manually" warning, which would be a false alarm in that flow.
|
|
219
|
+
const deferRestart = Boolean(options.noRestart || options.restartDeferred);
|
|
209
220
|
const serverRestartNeeded = serverPackageUpdated || Boolean(snapshot.runningServerVersion && snapshot.runningServerVersion !== targetVersion);
|
|
210
221
|
if (snapshot.hasSystemdUserService && serverRestartNeeded) {
|
|
211
|
-
if (
|
|
212
|
-
warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
|
|
222
|
+
if (deferRestart) {
|
|
223
|
+
if (!options.restartDeferred) warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
|
|
213
224
|
} else {
|
|
214
225
|
actions.push({
|
|
215
226
|
label: "Restart Agent Relay service",
|
|
@@ -226,8 +237,8 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
226
237
|
Boolean((orch.version && orch.version !== targetVersion) || orch.health?.restartRequired)
|
|
227
238
|
);
|
|
228
239
|
if (snapshot.hasSystemdUserOrchestratorService && orchestratorRestartNeeded) {
|
|
229
|
-
if (
|
|
230
|
-
warnings.push("agent-relay-orchestrator.service detected but --no-restart was set; restart manually to run the upgraded orchestrator.");
|
|
240
|
+
if (deferRestart) {
|
|
241
|
+
if (!options.restartDeferred) warnings.push("agent-relay-orchestrator.service detected but --no-restart was set; restart manually to run the upgraded orchestrator.");
|
|
231
242
|
} else {
|
|
232
243
|
actions.push({
|
|
233
244
|
label: "Restart Agent Relay orchestrator service",
|