agent-relay-server 0.36.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{activity-C3mkM6AU.js → activity-ClpDglG8.js} +2 -2
- package/public/assets/{activity-C3mkM6AU.js.map → activity-ClpDglG8.js.map} +1 -1
- package/public/assets/{agent-profiles-DS4_jLPT.js → agent-profiles-kb5H23CF.js} +2 -2
- package/public/assets/{agent-profiles-DS4_jLPT.js.map → agent-profiles-kb5H23CF.js.map} +1 -1
- package/public/assets/{agents-CAhQO7JH.js → agents-CHmEJvqV.js} +2 -2
- package/public/assets/{agents-CAhQO7JH.js.map → agents-CHmEJvqV.js.map} +1 -1
- package/public/assets/{analytics-BwihhhNn.js → analytics-2kTjXIj1.js} +3 -3
- package/public/assets/{analytics-BwihhhNn.js.map → analytics-2kTjXIj1.js.map} +1 -1
- package/public/assets/{automation-BLXToUiU.js → automation-B5U_g-1P.js} +2 -2
- package/public/assets/{automation-BLXToUiU.js.map → automation-B5U_g-1P.js.map} +1 -1
- package/public/assets/{branch-state-badge-D8-T2c1K.js → branch-state-badge-B1K7aIzF.js} +2 -2
- package/public/assets/{branch-state-badge-D8-T2c1K.js.map → branch-state-badge-B1K7aIzF.js.map} +1 -1
- package/public/assets/{channels-ppN8k4hu.js → channels-DyPw9JsY.js} +2 -2
- package/public/assets/{channels-ppN8k4hu.js.map → channels-DyPw9JsY.js.map} +1 -1
- package/public/assets/chat-zPXWB-03.js +2 -0
- package/public/assets/chat-zPXWB-03.js.map +1 -0
- package/public/assets/{connectors-CL9BALhF.js → connectors-k7JYCrrl.js} +2 -2
- package/public/assets/{connectors-CL9BALhF.js.map → connectors-k7JYCrrl.js.map} +1 -1
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-tmf8IBfr.js} +2 -2
- package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-tmf8IBfr.js.map} +1 -1
- package/public/assets/index-B1QUkb_O.js +21 -0
- package/public/assets/index-B1QUkb_O.js.map +1 -0
- package/public/assets/index-Bins8N_5.css +2 -0
- package/public/assets/{integrations-DX55ARy0.js → integrations-BEkyjBAs.js} +2 -2
- package/public/assets/{integrations-DX55ARy0.js.map → integrations-BEkyjBAs.js.map} +1 -1
- package/public/assets/{maintenance-9n_rJCHT.js → maintenance-Tn23oWBF.js} +2 -2
- package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-Tn23oWBF.js.map} +1 -1
- package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-CasacvJX.js} +2 -2
- package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-CasacvJX.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D4UIjB3I.js} +2 -2
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D4UIjB3I.js.map} +1 -1
- package/public/assets/{memory-BQONtGQS.js → memory-SVCob0fo.js} +2 -2
- package/public/assets/{memory-BQONtGQS.js.map → memory-SVCob0fo.js.map} +1 -1
- package/public/assets/{messages-DGqpkH72.js → messages-CHK24Uxx.js} +2 -2
- package/public/assets/{messages-DGqpkH72.js.map → messages-CHK24Uxx.js.map} +1 -1
- package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CQcJb6VE.js} +2 -2
- package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CQcJb6VE.js.map} +1 -1
- package/public/assets/{overview-DSU_CggA.js → overview-DbyX7k-7.js} +2 -2
- package/public/assets/{overview-DSU_CggA.js.map → overview-DbyX7k-7.js.map} +1 -1
- package/public/assets/{pairs-DGocNC1U.js → pairs-CaL0_ZfW.js} +2 -2
- package/public/assets/{pairs-DGocNC1U.js.map → pairs-CaL0_ZfW.js.map} +1 -1
- package/public/assets/{security-BSh0QxOl.js → security-BogsfkbT.js} +2 -2
- package/public/assets/{security-BSh0QxOl.js.map → security-BogsfkbT.js.map} +1 -1
- package/public/assets/{settings-C03CAJgO.js → settings-BOsnUh5f.js} +2 -2
- package/public/assets/{settings-C03CAJgO.js.map → settings-BOsnUh5f.js.map} +1 -1
- package/public/assets/{store-DKVWC6Uh.js → store-Bo72e9My.js} +2 -2
- package/public/assets/{store-DKVWC6Uh.js.map → store-Bo72e9My.js.map} +1 -1
- package/public/assets/{tasks-rKbuUPOk.js → tasks-CCxQovOv.js} +2 -2
- package/public/assets/{tasks-rKbuUPOk.js.map → tasks-CCxQovOv.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-BDikdsxs.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-BDikdsxs.js.map} +1 -1
- package/public/assets/{work-queue-DOsA9s4M.js → work-queue-fM-tu0iP.js} +2 -2
- package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-fM-tu0iP.js.map} +1 -1
- package/public/assets/{workspaces-CoC2nflZ.js → workspaces-Df0xJuIo.js} +2 -2
- package/public/assets/{workspaces-CoC2nflZ.js.map → workspaces-Df0xJuIo.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/src/adapter.ts +7 -0
- package/scripts/orchestrator-spawn-smoke.ts +65 -33
- package/src/agent-ref.ts +28 -1
- package/src/automations.ts +17 -2
- package/src/bus.ts +52 -41
- package/src/cli/index.ts +1 -1
- package/src/cli/workspace.ts +36 -3
- package/src/compaction-watch.ts +7 -0
- package/src/config-store.ts +46 -0
- package/src/index.ts +23 -6
- package/src/lifecycle-manager.ts +33 -71
- package/src/maintenance.ts +6 -1
- package/src/mcp.ts +106 -309
- package/src/routes/agent-sessions.ts +38 -3
- package/src/routes/agents-spawn.ts +43 -174
- package/src/routes/commands.ts +7 -19
- package/src/routes/messages.ts +24 -87
- package/src/routes/workspaces.ts +4 -1
- package/src/security.ts +7 -0
- package/src/services/auth-context.ts +109 -0
- package/src/services/dispatch-command.ts +60 -0
- package/src/services/errors.ts +26 -0
- package/src/services/managed-running.ts +130 -0
- package/src/services/parity-harness.ts +135 -0
- package/src/services/register-agent.ts +74 -0
- package/src/services/send-message.ts +177 -0
- package/src/services/shutdown-agent.ts +234 -0
- package/src/services/spawn-agent.ts +284 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-merge.ts +102 -3
- package/src/workspace-phase.ts +15 -1
- package/public/assets/chat-8iIPyww9.js +0 -2
- package/public/assets/chat-8iIPyww9.js.map +0 -1
- package/public/assets/index-3pO43nJo.css +0 -2
- package/public/assets/index-CaauKXl9.js +0 -21
- package/public/assets/index-CaauKXl9.js.map +0 -1
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, Message } from "./types";
|
|
15
|
+
import type { AgentCard, Message, TokenConstraints } from "./types";
|
|
16
16
|
|
|
17
17
|
interface ResolveOptions {
|
|
18
18
|
/** Exclude this agent id from matches (e.g. the requester, when pairing). */
|
|
@@ -255,6 +255,33 @@ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions =
|
|
|
255
255
|
return { kind: "not_found", message: notFoundMessage(target, agents) };
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* THE caller-identity resolver: the agent id behind a token's signed constraints, for
|
|
260
|
+
* `from`-autofill, whoami, and spawn/shutdown gating (#221/#243/#323). Two cases, in order:
|
|
261
|
+
* - an identity-bearing token (`constraints.agents` with a single id) → that id, no db.
|
|
262
|
+
* - a managed/spawned agent's runner token (no `agents` constraint, but a single
|
|
263
|
+
* `spawnRequestId` or `policy`) → matched back to its registered agent card.
|
|
264
|
+
* Returns undefined for admin/server tokens (no constraints → unrestricted by design) and
|
|
265
|
+
* multi-agent tokens. `getAgents` is LAZY — the live-agent scan runs only when a single
|
|
266
|
+
* spawnRequestId/policy actually needs resolving, so identity-bearing and admin tokens (and
|
|
267
|
+
* the hot registration path) pay no db cost. One home so the #243 "managed agents silently
|
|
268
|
+
* lose implicit identity" drift can't recur across the transport-convergence services.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveCallerAgentId(
|
|
271
|
+
constraints: TokenConstraints | undefined,
|
|
272
|
+
getAgents: () => AgentCard[],
|
|
273
|
+
): string | undefined {
|
|
274
|
+
const agents = constraints?.agents;
|
|
275
|
+
if (agents?.length === 1) return agents[0];
|
|
276
|
+
const spawnRequestId = constraints?.spawnRequestIds?.length === 1 ? constraints.spawnRequestIds[0] : undefined;
|
|
277
|
+
const policyName = constraints?.policies?.length === 1 ? constraints.policies[0] : undefined;
|
|
278
|
+
if (!spawnRequestId && !policyName) return undefined;
|
|
279
|
+
const match = getAgents().find((a) =>
|
|
280
|
+
(spawnRequestId !== undefined && a.meta?.spawnRequestId === spawnRequestId) ||
|
|
281
|
+
(policyName !== undefined && a.meta?.policyName === policyName));
|
|
282
|
+
return match?.id;
|
|
283
|
+
}
|
|
284
|
+
|
|
258
285
|
function fanoutReceipt(recipients: string[]): DeliveryReceipt {
|
|
259
286
|
if (recipients.length === 0) return { delivered: false, expectReply: false, recipients: [], queued: true, reason: "no online members — queued" };
|
|
260
287
|
return { delivered: true, expectReply: true, recipients };
|
package/src/automations.ts
CHANGED
|
@@ -487,7 +487,10 @@ function dispatchAutomationRun(automation: Automation, run: AutomationRun, now:
|
|
|
487
487
|
if (automation.targetPolicy.mode === "existing_agent") {
|
|
488
488
|
const agent = resolveExistingAgent(automation.targetPolicy, orchestrator);
|
|
489
489
|
if (!agent && automation.targetPolicy.ifNoMatch !== "spawn") throw new ValidationError("no matching managed agent for automation");
|
|
490
|
-
if (agent)
|
|
490
|
+
if (agent) {
|
|
491
|
+
const durableTarget = durableExistingAgentTarget(automation.targetPolicy);
|
|
492
|
+
return createRunTask(automation, run, durableTarget, now, { targetMode: "existing_agent", resolvedAgentId: agent.id });
|
|
493
|
+
}
|
|
491
494
|
const fallbackPolicy: AutomationTargetPolicy = { mode: "on_demand_agent", provider: orchestrator.providers[0] ?? "codex", keepAlive: false };
|
|
492
495
|
return dispatchOnDemandAutomation(automation, run, orchestrator, fallbackPolicy, now);
|
|
493
496
|
}
|
|
@@ -582,7 +585,11 @@ function createRunTask(
|
|
|
582
585
|
updateRun(run.id, {
|
|
583
586
|
status: meta.targetMode === "on_demand_agent" ? "waiting_agent" : "running",
|
|
584
587
|
startedAt: now,
|
|
585
|
-
targetAgentId:
|
|
588
|
+
targetAgentId: typeof meta.resolvedAgentId === "string"
|
|
589
|
+
? meta.resolvedAgentId
|
|
590
|
+
: target.startsWith("label:") || target.startsWith("cap:")
|
|
591
|
+
? undefined
|
|
592
|
+
: target,
|
|
586
593
|
taskId: result.task.id,
|
|
587
594
|
messageId: result.message?.id,
|
|
588
595
|
meta,
|
|
@@ -590,6 +597,14 @@ function createRunTask(
|
|
|
590
597
|
return { automation, run: getAutomationRun(run.id)!, task: result.task, message: result.message };
|
|
591
598
|
}
|
|
592
599
|
|
|
600
|
+
function durableExistingAgentTarget(policy: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>): string {
|
|
601
|
+
const label = policy.selector.label?.trim();
|
|
602
|
+
if (label) return `label:${label}`;
|
|
603
|
+
const cap = policy.selector.capabilities?.find((item) => item.trim().length > 0)?.trim();
|
|
604
|
+
if (cap) return `cap:${cap}`;
|
|
605
|
+
throw new ValidationError("existing-agent automation target selector must include a label or capability");
|
|
606
|
+
}
|
|
607
|
+
|
|
593
608
|
function insertRun(automation: Automation, scheduledFor: number, now: number): AutomationRun {
|
|
594
609
|
const id = randomUUID();
|
|
595
610
|
db().query(`
|
package/src/bus.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
-
import { createActivityEvent, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus,
|
|
2
|
+
import { ValidationError, createActivityEvent, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
|
|
3
3
|
import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
|
|
4
4
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
5
|
-
import {
|
|
5
|
+
import { getCommand, updateCommand } from "./commands-db";
|
|
6
6
|
import { emitCommandEvent } from "./command-events";
|
|
7
7
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
8
8
|
import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
|
|
9
|
+
import { registerAgent } from "./services/register-agent";
|
|
10
|
+
import { authContextFromBus } from "./services/auth-context";
|
|
11
|
+
import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
|
|
12
|
+
import { ServiceAuthError } from "./services/errors";
|
|
13
|
+
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
|
|
9
14
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
10
15
|
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
11
16
|
import {
|
|
@@ -16,8 +21,8 @@ import {
|
|
|
16
21
|
type RegisterFrame,
|
|
17
22
|
} from "agent-relay-sdk/protocol";
|
|
18
23
|
import { errMessage, isRecord, stringValue } from "agent-relay-sdk";
|
|
19
|
-
import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed,
|
|
20
|
-
import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, Task } from "./types";
|
|
24
|
+
import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
|
|
25
|
+
import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, RegisterAgentInput, Task } from "./types";
|
|
21
26
|
|
|
22
27
|
interface BusSocketData {
|
|
23
28
|
kind: "bus";
|
|
@@ -219,19 +224,42 @@ function handleCommandFrame(
|
|
|
219
224
|
return;
|
|
220
225
|
}
|
|
221
226
|
|
|
222
|
-
|
|
223
|
-
|
|
227
|
+
// agent.shutdown converges on the shutdownAgent service (#342/#347): the #221 parent→child
|
|
228
|
+
// gate + canonical payload, identical to HTTP + MCP. The coarse command:write check above
|
|
229
|
+
// stays; the service adds the fine parent→child gate the bus path previously LACKED.
|
|
230
|
+
if (commandType === "agent.shutdown") {
|
|
231
|
+
if (conn.componentAuth && !isComponentAuthorizedFor(conn.componentAuth, { scope: "command:write", resource: commandAuthorizationResource({ target, params }) })) {
|
|
232
|
+
sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const input = shutdownInputFromBusFrame(target, params, conn.agentId ?? conn.componentId);
|
|
237
|
+
const result = shutdownAgent(input, authContextFromBus(conn.componentAuth));
|
|
238
|
+
sendCommandResult(ws, frameId, "succeeded", { command: result.command });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (e instanceof ShutdownAuthError) sendCommandResult(ws, frameId, "rejected", undefined, e.message);
|
|
241
|
+
else if (e instanceof ShutdownTargetError || e instanceof ValidationError) sendCommandResult(ws, frameId, "failed", undefined, e.message);
|
|
242
|
+
else throw e;
|
|
243
|
+
}
|
|
224
244
|
return;
|
|
225
245
|
}
|
|
226
246
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
247
|
+
// Dispatch through the shared service — it owns authorization (identical to the old
|
|
248
|
+
// busCommandAuthorized gate), createCommand, and the "command.requested" emit. The bus
|
|
249
|
+
// adapter only translates the typed ServiceAuthError into a rejected result frame.
|
|
250
|
+
try {
|
|
251
|
+
const command = dispatchCommand(
|
|
252
|
+
{ type: commandType, source: conn.agentId ?? conn.componentId, target, params },
|
|
253
|
+
authContextFromBus(conn.componentAuth),
|
|
254
|
+
);
|
|
255
|
+
sendCommandResult(ws, frameId, "succeeded", { command });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (e instanceof ServiceAuthError) {
|
|
258
|
+
sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
235
263
|
}
|
|
236
264
|
|
|
237
265
|
function busCommandAuthorized(
|
|
@@ -245,18 +273,6 @@ function busCommandAuthorized(
|
|
|
245
273
|
});
|
|
246
274
|
}
|
|
247
275
|
|
|
248
|
-
function commandAuthorizationResource(command: Pick<Command, "target" | "params">) {
|
|
249
|
-
const params = isRecord(command.params) ? command.params : {};
|
|
250
|
-
return {
|
|
251
|
-
target: command.target,
|
|
252
|
-
agentId: stringValue(params.agentId) ?? command.target,
|
|
253
|
-
policyName: stringValue(params.policyName),
|
|
254
|
-
orchestratorId: stringValue(params.orchestratorId),
|
|
255
|
-
cwd: stringValue(params.cwd),
|
|
256
|
-
spawnRequestId: stringValue(params.spawnRequestId),
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
276
|
function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
|
|
261
277
|
const payload = frame.payload;
|
|
262
278
|
const runnerManaged = payload.meta?.runnerManaged === true || typeof payload.meta?.runnerId === "string";
|
|
@@ -273,14 +289,16 @@ function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
|
|
|
273
289
|
if (payload.agentId) {
|
|
274
290
|
const label = stringMeta(payload.meta, "label");
|
|
275
291
|
const context = contextFromMeta(payload.meta);
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
// Thin transport: build the unified RegisterAgentInput from the wire frame and hand it to
|
|
293
|
+
// the registerAgent service. ALL registration side effects — authoritative parent lineage
|
|
294
|
+
// (resolved from the token constraints inside the service, never the wire body), the managed
|
|
295
|
+
// came-up-running reconcile, the status broadcast, the timeline note, the parent-wake (#351),
|
|
296
|
+
// and the audit row — live in src/services/register-agent.ts so this bus path can never drift
|
|
297
|
+
// from the HTTP path again (the #342 `spawned_by`-NULL class of bug).
|
|
298
|
+
const input: RegisterAgentInput = {
|
|
281
299
|
id: payload.agentId,
|
|
282
300
|
name: stringMeta(payload.meta, "name") ?? payload.componentId,
|
|
283
|
-
kind: payload.role === "integration" ? "provider" : payload.role,
|
|
301
|
+
kind: (payload.role === "integration" ? "provider" : payload.role) as RegisterAgentInput["kind"],
|
|
284
302
|
...(label ? { label } : {}),
|
|
285
303
|
tags: payload.tags,
|
|
286
304
|
machine: payload.machine,
|
|
@@ -290,17 +308,10 @@ function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
|
|
|
290
308
|
instanceId: payload.instanceId,
|
|
291
309
|
...(providerCapabilities ? { providerCapabilities } : {}),
|
|
292
310
|
...(context ? { context } : {}),
|
|
293
|
-
...(lineage ? { spawnedBy: lineage } : {}),
|
|
294
311
|
meta: payload.meta,
|
|
295
|
-
}
|
|
312
|
+
};
|
|
313
|
+
const agent = registerAgent(input, authContextFromBus(ws.data.componentAuth));
|
|
296
314
|
epoch = agent.epoch;
|
|
297
|
-
getLifecycleManager().onAgentRegistered(agent.id, {
|
|
298
|
-
policyName: stringMeta(payload.meta, "policyName"),
|
|
299
|
-
spawnRequestId: stringMeta(payload.meta, "spawnRequestId"),
|
|
300
|
-
tmuxSession: stringMeta(payload.meta, "tmuxSession"),
|
|
301
|
-
});
|
|
302
|
-
noteAgentTimelineEvent(agent.id, agent.meta?.timelineEvent);
|
|
303
|
-
emitAgentStatusEvent(agent.id);
|
|
304
315
|
}
|
|
305
316
|
|
|
306
317
|
busConnections.set(ws.data.id, {
|
package/src/cli/index.ts
CHANGED
|
@@ -44,7 +44,7 @@ Usage:
|
|
|
44
44
|
agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
|
|
45
45
|
agent-relay token <create|list|revoke|verify> [options]
|
|
46
46
|
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
47
|
-
agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
|
|
47
|
+
agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
|
|
48
48
|
agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
|
|
49
49
|
agent-relay message <target> <body> [options]
|
|
50
50
|
agent-relay get-message <messageId> [--json|--body]
|
package/src/cli/workspace.ts
CHANGED
|
@@ -5,8 +5,11 @@ import { apiRequest } from "./_shared";
|
|
|
5
5
|
import { detectAgentId } from "./agent-detect";
|
|
6
6
|
import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "../workspace-phase";
|
|
7
7
|
import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
|
|
8
|
+
import { AUTO_MERGE_POLICIES, resolveLandStrategy } from "../workspace-merge";
|
|
9
|
+
import { getLandingConfig } from "../config-store";
|
|
10
|
+
import type { WorkspaceAutoMergePolicy } from "../types";
|
|
8
11
|
|
|
9
|
-
export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
|
|
12
|
+
export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
|
|
10
13
|
|
|
11
14
|
// The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
|
|
12
15
|
// by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
|
|
@@ -98,6 +101,8 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
98
101
|
|
|
99
102
|
let id = currentWorkspaceId(), idExplicit = false; // idExplicit: --id was passed, not the ambient default (#307)
|
|
100
103
|
let strategy: string | undefined;
|
|
104
|
+
let autoMerge: WorkspaceAutoMergePolicy | undefined;
|
|
105
|
+
let reviewer: string | undefined;
|
|
101
106
|
let purpose: string | undefined;
|
|
102
107
|
let repo: string | undefined;
|
|
103
108
|
let execute = false;
|
|
@@ -109,6 +114,14 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
109
114
|
const arg = args[i];
|
|
110
115
|
if (arg === "--id" && i + 1 < args.length) { id = args[++i]; idExplicit = true; }
|
|
111
116
|
else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
|
|
117
|
+
else if (arg === "--auto-merge" && i + 1 < args.length) {
|
|
118
|
+
const val = args[++i] as string;
|
|
119
|
+
if (!(AUTO_MERGE_POLICIES as readonly string[]).includes(val)) {
|
|
120
|
+
throw new Error(`--auto-merge must be one of: ${AUTO_MERGE_POLICIES.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
autoMerge = val as WorkspaceAutoMergePolicy;
|
|
123
|
+
}
|
|
124
|
+
else if (arg === "--reviewer" && i + 1 < args.length) reviewer = args[++i];
|
|
112
125
|
else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
|
|
113
126
|
else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
|
|
114
127
|
else if (arg === "--execute") execute = true;
|
|
@@ -190,11 +203,31 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
190
203
|
}
|
|
191
204
|
|
|
192
205
|
const from = await detectAgentId();
|
|
206
|
+
|
|
207
|
+
// For the `land` action, resolve the effective strategy via the three-level chain
|
|
208
|
+
// (explicit > instance config > default) so a bare `land` with no --strategy still
|
|
209
|
+
// picks up the operator's landing.strategy setting. The strategy is passed verbatim
|
|
210
|
+
// to the server; requestWorkspaceMerge is the single resolution point for the default.
|
|
211
|
+
let resolvedStrategy: string | undefined = strategy;
|
|
212
|
+
if (action === "land") {
|
|
213
|
+
const instanceConfig = getLandingConfig();
|
|
214
|
+
resolvedStrategy = resolveLandStrategy({
|
|
215
|
+
explicit: strategy,
|
|
216
|
+
instanceStrategy: instanceConfig.strategy,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
193
220
|
const actionBody: Record<string, unknown> =
|
|
194
221
|
action === "ready" ? { action: "request-review", agentId: from }
|
|
195
222
|
: action === "claim" ? { action: "claim", agentId: from, purpose }
|
|
196
223
|
: action === "release" ? { action: "release-claim", agentId: from }
|
|
197
|
-
: {
|
|
224
|
+
: {
|
|
225
|
+
action: "merge",
|
|
226
|
+
agentId: from,
|
|
227
|
+
...(resolvedStrategy ? { strategy: resolvedStrategy } : {}),
|
|
228
|
+
...(autoMerge ? { autoMerge } : {}),
|
|
229
|
+
...(reviewer ? { reviewer } : {}),
|
|
230
|
+
};
|
|
198
231
|
const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
|
|
199
232
|
if (json) {
|
|
200
233
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -210,6 +243,6 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
210
243
|
console.log(
|
|
211
244
|
action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
|
|
212
245
|
: action === "release" ? `Workspace ${id} claim released.`
|
|
213
|
-
: `Workspace ${id} merge requested (${
|
|
246
|
+
: `Workspace ${id} merge requested (${resolvedStrategy ?? "auto"}${autoMerge ? `, autoMerge: ${autoMerge}` : ""}${reviewer ? `, reviewer: ${reviewer}` : ""}).`,
|
|
214
247
|
);
|
|
215
248
|
}
|
package/src/compaction-watch.ts
CHANGED
|
@@ -151,6 +151,13 @@ export function getCompactionWatch(): CompactionWatch {
|
|
|
151
151
|
return singleton;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** Test seam: stop and drop the singleton so each parity run starts with no
|
|
155
|
+
* armed watches. */
|
|
156
|
+
export function __resetCompactionWatch(): void {
|
|
157
|
+
singleton?.stop();
|
|
158
|
+
singleton = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
// Feed an agent's latched timelineEvent (from meta) to the watch. Called from
|
|
155
162
|
// every path that updates an agent's meta — bus status/register frames and the
|
|
156
163
|
// HTTP register route — so a real provider hook clears the watch regardless of
|
package/src/config-store.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ConfigEntry,
|
|
9
9
|
ConfigHistoryEntry,
|
|
10
10
|
InsightsConfig,
|
|
11
|
+
LandingConfig,
|
|
11
12
|
ManagedAgentState,
|
|
12
13
|
ManagedAgentStatus,
|
|
13
14
|
NotificationsConfig,
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
SpawnProvider,
|
|
17
18
|
StewardConfig,
|
|
18
19
|
WorkspaceConfig,
|
|
20
|
+
WorkspaceLandingPolicy,
|
|
19
21
|
} from "./types";
|
|
20
22
|
|
|
21
23
|
const CONFIG_HISTORY_LIMIT = 50;
|
|
@@ -29,6 +31,9 @@ const NOTIFICATIONS_NAMESPACE = "notifications";
|
|
|
29
31
|
const NOTIFICATIONS_KEY = "default";
|
|
30
32
|
const WORKSPACE_NAMESPACE = "workspace";
|
|
31
33
|
const WORKSPACE_KEY = "default";
|
|
34
|
+
const LANDING_NAMESPACE = "landing";
|
|
35
|
+
const LANDING_KEY = "default";
|
|
36
|
+
const VALID_LANDING_STRATEGIES = ["direct", "pr"] as const;
|
|
32
37
|
const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
|
|
33
38
|
const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
|
|
34
39
|
const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
|
|
@@ -239,6 +244,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
|
|
|
239
244
|
env: input.env ?? {},
|
|
240
245
|
providerOptions: input.providerOptions ?? {},
|
|
241
246
|
...(input.maxSpawnedAgents === undefined ? {} : { maxSpawnedAgents: input.maxSpawnedAgents }),
|
|
247
|
+
...(input.landingStrategy === undefined ? {} : { landingStrategy: input.landingStrategy }),
|
|
242
248
|
};
|
|
243
249
|
}
|
|
244
250
|
|
|
@@ -317,6 +323,9 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
|
|
|
317
323
|
maxSpawnedAgents: value.maxSpawnedAgents === undefined || value.maxSpawnedAgents === null
|
|
318
324
|
? undefined
|
|
319
325
|
: cleanNumber(value.maxSpawnedAgents, "maxSpawnedAgents", { min: 0, max: 100 }),
|
|
326
|
+
landingStrategy: value.landingStrategy === undefined || value.landingStrategy === null
|
|
327
|
+
? undefined
|
|
328
|
+
: cleanEnum(value.landingStrategy, "landingStrategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
|
|
320
329
|
});
|
|
321
330
|
}
|
|
322
331
|
|
|
@@ -508,6 +517,19 @@ function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
|
|
|
508
517
|
return { symlinkPaths };
|
|
509
518
|
}
|
|
510
519
|
|
|
520
|
+
const LANDING_CONFIG_DEFAULTS: LandingConfig = {
|
|
521
|
+
strategy: "direct",
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
function validateLandingConfig(value: unknown): LandingConfig {
|
|
525
|
+
if (!isRecord(value)) throw new ValidationError("landing config value must be an object");
|
|
526
|
+
return {
|
|
527
|
+
strategy: value.strategy === undefined || value.strategy === null
|
|
528
|
+
? LANDING_CONFIG_DEFAULTS.strategy
|
|
529
|
+
: cleanEnum(value.strategy, "strategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
511
533
|
function normalizeValue(namespace: string, key: string, value: unknown): unknown {
|
|
512
534
|
if (value === undefined) throw new ValidationError("value required");
|
|
513
535
|
if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
|
|
@@ -516,6 +538,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
|
|
|
516
538
|
if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
|
|
517
539
|
if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
|
|
518
540
|
if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
|
|
541
|
+
if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
|
|
519
542
|
if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
|
|
520
543
|
return value;
|
|
521
544
|
}
|
|
@@ -682,6 +705,29 @@ export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEn
|
|
|
682
705
|
return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
|
|
683
706
|
}
|
|
684
707
|
|
|
708
|
+
/** Instance-wide landing config, merged over defaults (always returns a usable value). */
|
|
709
|
+
export function getLandingConfig(): LandingConfig {
|
|
710
|
+
const entry = getConfig<Partial<LandingConfig>>(LANDING_NAMESPACE, LANDING_KEY);
|
|
711
|
+
if (!entry) return { ...LANDING_CONFIG_DEFAULTS };
|
|
712
|
+
return validateLandingConfig({ ...LANDING_CONFIG_DEFAULTS, ...entry.value });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function getLandingConfigEntry(): ConfigEntry<LandingConfig> {
|
|
716
|
+
const entry = getConfig<LandingConfig>(LANDING_NAMESPACE, LANDING_KEY);
|
|
717
|
+
return entry ?? {
|
|
718
|
+
namespace: LANDING_NAMESPACE,
|
|
719
|
+
key: LANDING_KEY,
|
|
720
|
+
value: { ...LANDING_CONFIG_DEFAULTS },
|
|
721
|
+
version: 0,
|
|
722
|
+
updatedAt: "default",
|
|
723
|
+
updatedBy: "system",
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function setLandingConfig(value: unknown, updatedBy?: string): ConfigEntry<LandingConfig> {
|
|
728
|
+
return setConfig(LANDING_NAMESPACE, LANDING_KEY, value as LandingConfig, updatedBy);
|
|
729
|
+
}
|
|
730
|
+
|
|
685
731
|
/**
|
|
686
732
|
* Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
|
|
687
733
|
* Spread into every spawn command's params next to `agentProfile` so any isolated
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:z
|
|
|
15
15
|
import {
|
|
16
16
|
VERSION,
|
|
17
17
|
} from "./config";
|
|
18
|
+
import { CONTRACT_VERSIONS } from "./contracts";
|
|
18
19
|
import {
|
|
19
20
|
applyCors,
|
|
20
21
|
assertSafeNetworkConfig,
|
|
@@ -38,12 +39,28 @@ async function main(): Promise<void> {
|
|
|
38
39
|
startServer();
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
// Restart-on-update gating (issue #341).
|
|
43
|
+
//
|
|
44
|
+
// A `restartOnUpdate` policy should cycle its managed agent only when the agent's
|
|
45
|
+
// running runner code is now protocol-INCOMPATIBLE with the relay — i.e. on a
|
|
46
|
+
// runner-protocol bump — NOT on every package version change. Gating on the package
|
|
47
|
+
// version churned the always-on Telegram agent ~12×/day (one restart per patch
|
|
48
|
+
// redeploy), dropping in-flight work (surfaced #340). The runner protocol
|
|
49
|
+
// (CONTRACT_VERSIONS.runnerProtocol) only moves on a breaking runner change, so this
|
|
50
|
+
// fires ~never during normal releases.
|
|
51
|
+
//
|
|
52
|
+
// `relay-version` is still tracked for observability/telemetry, but it no longer
|
|
53
|
+
// triggers restarts.
|
|
54
|
+
export function reconcileRelayVersion(): void {
|
|
55
|
+
const storedVersion = getConfig<string>("system", "relay-version")?.value;
|
|
56
|
+
if (storedVersion !== VERSION) setConfig("system", "relay-version", VERSION, "server-startup");
|
|
57
|
+
|
|
58
|
+
const storedProtocol = getConfig<string>("system", "runner-protocol-version")?.value;
|
|
59
|
+
const currentProtocol = String(CONTRACT_VERSIONS.runnerProtocol);
|
|
60
|
+
if (storedProtocol === currentProtocol) return;
|
|
61
|
+
// Skip the very first run (no stored protocol yet) — that's initial bootstrap, not an upgrade.
|
|
62
|
+
if (storedProtocol) getLifecycleManager().onRelayUpdated(VERSION);
|
|
63
|
+
setConfig("system", "runner-protocol-version", currentProtocol, "server-startup");
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
function startServer(): void {
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCommand } from "./commands-db";
|
|
2
2
|
import { isPathWithinBase } from "./utils";
|
|
3
|
-
import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces
|
|
3
|
+
import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces } from "./db";
|
|
4
4
|
import {
|
|
5
5
|
getManagedAgentState,
|
|
6
6
|
listSpawnPolicies,
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
upsertManagedAgentState,
|
|
9
9
|
} from "./config-store";
|
|
10
10
|
import { emitRelayEvent } from "./events";
|
|
11
|
-
import {
|
|
11
|
+
import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
|
|
12
|
+
import { authContextFromSystem } from "./services/auth-context";
|
|
12
13
|
import { emitCommandEvent } from "./command-events";
|
|
13
14
|
import { buildManagedSpawnParams } from "./managed-policy";
|
|
14
15
|
import { generateSpawnRequestId } from "./spawn-command";
|
|
@@ -75,50 +76,30 @@ export class LifecycleManager {
|
|
|
75
76
|
this.spawnAgent(policy, "message-trigger");
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
agentId,
|
|
85
|
-
tmuxSession: meta.tmuxSession ?? state.tmuxSession,
|
|
86
|
-
healthySince: this.now(),
|
|
87
|
-
backoffUntil: undefined,
|
|
88
|
-
lastError: undefined,
|
|
89
|
-
});
|
|
90
|
-
if (next) this.emitState(next);
|
|
91
|
-
const available = resolveQueuedPolicyMessages(meta.policyName, agentId);
|
|
92
|
-
if (available.length) {
|
|
93
|
-
emitRelayEvent({
|
|
94
|
-
type: "message.available",
|
|
95
|
-
source: "server",
|
|
96
|
-
subject: `policy:${meta.policyName}`,
|
|
97
|
-
data: { policyName: meta.policyName, agentId, messageIds: available.map((message) => message.id), count: available.length },
|
|
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);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
79
|
+
// NOTE: the bus registration path used to call `onAgentRegistered` here for the
|
|
80
|
+
// came-up-running transition + queue flush. That logic now lives in the
|
|
81
|
+
// registerAgent service (src/services/register-agent.ts → markManagedAgentRunning),
|
|
82
|
+
// which the bus `handleRegister` calls directly — collapsing the second of the three
|
|
83
|
+
// drifted registration copies (epic #342). Only the orchestrator-report projection
|
|
84
|
+
// below still enters managed-running from the lifecycle manager.
|
|
104
85
|
|
|
105
86
|
onOrchestratorManagedAgentsReported(orchestratorId: string, agents: ManagedAgent[], exitedAgents: ManagedSessionExitDiagnostics[] = []): void {
|
|
106
87
|
for (const agent of agents) {
|
|
107
88
|
if (!agent.policyName || !agent.spawnRequestId) continue;
|
|
108
89
|
const state = getManagedAgentState(agent.policyName);
|
|
109
90
|
if (!state || state.spawnRequestId !== agent.spawnRequestId) continue;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
91
|
+
// Same came-up-running core the registration transports use — one home, no drift.
|
|
92
|
+
markManagedAgentRunning(
|
|
93
|
+
{
|
|
94
|
+
policyName: agent.policyName,
|
|
95
|
+
spawnRequestId: agent.spawnRequestId,
|
|
96
|
+
agentId: agent.agentId || state.agentId || "",
|
|
97
|
+
tmuxSession: agent.tmuxSession,
|
|
98
|
+
workspace: agent.workspace,
|
|
99
|
+
},
|
|
100
|
+
authContextFromSystem(),
|
|
101
|
+
{ now: this.now },
|
|
102
|
+
);
|
|
122
103
|
|
|
123
104
|
// Liveness reconciliation: the orchestrator just reported this process as
|
|
124
105
|
// running. If the relay record says offline, the two truths disagree
|
|
@@ -511,40 +492,14 @@ export class LifecycleManager {
|
|
|
511
492
|
context.utilization > DEFAULT_COMPACT_TARGET;
|
|
512
493
|
}
|
|
513
494
|
|
|
495
|
+
// The managed-state emit + activity-transition record lives in the shared
|
|
496
|
+
// emitManagedState (src/services/managed-running.ts) so the registration service
|
|
497
|
+
// and this lifecycle reconciler emit identically (epic #342). We track the prior
|
|
498
|
+
// status per policy here only to compute the from→to for the transition row.
|
|
514
499
|
private emitState(state: ManagedAgentState, reason?: string): void {
|
|
515
|
-
emitRelayEvent({
|
|
516
|
-
type: "policy.state.changed",
|
|
517
|
-
source: "lifecycle-manager",
|
|
518
|
-
subject: state.policyName,
|
|
519
|
-
data: state as unknown as Record<string, unknown>,
|
|
520
|
-
});
|
|
521
500
|
const previous = this.lastStatusByPolicy.get(state.policyName);
|
|
522
501
|
this.lastStatusByPolicy.set(state.policyName, state.status);
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// Persist a managed-state transition so it shows on the activity feed and the
|
|
527
|
-
// per-agent timeline — the "what changed, why, and when" record for a policy.
|
|
528
|
-
private recordTransition(state: ManagedAgentState, fromState: string | undefined, reason?: string): void {
|
|
529
|
-
const why = reason ?? state.lastError ?? undefined;
|
|
530
|
-
const seq = ++this.transitionSeq;
|
|
531
|
-
createActivityEvent({
|
|
532
|
-
clientId: `lifecycle-${state.policyName}-${state.status}-${this.now()}-${seq}`,
|
|
533
|
-
kind: "state",
|
|
534
|
-
title: `${state.policyName}: ${fromState ?? "—"} → ${state.status}`,
|
|
535
|
-
body: why,
|
|
536
|
-
icon: "ti-arrows-exchange",
|
|
537
|
-
view: "managed-agents",
|
|
538
|
-
agentId: state.agentId,
|
|
539
|
-
metadata: {
|
|
540
|
-
source: "lifecycle-manager",
|
|
541
|
-
policyName: state.policyName,
|
|
542
|
-
spawnRequestId: state.spawnRequestId,
|
|
543
|
-
fromState: fromState ?? null,
|
|
544
|
-
toState: state.status,
|
|
545
|
-
reason: why ?? null,
|
|
546
|
-
},
|
|
547
|
-
});
|
|
502
|
+
emitManagedState(state, previous, reason, this.now);
|
|
548
503
|
}
|
|
549
504
|
|
|
550
505
|
// Surface a disagreement between relay status and real process liveness so the
|
|
@@ -624,6 +579,13 @@ export function getLifecycleManager(): LifecycleManager {
|
|
|
624
579
|
return singleton;
|
|
625
580
|
}
|
|
626
581
|
|
|
582
|
+
/** Test seam: stop and drop the singleton so each parity run starts with clean
|
|
583
|
+
* in-memory state (the per-policy last-status map, timers). */
|
|
584
|
+
export function __resetLifecycleManager(): void {
|
|
585
|
+
singleton?.stop();
|
|
586
|
+
singleton = null;
|
|
587
|
+
}
|
|
588
|
+
|
|
627
589
|
function alwaysReloadTags(tags: string[]): string[] {
|
|
628
590
|
return tags
|
|
629
591
|
.filter((tag) => tag.startsWith("memory-reload:"))
|
package/src/maintenance.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { reconcileLandedWorkspace } from "./branch-landed";
|
|
|
37
37
|
import { notifyAgentOffline } from "./agent-lifecycle-events";
|
|
38
38
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
39
39
|
import { reapOrphanedWorktrees } from "./workspace-orphans";
|
|
40
|
-
import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
40
|
+
import { deriveBranchState, DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
41
41
|
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
42
42
|
import { getStewardConfig } from "./config-store";
|
|
43
43
|
import { ensureRepoSteward } from "./steward";
|
|
@@ -716,6 +716,11 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
716
716
|
if (!preview || (preview as { available?: false }).available === false) continue;
|
|
717
717
|
const p = preview as WorkspaceMergePreview;
|
|
718
718
|
if (p.error || p.missing) continue;
|
|
719
|
+
if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
|
|
720
|
+
patchWorkspaceMetadata(ws.id, { lastLandSkipReason: p.reason, lastLandSkipAt: Date.now() });
|
|
721
|
+
} else if (ws.metadata.lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
|
|
722
|
+
patchWorkspaceMetadata(ws.id, { lastLandSkipReason: undefined, lastLandSkipAt: undefined });
|
|
723
|
+
}
|
|
719
724
|
|
|
720
725
|
const ahead = p.unmergedAhead ?? p.ahead ?? 0;
|
|
721
726
|
const behind = p.behind ?? 0;
|