agent-relay-server 0.121.0 → 0.121.2
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 +5 -4
- package/public/assets/{MessageBubble-BaPGFJxq.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-BaPGFJxq.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BXCVfmln.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-BXCVfmln.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-D_shwAZt.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-D_shwAZt.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BrfVz5IV.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BrfVz5IV.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-B5w-Z9Ic.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-B5w-Z9Ic.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-Bm3D12UN.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-Bm3D12UN.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-DOTL8BFs.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-DOTL8BFs.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-BlqeJZ5-.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-BlqeJZ5-.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BoiKfK6Q.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BoiKfK6Q.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-PeU3iOaa.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-PeU3iOaa.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-BvV8Hee2.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-BvV8Hee2.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-C1FgzlT1.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-C1FgzlT1.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BTpU1xH4.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-BTpU1xH4.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-DZEahmyc.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-DZEahmyc.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-BqmgSpQm.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-BqmgSpQm.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-CuT92zQi.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-CuT92zQi.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-lM7FeXVC.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-lM7FeXVC.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-DaPNv3yY.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-DaPNv3yY.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-B1v_zD-M.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-B1v_zD-M.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-BNOx3yK6.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-BNOx3yK6.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DWFy4asl.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DWFy4asl.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-C_12cFPM.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-C_12cFPM.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-DIc7gX83.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-DIc7gX83.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-DPjvJUzT.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-DPjvJUzT.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-CtumNgG8.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-CtumNgG8.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-DzUhgMq9.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-DzUhgMq9.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BJ3_u0uY.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BJ3_u0uY.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-AWvlctyQ.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-AWvlctyQ.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-5ofqi-bN.js → settings-hd5kzdRB.js} +2 -2
- package/public/assets/{settings-5ofqi-bN.js.map → settings-hd5kzdRB.js.map} +1 -1
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-BEE9bBf7.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-BEE9bBf7.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C27H_Gwo.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C27H_Gwo.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-CtmPK2hK.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-CtmPK2hK.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-B26EKv_H.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-B26EKv_H.js.map → workspaces-CLYc3yF3.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
- package/src/agent-issue-repo.ts +10 -3
- package/src/agent-lifecycle-events.ts +23 -1
- package/src/agent-ref.ts +18 -3
- package/src/bus-outbox.ts +44 -1
- package/src/bus.ts +34 -7
- package/src/channel-target.ts +4 -0
- package/src/cli/steward.ts +15 -14
- package/src/commands-db.ts +63 -9
- package/src/config.ts +50 -0
- package/src/db/agents.ts +6 -2
- package/src/db/claim-sql.ts +16 -0
- package/src/db/continuations.ts +44 -3
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +128 -4
- package/src/db/pairs.ts +1 -0
- package/src/db/schema.ts +6 -0
- package/src/event-loop-lag.ts +10 -1
- package/src/gate-resolver.ts +124 -0
- package/src/lifecycle-manager.ts +41 -10
- package/src/lifecycle-signal-registry.ts +87 -0
- package/src/maintenance/constants.ts +5 -1
- package/src/maintenance/jobs.ts +36 -4
- package/src/maintenance/workspaces.ts +75 -44
- package/src/managed-policy-escalation.ts +68 -3
- package/src/mcp/tools-spawn.ts +16 -6
- package/src/mcp-plan-tools.ts +2 -3
- package/src/notification-settings.ts +11 -0
- package/src/notification-types.ts +4 -0
- package/src/operator-alarm.ts +37 -0
- package/src/routes/agents.ts +4 -0
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/routes/workspaces.ts +25 -7
- package/src/security.ts +35 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +8 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +36 -0
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/task-semantic-events.ts +17 -1
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +13 -5
- package/src/workspace-auto-merge.ts +90 -34
- package/src/workspace-escalation.ts +52 -3
- package/src/workspace-human-escalation.ts +68 -0
- package/src/workspace-merge.ts +16 -0
- package/src/workspace-orphans.ts +11 -6
- package/src/workspace-phase.ts +22 -1
- package/public/assets/store-D5et8685.js +0 -9
- package/public/assets/store-D5et8685.js.map +0 -1
package/src/routes/workspaces.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { WORKSPACE_ACTIONS, applyWorkspaceAction, buildWorkspaceCleanupCommand,
|
|
|
7
7
|
import { auditEvent, authAuditMetadata, authorizeRoute, emitCommand, error, json, parseBody, type Handler } from "./_shared";
|
|
8
8
|
import { collectWorkspaceOrphans } from "../workspace-orphans";
|
|
9
9
|
import { createCommand } from "../commands-db";
|
|
10
|
-
import { LAND_STRATEGIES, isOwnerAlive, withOwnerOnline } from "../workspace-merge";
|
|
10
|
+
import { LAND_STRATEGIES, isOwnerAlive, isOwnerProcessConfirmedGone, withOwnerOnline } from "../workspace-merge";
|
|
11
11
|
import { isPathWithinBase } from "../utils";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecoveryBranch, type WorkspaceRecord, type WorkspaceStatus } from "../types";
|
|
@@ -340,8 +340,14 @@ async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ sta
|
|
|
340
340
|
}
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
|
|
344
|
-
const { workspace, ownerOnline, gitState, claim } = input;
|
|
343
|
+
function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; ownerProcessGone: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
|
|
344
|
+
const { workspace, ownerOnline, ownerProcessGone, gitState, claim } = input;
|
|
345
|
+
// #1049 — `!ownerOnline` is a heartbeat lapse, which a relay self-stall fakes for LIVE
|
|
346
|
+
// agents (the 2026-07-04 cascade). It must NOT alone drive a destructive recommendation.
|
|
347
|
+
// Treat the owner as genuinely gone only when its process death was actually observed
|
|
348
|
+
// (orchestrator-reported exit); otherwise a maybe-alive owner gets recovery, not a nuke.
|
|
349
|
+
const ownerGone = !ownerOnline && ownerProcessGone;
|
|
350
|
+
const awaitingReview = workspace.status === "review_requested" || workspace.status === "conflict";
|
|
345
351
|
if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
|
|
346
352
|
if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
|
|
347
353
|
if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
|
|
@@ -350,19 +356,31 @@ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnli
|
|
|
350
356
|
const landed = gitState.landed === true;
|
|
351
357
|
if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
|
|
352
358
|
if (ahead === 0 || landed) {
|
|
353
|
-
if (!ownerOnline)
|
|
359
|
+
if (!ownerOnline) {
|
|
360
|
+
if (ownerGone) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner process gone" : "no unmerged commits; owner process gone" };
|
|
361
|
+
// Offline but the process wasn't observed dying — likely a false exit (relay stall /
|
|
362
|
+
// transport gap). Don't clean up; surface for review so a live owner can reconnect.
|
|
363
|
+
return { action: "review", confidence: "medium", reason: landed ? "work already landed; owner offline but process not confirmed gone — recover, don't clean up" : "owner offline but process not confirmed gone — recover, don't clean up" };
|
|
364
|
+
}
|
|
354
365
|
// Owner active but the workspace is awaiting attention with nothing to land (#230):
|
|
355
366
|
// landing it is a safe no-op that resolves it to terminal `merged`, clearing the
|
|
356
367
|
// queue (the host keeps a live owner's worktree). Otherwise there's genuinely
|
|
357
368
|
// nothing to do.
|
|
358
|
-
if (
|
|
369
|
+
if (awaitingReview) {
|
|
359
370
|
return { action: "merge", confidence: "high", reason: landed ? "work already landed; resolve the no-op" : "nothing to merge; resolve the no-op" };
|
|
360
371
|
}
|
|
361
372
|
return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
|
|
362
373
|
}
|
|
363
|
-
if (ownerOnline &&
|
|
374
|
+
if (ownerOnline && !awaitingReview) {
|
|
364
375
|
return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
|
|
365
376
|
}
|
|
377
|
+
// ahead > 0 with the owner offline. A worker that explicitly signaled review/conflict
|
|
378
|
+
// before going offline asked for the land — honor it. But a worker that merely lapsed
|
|
379
|
+
// mid-work (not awaiting review) with its process NOT confirmed gone is a false-exit
|
|
380
|
+
// candidate; recommend recovery, never an auto-land of its unmerged commits (#1049).
|
|
381
|
+
if (!ownerOnline && !awaitingReview && !ownerGone) {
|
|
382
|
+
return { action: "review", confidence: "medium", reason: `${ahead} unmerged commit(s) but owner offline and process not confirmed gone — recover, don't land` };
|
|
383
|
+
}
|
|
366
384
|
if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
|
|
367
385
|
if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
|
|
368
386
|
return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
|
|
@@ -397,7 +415,7 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
|
|
|
397
415
|
orchestrator: { id: orch?.id, online: orchOnline },
|
|
398
416
|
...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
|
|
399
417
|
...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
|
|
400
|
-
recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
|
|
418
|
+
recommendation: recommendWorkspaceAction({ workspace, ownerOnline, ownerProcessGone: isOwnerProcessConfirmedGone(workspace.ownerAgentId), gitState, claim }),
|
|
401
419
|
};
|
|
402
420
|
return json(diagnostics);
|
|
403
421
|
};
|
package/src/security.ts
CHANGED
|
@@ -153,6 +153,26 @@ export function isComponentAuthorizedFor(auth: ComponentToken, check: Authorizat
|
|
|
153
153
|
return hasComponentScope(auth, check.scope) && constraintsAllow(auth, check.resource);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// #1024 — Orchestrator registration/upsert (POST /api/orchestrators) is a privileged bootstrap
|
|
157
|
+
// operation: the row it writes carries an attacker-controllable `apiUrl` that the orchestrator
|
|
158
|
+
// proxy later hands the relay MASTER token to (the C1 admin-token-exfil chain). The coarse scope
|
|
159
|
+
// gate only requires `command:write` — which every routine provider-agent runtime token holds —
|
|
160
|
+
// so gate it explicitly here. Allowed callers: the raw root/god credential, an admin
|
|
161
|
+
// (`system:admin`) token, or an established orchestrator-host token (role "orchestrator", which
|
|
162
|
+
// re-registers on every reconnect). A provider-agent runtime token is refused, closing the chain
|
|
163
|
+
// at its entry point.
|
|
164
|
+
export function isOrchestratorRegistrationAuthorized(req: Request): boolean {
|
|
165
|
+
const component = getComponentAuth(req);
|
|
166
|
+
if (component) {
|
|
167
|
+
return hasComponentScope(component, "system:admin") || component.role === "orchestrator";
|
|
168
|
+
}
|
|
169
|
+
// Not a component token: the raw root credential, an integration token, or no credential at all.
|
|
170
|
+
// The coarse gate (which runs before this handler) already governs those — a routine unauthorized
|
|
171
|
+
// request never reaches here, and the root credential is the legitimate bootstrap path. Mirror
|
|
172
|
+
// authorizeRoute's contract and don't impose a stricter bar than the rest of the route surface.
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
export function requiredScopeFor(method: string, pathname: string): string | null {
|
|
157
177
|
if (pathname === "/api/stats") return "stats:read";
|
|
158
178
|
if (pathname === "/api/health") return "health:read";
|
|
@@ -456,6 +476,21 @@ export function unauthorized(req: Request): Response {
|
|
|
456
476
|
return applyCors(req, response);
|
|
457
477
|
}
|
|
458
478
|
|
|
479
|
+
// #1047 — the MCP endpoint speaks JSON-RPC, so a 401 must be a JSON-RPC error
|
|
480
|
+
// envelope, not the bare {"error":"unauthorized"} body (which fails client schema
|
|
481
|
+
// validation and reads as protocol noise). Mirrors the runner proxy's own auth
|
|
482
|
+
// failure (runner/src/relay-mcp-proxy.ts). id is null: the coarse auth gate runs
|
|
483
|
+
// before the JSON-RPC body is parsed, and a revoked-token caller can't be trusted
|
|
484
|
+
// to have supplied a well-formed id anyway.
|
|
485
|
+
export function unauthorizedMcp(req: Request): Response {
|
|
486
|
+
const response = Response.json(
|
|
487
|
+
{ jsonrpc: "2.0", id: null, error: { code: -32001, message: "unauthorized: session invalid or expired" } },
|
|
488
|
+
{ status: 401 },
|
|
489
|
+
);
|
|
490
|
+
response.headers.set("WWW-Authenticate", "Bearer");
|
|
491
|
+
return applyCors(req, response);
|
|
492
|
+
}
|
|
493
|
+
|
|
459
494
|
function authToken(): string {
|
|
460
495
|
return relayToken();
|
|
461
496
|
}
|
package/src/self-resume.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
2
|
import { createCommand, listCommands } from "./commands-db";
|
|
3
3
|
import { emitCommandEvent } from "./command-events";
|
|
4
|
+
import { MAX_BODY_BYTES, REAP_INTERVAL_MS } from "./config";
|
|
4
5
|
import { getSelfResumeConfig } from "./config-store";
|
|
5
6
|
import {
|
|
6
7
|
archiveContinuationSegment,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
import { emitRelayEvent } from "./events";
|
|
15
16
|
import { routeNotification } from "./notification-router";
|
|
16
17
|
import { renderTemplate } from "./prompt-resolver";
|
|
17
|
-
import type { AgentCard, Command, ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
|
|
18
|
+
import type { AgentCard, Command, CommandStatus, ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
|
|
18
19
|
|
|
19
20
|
interface CompactAndResumeInput {
|
|
20
21
|
agentId: string;
|
|
@@ -89,7 +90,8 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
|
|
|
89
90
|
spentTokens: typeof agent.context?.tokensUsed === "number" ? agent.context.tokensUsed : 0,
|
|
90
91
|
now,
|
|
91
92
|
});
|
|
92
|
-
|
|
93
|
+
if (updated.droppedLedgerEntries.length) archiveDroppedLedgerEntries(agent.id, generation, updated.droppedLedgerEntries, now);
|
|
94
|
+
const continuation = renderContinuation(updated.envelope);
|
|
93
95
|
const command = mode === "native"
|
|
94
96
|
? createNativeCompact(agent.id, generation, continuation)
|
|
95
97
|
: createClearThenInject(agent.id, generation, continuation);
|
|
@@ -100,18 +102,55 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
|
|
|
100
102
|
generation,
|
|
101
103
|
commandId: command.id,
|
|
102
104
|
continuationChars: continuation.length,
|
|
103
|
-
ledgerEntries: updated.ledger.length,
|
|
105
|
+
ledgerEntries: updated.envelope.ledger.length,
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
// #1023 — the ruledOut ledger bound (see updateContinuationAfterCompact) drops the
|
|
110
|
+
// oldest entries once the persisted ledger grows past its cap instead of letting a
|
|
111
|
+
// long-running agent render a multi-megabyte continuation. Dropped entries are not
|
|
112
|
+
// destroyed: they're archived alongside the workingState segments so relay_recall
|
|
113
|
+
// still finds them. Chunked well under archiveContinuationSegment's MAX_BODY_BYTES
|
|
114
|
+
// limit since a large drop batch could otherwise exceed a single segment's cap.
|
|
115
|
+
const DROPPED_LEDGER_ARCHIVE_CHUNK_BYTES = MAX_BODY_BYTES / 2;
|
|
116
|
+
|
|
117
|
+
function archiveDroppedLedgerEntries(agentId: string, generation: number, dropped: ContinuationLedgerEntry[], now: number): void {
|
|
118
|
+
let chunk: string[] = [];
|
|
119
|
+
let chunkBytes = 0;
|
|
120
|
+
const flush = () => {
|
|
121
|
+
if (!chunk.length) return;
|
|
122
|
+
archiveContinuationSegment({
|
|
123
|
+
agentId,
|
|
124
|
+
generation,
|
|
125
|
+
segment: `Ruled-out ledger entries dropped for size (search via relay_recall):\n${chunk.join("\n")}`,
|
|
126
|
+
now,
|
|
127
|
+
});
|
|
128
|
+
chunk = [];
|
|
129
|
+
chunkBytes = 0;
|
|
130
|
+
};
|
|
131
|
+
for (const entry of dropped) {
|
|
132
|
+
const line = renderLedgerLine(entry);
|
|
133
|
+
const lineBytes = new TextEncoder().encode(line).byteLength;
|
|
134
|
+
if (chunk.length && chunkBytes + lineBytes > DROPPED_LEDGER_ARCHIVE_CHUNK_BYTES) flush();
|
|
135
|
+
chunk.push(line);
|
|
136
|
+
chunkBytes += lineBytes;
|
|
137
|
+
}
|
|
138
|
+
flush();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface PendingInjection {
|
|
142
|
+
continuation: string;
|
|
143
|
+
mode: string;
|
|
144
|
+
generation?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractPendingInjection(command: Command): PendingInjection | null {
|
|
109
148
|
const resume = isRecord(command.params.selfResume) ? command.params.selfResume : undefined;
|
|
110
149
|
const mode = typeof resume?.mode === "string" ? resume.mode : undefined;
|
|
111
150
|
const shouldInject =
|
|
112
151
|
command.type === "agent.clearContext" && mode === "clear-inject"
|
|
113
152
|
|| command.type === "agent.compact" && mode === "native";
|
|
114
|
-
if (!shouldInject) return null;
|
|
153
|
+
if (!shouldInject || !mode) return null;
|
|
115
154
|
const continuation = typeof resume?.continuation === "string"
|
|
116
155
|
? resume.continuation
|
|
117
156
|
: typeof command.params.instructions === "string"
|
|
@@ -119,6 +158,26 @@ export function enqueueContinuationInjectionAfterSelfResume(command: Command): C
|
|
|
119
158
|
: undefined;
|
|
120
159
|
if (!continuation) return null;
|
|
121
160
|
const generation = typeof resume?.generation === "number" ? resume.generation : undefined;
|
|
161
|
+
return { continuation, mode, generation };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildInjectionParams(command: Command, injection: PendingInjection): Record<string, unknown> {
|
|
165
|
+
return {
|
|
166
|
+
reason: "self-resume-continuation",
|
|
167
|
+
content: injection.continuation,
|
|
168
|
+
selfResume: {
|
|
169
|
+
generation: injection.generation,
|
|
170
|
+
mode: injection.mode,
|
|
171
|
+
...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
|
|
172
|
+
...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function enqueueContinuationInjectionAfterSelfResume(command: Command): Command | null {
|
|
178
|
+
if (command.status !== "succeeded") return null;
|
|
179
|
+
const injection = extractPendingInjection(command);
|
|
180
|
+
if (!injection) return null;
|
|
122
181
|
const existing = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
|
|
123
182
|
.find((item) => item.correlationId === command.id);
|
|
124
183
|
if (existing) return null;
|
|
@@ -127,16 +186,7 @@ export function enqueueContinuationInjectionAfterSelfResume(command: Command): C
|
|
|
127
186
|
source: "system",
|
|
128
187
|
target: command.target,
|
|
129
188
|
correlationId: command.id,
|
|
130
|
-
params:
|
|
131
|
-
reason: "self-resume-continuation",
|
|
132
|
-
content: continuation,
|
|
133
|
-
selfResume: {
|
|
134
|
-
generation,
|
|
135
|
-
mode,
|
|
136
|
-
...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
|
|
137
|
-
...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
|
|
138
|
-
},
|
|
139
|
-
},
|
|
189
|
+
params: buildInjectionParams(command, injection),
|
|
140
190
|
});
|
|
141
191
|
emitCommandEvent(inject, "command.requested");
|
|
142
192
|
return inject;
|
|
@@ -146,19 +196,101 @@ export function enqueueContinuationInjectionAfterClear(command: Command): Comman
|
|
|
146
196
|
return enqueueContinuationInjectionAfterSelfResume(command);
|
|
147
197
|
}
|
|
148
198
|
|
|
199
|
+
const CLEAR_OR_COMPACT_TYPES = ["agent.clearContext", "agent.compact"] as const;
|
|
200
|
+
const INJECTION_SWEEP_TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "timed_out"];
|
|
201
|
+
const INJECTION_SWEEP_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
|
202
|
+
const MAX_INJECTION_ATTEMPTS = 3;
|
|
203
|
+
const IN_FLIGHT_OR_DONE_STATUSES: CommandStatus[] = ["succeeded", "pending", "accepted", "running"];
|
|
204
|
+
|
|
205
|
+
// #1023 — periodic backstop for the three silent mind-wipe windows found in the
|
|
206
|
+
// self-resume audit (relay message #176981):
|
|
207
|
+
// 1. runner->relay `command.update` lost in transit, so the clear/compact command
|
|
208
|
+
// never reaches "succeeded" and just times out — the reactive inject in
|
|
209
|
+
// enqueueContinuationInjectionAfterSelfResume never fires.
|
|
210
|
+
// 2. relay crash between persisting "succeeded" and creating the inject command.
|
|
211
|
+
// 3. the inject command itself fails once (e.g. provider momentarily unavailable)
|
|
212
|
+
// with no retry.
|
|
213
|
+
// This re-derives the missing inject directly from the durably persisted command
|
|
214
|
+
// params — the same idempotency key (correlationId = the clear/compact command id)
|
|
215
|
+
// used by the reactive path, so running this concurrently with it is safe. A
|
|
216
|
+
// timed-out clear/compact is treated the same as succeeded: we cannot always tell
|
|
217
|
+
// whether the runner's destructive step actually completed before the update was
|
|
218
|
+
// lost, and a redundant-but-harmless re-injection is preferable to a permanent
|
|
219
|
+
// silent mind-wipe. Retries are capped at MAX_INJECTION_ATTEMPTS; once exhausted the
|
|
220
|
+
// parent is notified once instead of retrying forever against an unreachable agent.
|
|
221
|
+
export function sweepMissedContinuationInjections(now: number = Date.now()): { enqueuedCommandIds: string[] } {
|
|
222
|
+
const enqueuedCommandIds: string[] = [];
|
|
223
|
+
const since = now - INJECTION_SWEEP_LOOKBACK_MS;
|
|
224
|
+
for (const type of CLEAR_OR_COMPACT_TYPES) {
|
|
225
|
+
for (const command of listCommands({ type, since, limit: 500 })) {
|
|
226
|
+
if (!INJECTION_SWEEP_TERMINAL_STATUSES.includes(command.status)) continue;
|
|
227
|
+
const injection = extractPendingInjection(command);
|
|
228
|
+
if (!injection) continue;
|
|
229
|
+
if (!getAgent(command.target)) continue;
|
|
230
|
+
const attempts = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
|
|
231
|
+
.filter((item) => item.correlationId === command.id);
|
|
232
|
+
if (attempts.some((item) => IN_FLIGHT_OR_DONE_STATUSES.includes(item.status))) continue;
|
|
233
|
+
if (attempts.length >= MAX_INJECTION_ATTEMPTS) {
|
|
234
|
+
notifyContinuationInjectionExhausted(command, attempts.length);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const inject = createCommand({
|
|
238
|
+
type: "agent.injectContext",
|
|
239
|
+
source: "system",
|
|
240
|
+
target: command.target,
|
|
241
|
+
correlationId: command.id,
|
|
242
|
+
params: buildInjectionParams(command, injection),
|
|
243
|
+
});
|
|
244
|
+
emitCommandEvent(inject, "command.requested");
|
|
245
|
+
enqueuedCommandIds.push(inject.id);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { enqueuedCommandIds };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const SELF_RESUME_INJECTION_SWEEP_JOB = {
|
|
252
|
+
id: "self-resume-injection-sweep",
|
|
253
|
+
title: "Self-resume injection sweep",
|
|
254
|
+
description: "Re-deliver self-resume continuations whose inject was lost to a transport blip, a relay crash between persisting the clear/compact and enqueuing the inject, or a failed inject attempt (#1023).",
|
|
255
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
256
|
+
runOnStart: true,
|
|
257
|
+
handler() {
|
|
258
|
+
return sweepMissedContinuationInjections();
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
function notifyContinuationInjectionExhausted(command: Command, attempts: number): void {
|
|
263
|
+
const idempotencyKey = `self-resume-injection-exhausted:${command.target}:${command.id}`;
|
|
264
|
+
if (findMessageByIdempotencyKey(command.target, idempotencyKey)) return;
|
|
265
|
+
const agent = getAgent(command.target);
|
|
266
|
+
const payload = { kind: "agent.resume_injection_failed", agentId: command.target, commandId: command.id, attempts };
|
|
267
|
+
emitRelayEvent({ type: "agent.resume_injection_failed", source: "server", subject: command.target, data: payload });
|
|
268
|
+
const recipient = agent?.spawnedBy ?? "user";
|
|
269
|
+
routeNotification({
|
|
270
|
+
type: "agent.resume_injection_failed",
|
|
271
|
+
scope: { agentId: command.target, parent: agent?.spawnedBy },
|
|
272
|
+
recipients: [recipient],
|
|
273
|
+
message: {
|
|
274
|
+
subject: "Self-resume continuation failed to inject",
|
|
275
|
+
body: `Agent \`${command.target}\` completed a self-resume ${command.type === "agent.compact" ? "compact" : "clear"} but its continuation could not be delivered after ${attempts} attempt(s). The agent may be running with an empty or stale context — check it and consider a manual relay_compact_and_resume or restart.`,
|
|
276
|
+
payload,
|
|
277
|
+
idempotencyKey,
|
|
278
|
+
from: command.target,
|
|
279
|
+
replyExpected: false,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
149
284
|
export function renderContinuation(
|
|
150
285
|
envelope: ContinuationEnvelope,
|
|
151
286
|
input: { intro?: string } = {},
|
|
152
287
|
): string {
|
|
153
|
-
const ledger = envelope.ledger.length
|
|
154
|
-
? envelope.ledger.map((entry) => `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`).join("\n")
|
|
155
|
-
: "- none";
|
|
156
288
|
const decisions = envelope.decisions.length ? envelope.decisions.map(renderDecisionEntry).join("\n") : "- none";
|
|
157
289
|
return renderTemplate("continuation.envelope", {
|
|
158
290
|
intro: input.intro ?? `You have been resumed by Agent Relay — generation ${envelope.generation}.`,
|
|
159
291
|
generation: String(envelope.generation),
|
|
160
292
|
objective: envelope.objective,
|
|
161
|
-
ruledOut: ledger,
|
|
293
|
+
ruledOut: renderLedger(envelope.ledger),
|
|
162
294
|
decisions,
|
|
163
295
|
workingState: envelope.workingState,
|
|
164
296
|
archiveRefs: envelope.archiveRefs.length ? envelope.archiveRefs.join("\n") : "- none",
|
|
@@ -170,6 +302,31 @@ function renderDecisionEntry(entry: ContinuationDecisionEntry): string {
|
|
|
170
302
|
return `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}${by}: ${entry.decision}${entry.rationale ? `; rationale: ${entry.rationale}` : ""}`;
|
|
171
303
|
}
|
|
172
304
|
|
|
305
|
+
function renderLedgerLine(entry: ContinuationLedgerEntry): string {
|
|
306
|
+
return `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// #1023 — render-time backstop on top of the persisted bound in
|
|
310
|
+
// updateContinuationAfterCompact. Even if that bound is ever bypassed by a future
|
|
311
|
+
// bug, this guarantees the ledger section injected into a fresh context can't blow
|
|
312
|
+
// past a fixed size; it keeps the newest entries and notes what was omitted rather
|
|
313
|
+
// than truncating mid-entry.
|
|
314
|
+
const MAX_RENDERED_LEDGER_CHARS = 300_000;
|
|
315
|
+
|
|
316
|
+
function renderLedger(entries: ContinuationLedgerEntry[]): string {
|
|
317
|
+
if (!entries.length) return "- none";
|
|
318
|
+
const lines = entries.map(renderLedgerLine);
|
|
319
|
+
let start = 0;
|
|
320
|
+
let totalChars = lines.reduce((sum, line) => sum + line.length + 1, 0);
|
|
321
|
+
while (totalChars > MAX_RENDERED_LEDGER_CHARS && start < lines.length - 1) {
|
|
322
|
+
totalChars -= lines[start]!.length + 1;
|
|
323
|
+
start += 1;
|
|
324
|
+
}
|
|
325
|
+
if (start === 0) return lines.join("\n");
|
|
326
|
+
const omitted = start;
|
|
327
|
+
return `- (${omitted} older ruled-out ${omitted === 1 ? "entry" : "entries"} omitted for size — see relay_recall for the archived history)\n${lines.slice(start).join("\n")}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
173
330
|
function assertResumeSupported(agent: AgentCard, mode: SelfResumeMode): void {
|
|
174
331
|
const context = agent.providerCapabilities?.context;
|
|
175
332
|
const resume = context?.resume ?? (context?.clear && context?.inject ? "clear-inject" : "none");
|
|
@@ -320,7 +477,7 @@ function notifySelfResumeBudgetWarning(agent: AgentCard, envelope: ContinuationE
|
|
|
320
477
|
function parseRuledOut(value: unknown, generation: number, now: number): ContinuationLedgerEntry[] {
|
|
321
478
|
if (value === undefined || value === null) return [];
|
|
322
479
|
if (!Array.isArray(value)) throw new ValidationError("ruledOut must be an array");
|
|
323
|
-
|
|
480
|
+
const entries = value.map((item, index) => {
|
|
324
481
|
if (typeof item === "string") return ledgerEntry(item, "", generation, now);
|
|
325
482
|
if (!isRecord(item)) throw new ValidationError(`ruledOut[${index}] must be a string or object`);
|
|
326
483
|
const ruledOut = typeof item.ruledOut === "string" ? item.ruledOut.trim() : "";
|
|
@@ -328,6 +485,13 @@ function parseRuledOut(value: unknown, generation: number, now: number): Continu
|
|
|
328
485
|
if (!ruledOut) throw new ValidationError(`ruledOut[${index}].ruledOut required`);
|
|
329
486
|
return ledgerEntry(ruledOut, why, generation, now);
|
|
330
487
|
});
|
|
488
|
+
// #1023 — ruledOut had no size limit at all (unlike the sibling workingState/objective
|
|
489
|
+
// fields, both capped at/near MAX_BODY_BYTES): a single legal call could inject a
|
|
490
|
+
// multi-megabyte continuation straight into the fresh context it was meant to fit
|
|
491
|
+
// inside. Bound total new content per call to the same ceiling as workingState.
|
|
492
|
+
const totalBytes = entries.reduce((sum, entry) => sum + new TextEncoder().encode(entry.ruledOut).byteLength + new TextEncoder().encode(entry.why).byteLength, 0);
|
|
493
|
+
if (totalBytes > MAX_BODY_BYTES) throw new ValidationError(`ruledOut exceeds ${MAX_BODY_BYTES} bytes for a single call — split it across multiple relay_compact_and_resume calls`);
|
|
494
|
+
return entries;
|
|
331
495
|
}
|
|
332
496
|
|
|
333
497
|
function ledgerEntry(ruledOut: string, why: string, generation: number, now: number): ContinuationLedgerEntry {
|
package/src/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { startRelayScheduler } from "./services/scheduler";
|
|
|
16
16
|
import { startEventLoopLagSampler } from "./event-loop-lag";
|
|
17
17
|
import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
|
|
18
18
|
import { onWorkspaceCreate } from "./db";
|
|
19
|
+
import { warnIfNoHumanReachableEscalationTarget } from "./workspace-human-escalation";
|
|
19
20
|
import { isTerminalSessionRequestAuthorized } from "./terminal-authz";
|
|
20
21
|
import { resolve, sep } from "path";
|
|
21
22
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
isScopedRequestAuthorized,
|
|
39
40
|
isOriginAllowed,
|
|
40
41
|
unauthorized,
|
|
42
|
+
unauthorizedMcp,
|
|
41
43
|
} from "./security";
|
|
42
44
|
import { startMaintenanceScheduler } from "./maintenance";
|
|
43
45
|
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
@@ -81,6 +83,7 @@ export function startServer(): void {
|
|
|
81
83
|
getCompactionWatch().start();
|
|
82
84
|
reconcileRelayVersion();
|
|
83
85
|
startConnectorStatusPoller();
|
|
86
|
+
warnIfNoHumanReachableEscalationTarget();
|
|
84
87
|
startEventLoopLagSampler();
|
|
85
88
|
startMaintenanceScheduler();
|
|
86
89
|
startRelayScheduler();
|
|
@@ -302,6 +305,11 @@ export function createFetchHandler(
|
|
|
302
305
|
url.pathname.startsWith("/api/settings/");
|
|
303
306
|
if (!isAuthorized(req)) {
|
|
304
307
|
if (!integrationAuth) {
|
|
308
|
+
// #1047 — the MCP endpoint must fail with a JSON-RPC envelope, not the bare
|
|
309
|
+
// 401 body. This gate runs BEFORE postMcp (whose mcpAuthContext falls back to
|
|
310
|
+
// full server scope), so it stays the authoritative reject for a revoked/
|
|
311
|
+
// invalid session token — it just answers in the caller's protocol.
|
|
312
|
+
if (url.pathname === "/api/mcp") return unauthorizedMcp(req);
|
|
305
313
|
return unauthorized(req);
|
|
306
314
|
}
|
|
307
315
|
if (!handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
-
import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, setPlanArchived, ValidationError } from "../db";
|
|
2
|
+
import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, sendMessage, setPlanArchived, ValidationError } from "../db";
|
|
3
3
|
import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/issue-associations";
|
|
4
4
|
import { resolveIssueRefFromString } from "../db/issue-refs";
|
|
5
5
|
import { emitRelayEvent } from "../events";
|
|
6
6
|
import { cleanString, cleanStringArray, optionalEnum } from "../validation";
|
|
7
7
|
import { defaultIssueRepo } from "../config";
|
|
8
|
+
import { issueRepoForAgent } from "../agent-issue-repo";
|
|
8
9
|
import { buildTaskGraphView, parseTaskId, planStatusToTaskStatus, projectPlanOverTasks } from "./task-plan-view";
|
|
9
10
|
import { createTaskEntity, setTaskEntityStatus } from "./task-entity";
|
|
10
11
|
import type { AuthContext } from "./auth-context";
|
|
@@ -75,14 +76,25 @@ function cleanOwnerRef(value: unknown): PlanOwnerRef | undefined {
|
|
|
75
76
|
return Object.keys(out).length ? out : undefined;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
// #220: a bare `#NNN` issueId ref needs a default repo to resolve against — issue-refs.ts
|
|
80
|
+
// throws without one, contrary to its own error text advertising the bare form as accepted.
|
|
81
|
+
// Resolve it the same way #533 resolves chat `#NNN` linkify: the CALLING agent's own project
|
|
82
|
+
// (cwd → project → issueRepo), falling back to the single global env default. This scopes the
|
|
83
|
+
// default to the actor's actual repo rather than guessing from teamId — agents have no
|
|
84
|
+
// team→project FK; cwd is the only durable link (see agent-issue-repo.ts).
|
|
85
|
+
function resolveDefaultIssueRepo(actor: string): string | undefined {
|
|
86
|
+
const agent = getAgent(actor);
|
|
87
|
+
return (agent ? issueRepoForAgent(agent) : undefined) ?? defaultIssueRepo();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cleanRefs(value: unknown, actor: string): PlanNode["refs"] | undefined {
|
|
79
91
|
if (value === undefined || value === null) return undefined;
|
|
80
92
|
const ref = cleanRecordRef(value, "refs");
|
|
81
93
|
if (!ref) return undefined;
|
|
82
94
|
const out: PlanNode["refs"] = {};
|
|
83
95
|
const issueId = cleanString(ref.issueId, "refs.issueId", { max: 1000 });
|
|
84
96
|
if (issueId) {
|
|
85
|
-
out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo:
|
|
97
|
+
out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo: resolveDefaultIssueRepo(actor) }).id;
|
|
86
98
|
}
|
|
87
99
|
// #836: refs.taskId is the live binding seam to a durable Task. Validate it resolves to a
|
|
88
100
|
// deliverable Task; store the canonical numeric id as a string. Once bound, the node's
|
|
@@ -147,12 +159,12 @@ function applyBoundStatus(node: PlanNode): PlanNode {
|
|
|
147
159
|
return { ...node, status: boundStatusForAgent(getAgent(agentId)) ?? "pending" };
|
|
148
160
|
}
|
|
149
161
|
|
|
150
|
-
function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
162
|
+
function cleanNode(input: PlanNodeInput, actor: string, planTeamId?: string): PlanNode {
|
|
151
163
|
if (!isRecord(input)) throw new ValidationError("node must be an object");
|
|
152
164
|
const scopeBlurb = cleanString(input.scopeBlurb, "node.scopeBlurb", { max: 2000 });
|
|
153
165
|
const ownerRef = cleanOwnerRef(input.ownerRef);
|
|
154
166
|
const lane = cleanString(input.lane, "node.lane", { max: 120 });
|
|
155
|
-
const refs = cleanRefs(input.refs);
|
|
167
|
+
const refs = cleanRefs(input.refs, actor);
|
|
156
168
|
const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
|
|
157
169
|
const statusSource = cleanStatusSource(input.statusSource);
|
|
158
170
|
return applyBoundStatus({
|
|
@@ -168,10 +180,10 @@ function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
|
168
180
|
});
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
|
|
183
|
+
function cleanNodes(value: unknown, actor: string, planTeamId?: string): PlanNode[] {
|
|
172
184
|
if (value === undefined || value === null) return [];
|
|
173
185
|
if (!Array.isArray(value)) throw new ValidationError("nodes must be an array");
|
|
174
|
-
const nodes = value.map((item) => cleanNode(item as PlanNodeInput, planTeamId));
|
|
186
|
+
const nodes = value.map((item) => cleanNode(item as PlanNodeInput, actor, planTeamId));
|
|
175
187
|
assertUnique(nodes.map((node) => node.id), "node id");
|
|
176
188
|
return nodes;
|
|
177
189
|
}
|
|
@@ -252,14 +264,32 @@ function emitPlan(type: "plan.created" | "plan.changed" | "plan.deleted", plan:
|
|
|
252
264
|
emitRelayEvent({ type, source: actor, subject: plan.id, data: { plan, actor } });
|
|
253
265
|
}
|
|
254
266
|
|
|
267
|
+
// #1035: a plan anchors to a messages.thread_id, but a fresh coordinator has no thread yet —
|
|
268
|
+
// forcing it to send a throwaway message just to mint one. When no threadId is supplied, mint the
|
|
269
|
+
// thread server-side by self-threading a lightweight system message (the same sendMessage path the
|
|
270
|
+
// caller would have used). Sent AS the actor when that agent is registered, else the reserved
|
|
271
|
+
// `system` sink, so the mint always passes the sender-registration gate.
|
|
272
|
+
function mintPlanThread(actor: string, channel: string | undefined): number {
|
|
273
|
+
const sender = getAgent(actor) ? actor : "system";
|
|
274
|
+
const root = sendMessage({
|
|
275
|
+
from: sender,
|
|
276
|
+
to: "system",
|
|
277
|
+
body: "Plan thread root",
|
|
278
|
+
...(channel ? { channel } : {}),
|
|
279
|
+
});
|
|
280
|
+
return root.threadId ?? root.id;
|
|
281
|
+
}
|
|
282
|
+
|
|
255
283
|
export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
256
|
-
const threadId = cleanThreadId(input.threadId);
|
|
257
|
-
if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
|
|
258
284
|
const channel = cleanString(input.channel, "channel", { max: 120 });
|
|
259
285
|
const actor = actorId(ctx);
|
|
286
|
+
const threadId = input.threadId === undefined || input.threadId === null
|
|
287
|
+
? mintPlanThread(actor, channel)
|
|
288
|
+
: cleanThreadId(input.threadId);
|
|
289
|
+
if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
|
|
260
290
|
const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
|
|
261
291
|
const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
|
|
262
|
-
const nodes = cleanNodes(input.nodes, teamId);
|
|
292
|
+
const nodes = cleanNodes(input.nodes, actor, teamId);
|
|
263
293
|
const edges = cleanEdges(input.edges);
|
|
264
294
|
validateEdges(nodes, edges);
|
|
265
295
|
const plan = createPlanRow({
|
|
@@ -279,7 +309,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
|
279
309
|
|
|
280
310
|
export function addPlanNode(planId: string, nodeInput: PlanNodeInput, ctx: AuthContext): Plan {
|
|
281
311
|
const plan = existingPlan(planId);
|
|
282
|
-
const node = cleanNode(nodeInput, plan.teamId);
|
|
312
|
+
const node = cleanNode(nodeInput, actorId(ctx), plan.teamId);
|
|
283
313
|
if (plan.nodes.some((item) => item.id === node.id)) throw new ValidationError(`node ${node.id} already exists`);
|
|
284
314
|
const updated = replacePlan({ ...plan, nodes: [...plan.nodes, node] }, actorId(ctx));
|
|
285
315
|
syncPlanNodeIssueAssociation(updated.id, node);
|
|
@@ -299,7 +329,7 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
|
|
|
299
329
|
...(patch.ownerRef !== undefined ? optionalProp("ownerRef", cleanOwnerRef(patch.ownerRef)) : {}),
|
|
300
330
|
...(patch.lane !== undefined ? optionalProp("lane", cleanString(patch.lane, "lane", { max: 120 })) : {}),
|
|
301
331
|
...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
|
|
302
|
-
...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
|
|
332
|
+
...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs, actorId(ctx))) : {}),
|
|
303
333
|
...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
|
|
304
334
|
statusSource,
|
|
305
335
|
};
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// status broadcast, the single timeline note, the spawned-child parent-wake, and
|
|
11
11
|
// the first-registration audit row.
|
|
12
12
|
import { errMessage, isReservedAgentId } from "agent-relay-sdk";
|
|
13
|
-
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
13
|
+
import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
14
14
|
import { emitAgentStatus, emitNewMessage } from "../sse";
|
|
15
15
|
import { noteAgentTimelineEvent } from "../compaction-watch";
|
|
16
16
|
import { agentProviderTimelineEvent } from "../provider-timeline-event";
|
|
@@ -128,6 +128,10 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
|
|
|
128
128
|
// spawner so it can't idle with its verdict undelivered (vent #121). First-registration only.
|
|
129
129
|
const spawnRequestId = metaStr(agent.meta, "spawnRequestId");
|
|
130
130
|
const provider = metaStr(agent.meta, "provider");
|
|
131
|
+
// #1037 — stamp the spawner-chosen report-up verbosity onto the child's meta from the persisted
|
|
132
|
+
// spawn command, so the lineage report-up delivery gate can read it (defaults to quiet if absent).
|
|
133
|
+
const reportVerbosity = readSpawnReportVerbosity(spawnRequestId);
|
|
134
|
+
if (reportVerbosity) mergeAgentMeta(agent.id, { reportVerbosity });
|
|
131
135
|
const promptMirror = seedSpawnPromptMirror(agent.id, spawnRequestId);
|
|
132
136
|
if (promptMirror?.created || promptMirror?.updated) emitNewMessage(promptMirror.message);
|
|
133
137
|
seedSpawnReplyObligation(agent.id, agent.spawnedBy, spawnRequestId);
|
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
|
|
25
25
|
import {
|
|
26
26
|
ValidationError,
|
|
27
|
+
flushDeferredLineageReport,
|
|
27
28
|
getAgent,
|
|
28
29
|
getMessage,
|
|
29
30
|
listAgents,
|
|
31
|
+
listRecentFinalResponseCaptures,
|
|
30
32
|
promoteLineageCapturedResponse,
|
|
31
33
|
sendMessagesBatchWithResult,
|
|
32
34
|
sendMessageWithResult,
|
|
@@ -225,6 +227,40 @@ function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: Sen
|
|
|
225
227
|
return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
/**
|
|
231
|
+
* #1037 — the child transitioned out of busy (idle/awaiting-input). Deliver its deferred turn-final
|
|
232
|
+
* report-up (a quiet child's last turn, held back so mid-work narration never reached the spawner)
|
|
233
|
+
* and fan it out over SSE. No-op when nothing was deferred. Driven off the bus turn-end edge.
|
|
234
|
+
*/
|
|
235
|
+
export function flushIdleLineageReport(childId: string): void {
|
|
236
|
+
const promoted = flushDeferredLineageReport(childId);
|
|
237
|
+
if (promoted?.created) emitNewMessages([promoted.message]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** How far back the startup sweep re-scans for held-but-lost turn-final captures (#1040). A report
|
|
241
|
+
* is only ever deferred while its child is mid-turn, so any capture lost across a restart is recent;
|
|
242
|
+
* a day is a generous ceiling that still bounds the scan. */
|
|
243
|
+
const LINEAGE_STARTUP_SWEEP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* #1040 — startup re-promotion sweep. `deferredLineageReports` is an in-memory Map, so a relay
|
|
247
|
+
* restart drops every held turn-final report-up. The captures are persisted, though, so re-run
|
|
248
|
+
* promotion for recent final response captures: an idle child whose report was lost gets it
|
|
249
|
+
* delivered now, an already-delivered one is a no-op (idempotency key), and a still-busy child
|
|
250
|
+
* re-defers (re-arming the in-memory hold so a later idle edge flushes it). Idempotent and safe to
|
|
251
|
+
* run periodically.
|
|
252
|
+
*/
|
|
253
|
+
export function sweepDeferredLineageReportsOnStartup(now: number = Date.now()): { promoted: number; scanned: number } {
|
|
254
|
+
const captures = listRecentFinalResponseCaptures(now - LINEAGE_STARTUP_SWEEP_WINDOW_MS);
|
|
255
|
+
const emitted: Message[] = [];
|
|
256
|
+
for (const capture of captures) {
|
|
257
|
+
const promoted = promoteLineageCapturedResponse(capture);
|
|
258
|
+
if (promoted?.created) emitted.push(promoted.message);
|
|
259
|
+
}
|
|
260
|
+
if (emitted.length > 0) emitNewMessages(emitted);
|
|
261
|
+
return { promoted: emitted.length, scanned: captures.length };
|
|
262
|
+
}
|
|
263
|
+
|
|
228
264
|
function emitSendResults(results: DbSendMessageResult[]): void {
|
|
229
265
|
const liveMessages: Message[] = [];
|
|
230
266
|
for (const result of results) {
|