agent-relay-server 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/public/index.html +254 -71
- 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 +21 -4
- package/src/branch-landed.ts +16 -2
- package/src/bus.ts +3 -21
- package/src/cli.ts +3 -3
- package/src/contracts.ts +1 -1
- package/src/db.ts +51 -14
- package/src/http-body.ts +1 -1
- package/src/insights-db.ts +2 -2
- package/src/lifecycle-manager.ts +4 -0
- package/src/maintenance.ts +16 -1
- package/src/managed-policy.ts +1 -1
- package/src/mcp.ts +3 -2
- package/src/notify.ts +1 -1
- package/src/orchestrator-lookup.ts +1 -1
- package/src/routes.ts +31 -9
- package/src/runtime-tokens.ts +2 -2
- package/src/security.ts +8 -0
- package/src/spawn-command.ts +2 -2
- package/src/sse.ts +15 -27
- package/src/token-db.ts +3 -3
- package/src/upgrade.ts +1 -1
- package/src/workspace-actions.ts +5 -5
- package/src/workspace-claim.ts +2 -2
- package/src/workspace-merge.ts +2 -2
- package/src/workspace-orphans.ts +1 -1
- package/src/workspace-phase.ts +71 -3
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,16 +12,16 @@
|
|
|
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). */
|
|
19
19
|
excludeId?: string;
|
|
20
20
|
/** Clock injection for tests. */
|
|
21
21
|
now?: number;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
type ResolveResult =
|
|
25
25
|
| { status: "resolved"; agent: AgentCard }
|
|
26
26
|
| { status: "ambiguous"; candidates: AgentCard[] }
|
|
27
27
|
| { status: "not_found"; offlineMatches: AgentCard[] };
|
|
@@ -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 {
|
|
@@ -136,7 +153,7 @@ export interface DeliveryReceipt {
|
|
|
136
153
|
reason?: string;
|
|
137
154
|
}
|
|
138
155
|
|
|
139
|
-
|
|
156
|
+
type SendPlan =
|
|
140
157
|
// resolved/fan-out/passthrough carry the (possibly rewritten) canonical `to`
|
|
141
158
|
| { kind: "direct" | "fanout" | "passthrough"; to: string; receipt: DeliveryReceipt }
|
|
142
159
|
| { kind: "not_found"; message: string }
|
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
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
} from "./upgrade";
|
|
45
45
|
import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
|
|
46
46
|
import { MAX_BODY_BYTES, VERSION } from "./config";
|
|
47
|
-
import {
|
|
47
|
+
import { runContextProbe } from "agent-relay-sdk/context-probe";
|
|
48
48
|
import { shellQuote } from "agent-relay-sdk/shell-utils";
|
|
49
49
|
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
50
50
|
import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
|
|
@@ -686,7 +686,7 @@ async function handleContextProbeCommand(args: string[]): Promise<void> {
|
|
|
686
686
|
let wrapCommand: string | undefined;
|
|
687
687
|
let wrapRequested = false;
|
|
688
688
|
let agentId: string | undefined;
|
|
689
|
-
let stateDir
|
|
689
|
+
let stateDir: string | undefined;
|
|
690
690
|
let standalone = false;
|
|
691
691
|
|
|
692
692
|
for (let i = 0; i < inputArgs.length; i++) {
|
|
@@ -718,7 +718,7 @@ async function handleContextProbeCommand(args: string[]): Promise<void> {
|
|
|
718
718
|
"context-probe",
|
|
719
719
|
...(wrapRequested ? ["--wrap", ...(wrapCommand ? [shellQuote(wrapCommand)] : [])] : ["--standalone"]),
|
|
720
720
|
...(agentId ? ["--agent-id", shellQuote(agentId)] : []),
|
|
721
|
-
...(stateDir
|
|
721
|
+
...(stateDir ? ["--state-dir", shellQuote(stateDir)] : []),
|
|
722
722
|
].join(" ");
|
|
723
723
|
console.log(command);
|
|
724
724
|
return;
|
package/src/contracts.ts
CHANGED
package/src/db.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
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,
|
|
@@ -4331,7 +4332,7 @@ function messageRequiresReply(message: Message): boolean {
|
|
|
4331
4332
|
// Server-owned notification flag (#283) wins over every kind/sender heuristic below: an
|
|
4332
4333
|
// explicit replyExpected:false is a fire-and-forget message that must never become an obligation.
|
|
4333
4334
|
if (message.replyExpected === false) return false;
|
|
4334
|
-
if (message.kind
|
|
4335
|
+
if (isMechanicalMessageKind(message.kind)) return false;
|
|
4335
4336
|
if (message.from === "user") return true;
|
|
4336
4337
|
if (message.kind === "task" || message.kind === "channel.event") return true;
|
|
4337
4338
|
return Boolean(message.payload?.source);
|
|
@@ -4793,7 +4794,7 @@ export type AnalyticsPeriod = keyof typeof ANALYTICS_PERIODS;
|
|
|
4793
4794
|
// Message → category, the ONE place this mapping lives (server-side SQL). Order is
|
|
4794
4795
|
// significant: a claimable/system/pair/channel message is classified as such even
|
|
4795
4796
|
// when it is also a reply; only an otherwise-plain reply counts as "Replies".
|
|
4796
|
-
|
|
4797
|
+
const ANALYTICS_CATEGORIES = ["Messages", "Replies", "Work items", "System", "Pair", "Channel"] as const;
|
|
4797
4798
|
export type AnalyticsCategory = (typeof ANALYTICS_CATEGORIES)[number];
|
|
4798
4799
|
const ANALYTICS_CATEGORY_SQL = `
|
|
4799
4800
|
CASE
|
|
@@ -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
|
|
@@ -5453,7 +5490,7 @@ export function setWorkspaceBranch(id: string, branch: string, baseSha?: string)
|
|
|
5453
5490
|
// of these is a candidate steward; the repo is worth coordinating.
|
|
5454
5491
|
const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
|
|
5455
5492
|
|
|
5456
|
-
|
|
5493
|
+
interface RepoStewardRecord {
|
|
5457
5494
|
repoRoot: string;
|
|
5458
5495
|
stewardAgentId?: string;
|
|
5459
5496
|
lastStewardAgentId?: string;
|
|
@@ -5570,7 +5607,7 @@ function electWorkspaceStewardsForAgent(agentId: string): void {
|
|
|
5570
5607
|
|
|
5571
5608
|
// --- Per-repo merge serialization lease (issue #157) -----------------------
|
|
5572
5609
|
|
|
5573
|
-
|
|
5610
|
+
interface MergeLeaseRecord {
|
|
5574
5611
|
repoRoot: string;
|
|
5575
5612
|
workspaceId: string;
|
|
5576
5613
|
commandId?: string;
|
package/src/http-body.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Concatenate body chunks into a single contiguous Uint8Array. */
|
|
2
|
-
|
|
2
|
+
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
|
3
3
|
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
4
4
|
const output = new Uint8Array(total);
|
|
5
5
|
let offset = 0;
|
package/src/insights-db.ts
CHANGED
|
@@ -46,7 +46,7 @@ function rowToObservation(row: ObservationRow): InsightObservation {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
interface RecordObservationInput {
|
|
50
50
|
sessionId: string;
|
|
51
51
|
agentId?: string;
|
|
52
52
|
project?: string;
|
|
@@ -99,7 +99,7 @@ export function getObservation(id: number): InsightObservation | null {
|
|
|
99
99
|
return row ? rowToObservation(row) : null;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
interface ListObservationsQuery {
|
|
103
103
|
project?: string;
|
|
104
104
|
signal?: string;
|
|
105
105
|
sessionId?: string;
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
upsertManagedAgentState,
|
|
9
9
|
} from "./config-store";
|
|
10
10
|
import { emitRelayEvent } from "./events";
|
|
11
|
+
import { emitMessageDeliveryUpdated } from "./sse";
|
|
11
12
|
import { emitCommandEvent } from "./command-events";
|
|
12
13
|
import { buildManagedSpawnParams } from "./managed-policy";
|
|
13
14
|
import { generateSpawnRequestId } from "./spawn-command";
|
|
@@ -95,6 +96,9 @@ export class LifecycleManager {
|
|
|
95
96
|
subject: `policy:${meta.policyName}`,
|
|
96
97
|
data: { policyName: meta.policyName, agentId, messageIds: available.map((message) => message.id), count: available.length },
|
|
97
98
|
});
|
|
99
|
+
// queued → pending changed delivery_status; refresh the dashboard delivery
|
|
100
|
+
// badge now rather than letting it sit stale until the next poll (#265).
|
|
101
|
+
for (const message of available) emitMessageDeliveryUpdated(message);
|
|
98
102
|
}
|
|
99
103
|
}
|
|
100
104
|
|
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/managed-policy.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): Worksp
|
|
|
20
20
|
return policy.binding?.type === "channel" ? "shared" : "inherit";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
interface ManagedSpawnContext {
|
|
24
24
|
createdBy: string;
|
|
25
25
|
requestedAt?: number;
|
|
26
26
|
}
|
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/notify.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { sendMessage } from "./db";
|
|
|
2
2
|
import { emitNewMessage } from "./sse";
|
|
3
3
|
import type { Message, MessageKind } from "./types";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
interface SystemNotifyOptions {
|
|
6
6
|
subject?: string;
|
|
7
7
|
body: string;
|
|
8
8
|
payload?: Record<string, unknown>;
|
package/src/routes.ts
CHANGED
|
@@ -160,10 +160,11 @@ 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";
|
|
166
|
-
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
167
|
+
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER, isMechanicalMessageKind, isReservedAgentId } from "agent-relay-sdk";
|
|
167
168
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
168
169
|
import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
|
|
169
170
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
|
|
@@ -1282,7 +1283,13 @@ const postAgent: Handler = async (req) => {
|
|
|
1282
1283
|
const available = resolveQueuedPolicyMessages(policyName, agent.id);
|
|
1283
1284
|
if (available.length) {
|
|
1284
1285
|
emitMessageAvailable(policyName, agent.id, available);
|
|
1285
|
-
for (const message of available)
|
|
1286
|
+
for (const message of available) {
|
|
1287
|
+
emitNewMessage(message);
|
|
1288
|
+
// queued → pending flips delivery_status; the dashboard dedups message.new
|
|
1289
|
+
// by id (the message already shows as "queued"), so without an explicit
|
|
1290
|
+
// delivery_updated the badge stays stale until the next poll (#265).
|
|
1291
|
+
emitMessageDeliveryUpdated(message);
|
|
1292
|
+
}
|
|
1286
1293
|
}
|
|
1287
1294
|
}
|
|
1288
1295
|
}
|
|
@@ -1321,7 +1328,7 @@ const getAgents: Handler = (req) => {
|
|
|
1321
1328
|
const tag = url.searchParams.get("tag") ?? undefined;
|
|
1322
1329
|
const machine = url.searchParams.get("machine") ?? undefined;
|
|
1323
1330
|
const status = url.searchParams.get("status") ?? undefined;
|
|
1324
|
-
return json(listAgents({ tag, machine, status }));
|
|
1331
|
+
return json(attachBranchStates(listAgents({ tag, machine, status })));
|
|
1325
1332
|
};
|
|
1326
1333
|
|
|
1327
1334
|
const findAgents: Handler = (req) => {
|
|
@@ -1355,7 +1362,7 @@ const postRouteAdvice: Handler = async (req) => {
|
|
|
1355
1362
|
|
|
1356
1363
|
const getAgentById: Handler = (_req, params) => {
|
|
1357
1364
|
const agent = getAgent(params.id!);
|
|
1358
|
-
return agent ? json(agent) : error("agent not found", 404);
|
|
1365
|
+
return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
|
|
1359
1366
|
};
|
|
1360
1367
|
|
|
1361
1368
|
const getAgentReplyObligations: Handler = (_req, params) => {
|
|
@@ -4489,8 +4496,12 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4489
4496
|
// Repoint the row so the next merge targets the live branch, not the deleted one.
|
|
4490
4497
|
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
4491
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 });
|
|
4492
4503
|
if (newBranch) {
|
|
4493
|
-
setWorkspaceBranch(workspaceId, newBranch, mergedSha);
|
|
4504
|
+
setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
|
|
4494
4505
|
}
|
|
4495
4506
|
// #239 — push the author a "your branch landed" notice (no polling). Only on a
|
|
4496
4507
|
// real land; a no-op resolution (#230) merged nothing, so it earns no notice.
|
|
@@ -4500,6 +4511,7 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4500
4511
|
mergedSha,
|
|
4501
4512
|
subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
|
|
4502
4513
|
newBranch,
|
|
4514
|
+
pushed: typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
|
|
4503
4515
|
});
|
|
4504
4516
|
}
|
|
4505
4517
|
}
|
|
@@ -5181,9 +5193,20 @@ const postMessage: Handler = async (req) => {
|
|
|
5181
5193
|
}
|
|
5182
5194
|
applyReplyRouting(input);
|
|
5183
5195
|
if (!input.to) return error("to is required (or provide replyTo to auto-route)");
|
|
5196
|
+
// Mechanical lifecycle/observability posts (system/control/session) addressed to a
|
|
5197
|
+
// reserved sink ("user"/"system") are the relay's own lane, not agent-directed
|
|
5198
|
+
// messaging. A managed token's recipient constraints (targets/policies/agents) gate
|
|
5199
|
+
// which *agents* it may message — they must NOT gate a session-mirror capture to the
|
|
5200
|
+
// reserved sink, or constrained tokens (telegram policy, codex steward) 403 → the
|
|
5201
|
+
// runner's outbox retries 12× and poisons the record → the dashboard silently loses
|
|
5202
|
+
// the turn (#284, same outbox-poison failure mode as #184). The message:send scope
|
|
5203
|
+
// and any channel constraint still apply; we only drop the target/agentId predicate.
|
|
5204
|
+
const reservedSinkPost = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
|
|
5184
5205
|
const denied = authorizeRoute(req, {
|
|
5185
5206
|
scope: "message:send",
|
|
5186
|
-
resource:
|
|
5207
|
+
resource: reservedSinkPost
|
|
5208
|
+
? { channel: input.channel }
|
|
5209
|
+
: { target: input.to, channel: input.channel, agentId: input.from },
|
|
5187
5210
|
});
|
|
5188
5211
|
if (denied) return denied;
|
|
5189
5212
|
// Resolve the target through the shared planner — the SAME matcher the MCP send tool
|
|
@@ -5198,8 +5221,7 @@ const postMessage: Handler = async (req) => {
|
|
|
5198
5221
|
// "session" = observed assistant turn (Phase 1 live-session lane). It is captured
|
|
5199
5222
|
// from the provider transcript and stored for the dashboard chat; it must persist
|
|
5200
5223
|
// regardless of target liveness and never be re-delivered into a session.
|
|
5201
|
-
|
|
5202
|
-
if (!bypassKinds.includes(input.kind ?? "")) {
|
|
5224
|
+
if (!isMechanicalMessageKind(input.kind)) {
|
|
5203
5225
|
const plan = planSend(input.to, listAgents());
|
|
5204
5226
|
if (plan.kind === "ambiguous") return error(plan.message, 409);
|
|
5205
5227
|
if (plan.kind !== "not_found") input.to = plan.to;
|
|
@@ -5249,7 +5271,7 @@ const postMessage: Handler = async (req) => {
|
|
|
5249
5271
|
};
|
|
5250
5272
|
|
|
5251
5273
|
function automaticMemoryTarget(message: { to: string; resolvedToAgent?: string; kind: string }): string | null {
|
|
5252
|
-
if (message.kind
|
|
5274
|
+
if (isMechanicalMessageKind(message.kind)) return null;
|
|
5253
5275
|
const target = message.resolvedToAgent ?? message.to;
|
|
5254
5276
|
if (!isDirectTarget(target)) return null;
|
|
5255
5277
|
const agent = getAgent(target);
|