agent-relay-server 0.59.0 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +156 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-vzupFQD8.js +2 -0
- package/public/assets/MessageBubble-vzupFQD8.js.map +1 -0
- package/public/assets/{PlanGraphPanel-CXix1C-H.js → PlanGraphPanel-DQvycBMW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CXix1C-H.js.map → PlanGraphPanel-DQvycBMW.js.map} +1 -1
- package/public/assets/{activity-CRafQ1Je.js → activity-gmfkOBhQ.js} +2 -2
- package/public/assets/{activity-CRafQ1Je.js.map → activity-gmfkOBhQ.js.map} +1 -1
- package/public/assets/{agent-profiles-DW4o5-ND.js → agent-profiles-7X8gFuwj.js} +2 -2
- package/public/assets/{agent-profiles-DW4o5-ND.js.map → agent-profiles-7X8gFuwj.js.map} +1 -1
- package/public/assets/{agents-Cbegv0Hm.js → agents-D201CuTR.js} +2 -2
- package/public/assets/{agents-Cbegv0Hm.js.map → agents-D201CuTR.js.map} +1 -1
- package/public/assets/{analytics-DX6T7JX3.js → analytics-CkOTrr5t.js} +2 -2
- package/public/assets/{analytics-DX6T7JX3.js.map → analytics-CkOTrr5t.js.map} +1 -1
- package/public/assets/{automation-CzSBv4gg.js → automation-_5u-nVs8.js} +2 -2
- package/public/assets/{automation-CzSBv4gg.js.map → automation-_5u-nVs8.js.map} +1 -1
- package/public/assets/{branch-state-badge-CRA50r81.js → branch-state-badge-BPXXLpQG.js} +2 -2
- package/public/assets/{branch-state-badge-CRA50r81.js.map → branch-state-badge-BPXXLpQG.js.map} +1 -1
- package/public/assets/{channels-B1MQCPCT.js → channels-BsJu5TXR.js} +2 -2
- package/public/assets/{channels-B1MQCPCT.js.map → channels-BsJu5TXR.js.map} +1 -1
- package/public/assets/{chat-BqIaFYGJ.js → chat-CHgPGHQD.js} +2 -2
- package/public/assets/{chat-BqIaFYGJ.js.map → chat-CHgPGHQD.js.map} +1 -1
- package/public/assets/{connectors-C9aFJIrh.js → connectors-arjfBQvc.js} +2 -2
- package/public/assets/{connectors-C9aFJIrh.js.map → connectors-arjfBQvc.js.map} +1 -1
- package/public/assets/display-CketawEF.js.map +1 -1
- package/public/assets/{formatted-body-impl-DOXH7oDF.js → formatted-body-impl-DBt5tnS0.js} +2 -2
- package/public/assets/{formatted-body-impl-DOXH7oDF.js.map → formatted-body-impl-DBt5tnS0.js.map} +1 -1
- package/public/assets/{index-br_Y6Cvt.js → index-D7js3XlH.js} +6 -6
- package/public/assets/{index-br_Y6Cvt.js.map → index-D7js3XlH.js.map} +1 -1
- package/public/assets/{insights-B1edq5lk.js → insights-BvjkOXk9.js} +2 -2
- package/public/assets/{insights-B1edq5lk.js.map → insights-BvjkOXk9.js.map} +1 -1
- package/public/assets/{integrations-Cm4fjp7_.js → integrations-7L1f3eIE.js} +2 -2
- package/public/assets/{integrations-Cm4fjp7_.js.map → integrations-7L1f3eIE.js.map} +1 -1
- package/public/assets/{maintenance-D6zvw0fU.js → maintenance-BBoL--vs.js} +2 -2
- package/public/assets/{maintenance-D6zvw0fU.js.map → maintenance-BBoL--vs.js.map} +1 -1
- package/public/assets/{managed-agents-CzfEznM1.js → managed-agents-BjYBzVZt.js} +2 -2
- package/public/assets/{managed-agents-CzfEznM1.js.map → managed-agents-BjYBzVZt.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DZlCaTHQ.js → markdown-preview-impl-BnwjZsmO.js} +2 -2
- package/public/assets/{markdown-preview-impl-DZlCaTHQ.js.map → markdown-preview-impl-BnwjZsmO.js.map} +1 -1
- package/public/assets/{memory-CUUAzJDc.js → memory-SLycz2gC.js} +2 -2
- package/public/assets/{memory-CUUAzJDc.js.map → memory-SLycz2gC.js.map} +1 -1
- package/public/assets/{messages-6QZH46LG.js → messages-DfSCTe6N.js} +2 -2
- package/public/assets/{messages-6QZH46LG.js.map → messages-DfSCTe6N.js.map} +1 -1
- package/public/assets/{orchestrators-CrcqkPuc.js → orchestrators-BwxdTQm_.js} +2 -2
- package/public/assets/{orchestrators-CrcqkPuc.js.map → orchestrators-BwxdTQm_.js.map} +1 -1
- package/public/assets/{overview-418xXZO_.js → overview-Djy3qkY5.js} +2 -2
- package/public/assets/{overview-418xXZO_.js.map → overview-Djy3qkY5.js.map} +1 -1
- package/public/assets/{pairs-CnedATgT.js → pairs-9KNduLnC.js} +2 -2
- package/public/assets/{pairs-CnedATgT.js.map → pairs-9KNduLnC.js.map} +1 -1
- package/public/assets/{security-DS13pbag.js → security-tG3q7fFW.js} +2 -2
- package/public/assets/{security-DS13pbag.js.map → security-tG3q7fFW.js.map} +1 -1
- package/public/assets/{select-BOprGZDS.js → select-CGUdMNOG.js} +2 -2
- package/public/assets/{select-BOprGZDS.js.map → select-CGUdMNOG.js.map} +1 -1
- package/public/assets/{settings-BgInn_f3.js → settings-CGcqyJ5L.js} +2 -2
- package/public/assets/{settings-BgInn_f3.js.map → settings-CGcqyJ5L.js.map} +1 -1
- package/public/assets/{store-DHO_a8BD.js → store-DXdfhiVf.js} +2 -2
- package/public/assets/store-DXdfhiVf.js.map +1 -0
- package/public/assets/{tasks-B-4oCPuv.js → tasks-DWlKg218.js} +2 -2
- package/public/assets/{tasks-B-4oCPuv.js.map → tasks-DWlKg218.js.map} +1 -1
- package/public/assets/{teams-sCQAs_QA.js → teams-DRchUP_p.js} +2 -2
- package/public/assets/{teams-sCQAs_QA.js.map → teams-DRchUP_p.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BhqS2XpS.js → terminal-viewer-impl-D_Sqe-vo.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BhqS2XpS.js.map → terminal-viewer-impl-D_Sqe-vo.js.map} +1 -1
- package/public/assets/{work-queue-BDhjlwUH.js → work-queue-HNcyjON9.js} +2 -2
- package/public/assets/{work-queue-BDhjlwUH.js.map → work-queue-HNcyjON9.js.map} +1 -1
- package/public/assets/{workspaces-6I-C69nf.js → workspaces-CicYE2Ax.js} +2 -2
- package/public/assets/{workspaces-6I-C69nf.js.map → workspaces-CicYE2Ax.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-lifecycle-events.ts +41 -21
- package/src/branch-landed.ts +46 -35
- package/src/channel-target.ts +10 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/merges.ts +89 -0
- package/src/config.ts +9 -0
- package/src/context-advisory.ts +13 -6
- package/src/db/merge-lease.ts +95 -1
- package/src/db/message-reads.ts +6 -0
- package/src/db/messages.ts +16 -1
- package/src/db/migrations.ts +30 -0
- package/src/db/notification-subscriptions.ts +198 -0
- package/src/db/schema.ts +11 -0
- package/src/index.ts +2 -0
- package/src/maintenance.ts +40 -21
- package/src/mcp-merge-tools.ts +66 -0
- package/src/mcp-notification-subscription-tools.ts +111 -0
- package/src/mcp.ts +5 -3
- package/src/merge-pause-maintenance.ts +13 -0
- package/src/notification-router.ts +168 -0
- package/src/notification-rules.ts +186 -0
- package/src/notify.ts +9 -0
- package/src/reviewer-pipeline.ts +54 -40
- package/src/routes/agent-sessions.ts +4 -2
- package/src/routes/commands.ts +20 -0
- package/src/routes/index.ts +7 -0
- package/src/routes/merges.ts +95 -0
- package/src/security.ts +2 -0
- package/src/self-resume.ts +11 -6
- package/src/services/merge-pause.ts +37 -0
- package/src/services/plan-live-bindings.ts +11 -6
- package/src/services/rate-limit-resume.ts +17 -10
- package/src/services/spawn-agent.ts +10 -8
- package/src/services/transient-agent-reaper.ts +11 -6
- package/src/token-db.ts +3 -3
- package/src/workspace-pr-completion.ts +85 -8
- package/public/assets/MessageBubble-BrEKymoV.js +0 -2
- package/public/assets/MessageBubble-BrEKymoV.js.map +0 -1
- package/public/assets/store-DHO_a8BD.js.map +0 -1
package/src/branch-landed.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { emitRelayEvent } from "./events";
|
|
2
2
|
import { getNotificationsConfig } from "./config-store";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
3
|
+
import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
|
|
4
|
+
import { registerDefaultAudienceRules, resolveAudience, audiencePredicates } from "./notification-rules";
|
|
5
|
+
import { createActivityEvent, getAgent, updateWorkspaceStatus } from "./db";
|
|
6
|
+
import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
7
|
+
|
|
8
|
+
// #444 — branch.landed is the first rule-driven type: the on-main "merged" fan-out audience comes
|
|
9
|
+
// from the #442 engine, not a hand-rolled agentsOnMain scan. onRepoRoot delivers online agents in
|
|
10
|
+
// the main checkout (author excluded — they get the direct store-ahead copy below); differentTeam
|
|
11
|
+
// then scopes the fan-out to the land's team, so a Team-A land no longer wakes Team-B agents sharing
|
|
12
|
+
// the main checkout (epic #440's dogfooded symptom). A teamless author/agent keeps prior reach.
|
|
13
|
+
registerDefaultAudienceRules("branch.landed", [audiencePredicates.onRepoRoot(), audiencePredicates.differentTeam()]);
|
|
7
14
|
|
|
8
15
|
export interface BranchLandedInput {
|
|
9
16
|
/**
|
|
@@ -88,27 +95,44 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
88
95
|
const continueLabel = input.newBranch
|
|
89
96
|
? ` You're now on \`${input.newBranch}\` — keep working there.`
|
|
90
97
|
: " Worktree reclaimed.";
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
// Durable (ttl null): the author's own branch landing must reach them even after they go
|
|
99
|
+
// offline (store-ahead #234) — not a bystander notice that goes stale.
|
|
100
|
+
routeNotification({
|
|
101
|
+
type: "branch.landed",
|
|
102
|
+
scope: { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
|
|
103
|
+
recipients: [author],
|
|
104
|
+
ttlMs: null,
|
|
105
|
+
message: {
|
|
106
|
+
subject: "Your branch landed",
|
|
107
|
+
body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}${continueLabel}`,
|
|
108
|
+
payload,
|
|
109
|
+
replyExpected: false,
|
|
110
|
+
},
|
|
96
111
|
});
|
|
97
112
|
}
|
|
98
113
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
// On-main "merged" fan-out — a live notice so a long-lived main agent's context stays current as
|
|
115
|
+
// work lands under it (#239). Recipients now come from the #442 rule engine (registered above):
|
|
116
|
+
// online agents whose cwd is the main checkout, author-excluded, team-scoped. Empty for a solo
|
|
117
|
+
// land with no other main agents → no-op.
|
|
118
|
+
const mainScope = { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id, teamId: author ? getAgent(author)?.teamId : undefined };
|
|
119
|
+
const mainRecipients = resolveAudience("branch.landed", mainScope).map((member) => member.agentId);
|
|
120
|
+
if (mainRecipients.length > 0) {
|
|
121
|
+
const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
|
|
122
|
+
const authorLabel = author ? ` by \`${author}\`` : "";
|
|
123
|
+
// Bystander fan-out (ttl informational): a "merged" notice is only useful while fresh — a
|
|
124
|
+
// late-joining main agent shouldn't be woken by an hours-old land.
|
|
125
|
+
routeNotification({
|
|
126
|
+
type: "branch.landed",
|
|
127
|
+
scope: mainScope,
|
|
128
|
+
recipients: mainRecipients,
|
|
129
|
+
ttlMs: INFORMATIONAL_TTL_MS,
|
|
130
|
+
message: {
|
|
131
|
+
subject: `Merged to ${base}`,
|
|
132
|
+
body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
|
|
133
|
+
payload,
|
|
134
|
+
replyExpected: false,
|
|
135
|
+
},
|
|
112
136
|
});
|
|
113
137
|
}
|
|
114
138
|
}
|
|
@@ -148,16 +172,3 @@ export function reconcileLandedWorkspace(ws: WorkspaceRecord, preview: Workspace
|
|
|
148
172
|
// Notification is best-effort; the merged status + activity event still stand.
|
|
149
173
|
}
|
|
150
174
|
}
|
|
151
|
-
|
|
152
|
-
// An agent is "on `main`" when its registered cwd equals the repo's main checkout — i.e. it
|
|
153
|
-
// works in the base, not an isolated worktree. Excludes the author, pseudo agents (system/
|
|
154
|
-
// user), channels, and offline sessions.
|
|
155
|
-
function agentsOnMain(repoRoot: string, author: string | undefined): AgentCard[] {
|
|
156
|
-
return listAgents().filter((a) => {
|
|
157
|
-
if (a.id === author || a.id === "system" || a.id === "user") return false;
|
|
158
|
-
if (a.kind === "channel" || a.meta?.kind === "channel") return false;
|
|
159
|
-
const cwd = a.meta?.cwd;
|
|
160
|
-
if (typeof cwd !== "string" || cwd !== repoRoot) return false;
|
|
161
|
-
return isAgentOnline(a);
|
|
162
|
-
});
|
|
163
|
-
}
|
package/src/channel-target.ts
CHANGED
|
@@ -23,3 +23,13 @@ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
|
|
|
23
23
|
if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
|
|
24
24
|
return { type: "agent", id: target };
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
// A fan-out target addresses MANY agents (broadcast / tag / cap / label / team / pool) rather than
|
|
28
|
+
// one. #451 uses this to scope the default message TTL: a stale fan-out message must not wake a
|
|
29
|
+
// late-joining agent, whereas a direct DM (and `policy:`, which resolves to one running agent or
|
|
30
|
+
// queues with its own expiry) is store-ahead and never auto-expires. Built on the one parser above
|
|
31
|
+
// so the prefix set can't drift from route resolution.
|
|
32
|
+
export function isFanoutTarget(target: string): boolean {
|
|
33
|
+
const { type } = parseChannelRouteTarget(target);
|
|
34
|
+
return type === "broadcast" || type === "tag" || type === "capability" || type === "label" || type === "team" || type === "pool";
|
|
35
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { handleDevCommand } from "./dev";
|
|
|
14
14
|
import { handleMemoryCommand } from "./memory";
|
|
15
15
|
import { handleSlashOrPairCommand } from "./pair";
|
|
16
16
|
import { handleWorkspaceCommand } from "./workspace";
|
|
17
|
+
import { handleMergesCommand } from "./merges";
|
|
17
18
|
import { handleStewardCommand } from "./steward";
|
|
18
19
|
import { handleMessageCommand, handleGetMessageCommand, handleReactCommand } from "./message";
|
|
19
20
|
import { handleReplyCommand } from "./reply";
|
|
@@ -44,6 +45,7 @@ Usage:
|
|
|
44
45
|
agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
|
|
45
46
|
agent-relay token <create|list|revoke|verify> [options]
|
|
46
47
|
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
48
|
+
agent-relay merges <pause|resume|renew|list> [--repo PATH] [--base BRANCH] [--ttl 3m|--ttl-ms N] [--reason TEXT] [--holder TEXT] [--id ID] [--json]
|
|
47
49
|
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
50
|
agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
|
|
49
51
|
agent-relay message <target> <body> [options]
|
|
@@ -141,6 +143,11 @@ Token examples:
|
|
|
141
143
|
agent-relay token list
|
|
142
144
|
agent-relay token revoke JTI
|
|
143
145
|
|
|
146
|
+
Merge pause examples:
|
|
147
|
+
agent-relay merges pause --repo /repo --reason release --ttl 5m
|
|
148
|
+
agent-relay merges renew --id PAUSE_ID --ttl 5m
|
|
149
|
+
agent-relay merges resume --id PAUSE_ID
|
|
150
|
+
|
|
144
151
|
Context probe examples:
|
|
145
152
|
agent-relay context-probe print-status-line --wrap 'ccstatusline'
|
|
146
153
|
agent-relay context-probe --wrap 'ccstatusline'
|
|
@@ -345,6 +352,10 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
|
|
|
345
352
|
await handleTagsCommand(args.slice(1));
|
|
346
353
|
return "handled";
|
|
347
354
|
}
|
|
355
|
+
if (command === "merges" || command === "merge") {
|
|
356
|
+
await handleMergesCommand(args.slice(1));
|
|
357
|
+
return "handled";
|
|
358
|
+
}
|
|
348
359
|
if (command === "workspace" || command === "workspaces") {
|
|
349
360
|
await handleWorkspaceCommand(args.slice(1));
|
|
350
361
|
return "handled";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Merge coordination CLI. Generic pause/resume lease for relay-owned auto-merge
|
|
2
|
+
// sweeps; useful for release/build critical sections (#448).
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { apiRequest } from "./_shared";
|
|
5
|
+
|
|
6
|
+
const MERGES_USAGE = "Usage: agent-relay merges <pause|resume|renew|list> [--repo PATH] [--base BRANCH] [--ttl 3m|--ttl-ms N] [--reason TEXT] [--holder TEXT] [--id ID] [--json]";
|
|
7
|
+
|
|
8
|
+
function gitRoot(): string | undefined {
|
|
9
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
|
|
10
|
+
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseDurationMs(raw: string): number {
|
|
14
|
+
const match = raw.trim().match(/^(\d+)(ms|s|m|h)?$/);
|
|
15
|
+
if (!match) throw new Error("--ttl must be a positive duration like 180s, 3m, 1h, or use --ttl-ms");
|
|
16
|
+
const value = Number(match[1]);
|
|
17
|
+
const unit = match[2] ?? "ms";
|
|
18
|
+
const multiplier = unit === "h" ? 60 * 60_000 : unit === "m" ? 60_000 : unit === "s" ? 1000 : 1;
|
|
19
|
+
const ms = value * multiplier;
|
|
20
|
+
if (!Number.isSafeInteger(ms) || ms <= 0) throw new Error("--ttl is out of range");
|
|
21
|
+
return ms;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatPause(pause: any): string {
|
|
25
|
+
const until = new Date(pause.expiresAt).toISOString();
|
|
26
|
+
return `Merge pause ${pause.id}: ${pause.repoRoot}#${pause.baseRef} held by ${pause.holder} until ${until}${pause.reason ? ` (${pause.reason})` : ""}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function handleMergesCommand(args: string[]): Promise<void> {
|
|
30
|
+
const action = args[0];
|
|
31
|
+
if (action === "--help" || action === "-h" || action === "help") {
|
|
32
|
+
console.log(MERGES_USAGE);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!action || !["pause", "resume", "renew", "list"].includes(action)) throw new Error(MERGES_USAGE);
|
|
36
|
+
|
|
37
|
+
let repoRoot: string | undefined;
|
|
38
|
+
let baseRef = "main";
|
|
39
|
+
let ttlMs: number | undefined;
|
|
40
|
+
let reason: string | undefined;
|
|
41
|
+
let holder: string | undefined;
|
|
42
|
+
let id: string | undefined;
|
|
43
|
+
let json = false;
|
|
44
|
+
|
|
45
|
+
for (let i = 1; i < args.length; i++) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
if (arg === "--repo" && i + 1 < args.length) repoRoot = args[++i];
|
|
48
|
+
else if (arg === "--base" && i + 1 < args.length) baseRef = args[++i]!;
|
|
49
|
+
else if (arg === "--ttl" && i + 1 < args.length) ttlMs = parseDurationMs(args[++i]!);
|
|
50
|
+
else if (arg === "--ttl-ms" && i + 1 < args.length) {
|
|
51
|
+
ttlMs = Number.parseInt(args[++i]!, 10);
|
|
52
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new Error("--ttl-ms must be a positive integer");
|
|
53
|
+
}
|
|
54
|
+
else if (arg === "--reason" && i + 1 < args.length) reason = args[++i];
|
|
55
|
+
else if (arg === "--holder" && i + 1 < args.length) holder = args[++i];
|
|
56
|
+
else if (arg === "--id" && i + 1 < args.length) id = args[++i];
|
|
57
|
+
else if (arg === "--json") json = true;
|
|
58
|
+
else throw new Error(`Unknown merges option "${arg}".`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (action === "list") {
|
|
62
|
+
const result = await apiRequest("GET", "/api/merges/pauses");
|
|
63
|
+
console.log(JSON.stringify(result, null, 2));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (action === "renew") {
|
|
68
|
+
if (!id) throw new Error("merges renew requires --id");
|
|
69
|
+
const result = await apiRequest("POST", "/api/merges/renew", { id, ttlMs });
|
|
70
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
71
|
+
else console.log(formatPause((result as any).pause));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
repoRoot = repoRoot ?? gitRoot();
|
|
76
|
+
if (action === "pause" && !repoRoot) throw new Error("No git repo detected. Pass --repo PATH.");
|
|
77
|
+
if (action === "resume" && !id && !repoRoot) throw new Error("No git repo detected. Pass --id ID or --repo PATH.");
|
|
78
|
+
|
|
79
|
+
if (action === "pause") {
|
|
80
|
+
const result = await apiRequest("POST", "/api/merges/pause", { repoRoot, baseRef, ttlMs, reason, holder });
|
|
81
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
82
|
+
else console.log(formatPause((result as any).pause));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = await apiRequest("POST", "/api/merges/resume", id ? { id } : { repoRoot, baseRef });
|
|
87
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
88
|
+
else console.log((result as any).released ? "Merge pause released." : "No matching merge pause was active.");
|
|
89
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -20,6 +20,11 @@ function envPositiveInt(name: string, fallback: number): number {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
|
|
23
|
+
// #451 — default TTL for fire-and-forget FAN-OUT messages (broadcast/tag/cap/label/team) that the
|
|
24
|
+
// sender didn't bound. A stale fan-out push must not wake an agent that matches the target hours
|
|
25
|
+
// later (the cold-session-woken-by-old-coordination-thread bug). Direct DMs, reply-obligations, and
|
|
26
|
+
// claimable work are exempt (never auto-expire) so store-ahead (#234) still delivers them.
|
|
27
|
+
export const DEFAULT_FANOUT_TTL_SECONDS = envPositiveInt("AGENT_RELAY_FANOUT_TTL_SECONDS", 2 * 60 * 60); // 2h
|
|
23
28
|
export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
|
|
24
29
|
export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
|
|
25
30
|
export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
|
|
@@ -28,6 +33,10 @@ export const POOL_CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_POOL_CLAIM_LEASE_
|
|
|
28
33
|
// repo. Held from when a workspace.merge command is dispatched until it settles
|
|
29
34
|
// (or this TTL expires, in case the orchestrator never reports back).
|
|
30
35
|
export const WORKSPACE_MERGE_LEASE_MS = envPositiveInt("AGENT_RELAY_WORKSPACE_MERGE_LEASE_MS", 900_000); // 15min
|
|
36
|
+
// Opt-in release/build critical-section pause for relay-owned auto-merge sweeps.
|
|
37
|
+
// This narrows the race window while the release script creates and pushes a tag;
|
|
38
|
+
// expiry is the safety net so a killed holder cannot wedge the repo.
|
|
39
|
+
export const MAIN_MERGE_PAUSE_TTL_MS = envPositiveInt("AGENT_RELAY_MAIN_MERGE_PAUSE_TTL_MS", 5 * 60_000); // 5min
|
|
31
40
|
|
|
32
41
|
// Max body size for any POST/PATCH request (64 KiB).
|
|
33
42
|
export const MAX_BODY_BYTES = 64 * 1024;
|
package/src/context-advisory.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getNotificationsConfig } from "./config-store";
|
|
2
2
|
import { getDb } from "./db/connection.ts";
|
|
3
3
|
import { emitRelayEvent } from "./events";
|
|
4
|
-
import {
|
|
4
|
+
import { routeNotification } from "./notification-router";
|
|
5
5
|
import type { AgentCard, Command, ContextState } from "./types";
|
|
6
6
|
|
|
7
7
|
interface ContextAdvisoryInput {
|
|
@@ -38,11 +38,18 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
|
|
|
38
38
|
const config = getNotificationsConfig();
|
|
39
39
|
if (!config.enabled || !config.contextThreshold.enabled) return;
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
// Short default TTL (per-type): a stale context advisory is misleading — the band may have moved
|
|
42
|
+
// by the time the agent next polls, so old advice shouldn't wake it.
|
|
43
|
+
routeNotification({
|
|
44
|
+
type: "agent.context_threshold",
|
|
45
|
+
scope: { agentId: input.agentId },
|
|
46
|
+
recipients: [input.agentId],
|
|
47
|
+
message: {
|
|
48
|
+
subject: "Context threshold reached",
|
|
49
|
+
body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
|
|
50
|
+
payload,
|
|
51
|
+
replyExpected: false,
|
|
52
|
+
},
|
|
46
53
|
});
|
|
47
54
|
}
|
|
48
55
|
|
package/src/db/merge-lease.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
parseRuntimePackage,
|
|
13
13
|
type RuntimeContracts,
|
|
14
14
|
} from "../contracts";
|
|
15
|
-
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
15
|
+
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, MAIN_MERGE_PAUSE_TTL_MS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { getDb } from "./connection.ts";
|
|
18
18
|
import type {
|
|
@@ -158,3 +158,97 @@ export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string;
|
|
|
158
158
|
return getDb().query(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// --- Per-target merge pause lease (issue #448) -----------------------------
|
|
162
|
+
|
|
163
|
+
export interface MergePauseRecord {
|
|
164
|
+
id: string;
|
|
165
|
+
repoRoot: string;
|
|
166
|
+
baseRef: string;
|
|
167
|
+
holder: string;
|
|
168
|
+
reason?: string;
|
|
169
|
+
createdAt: number;
|
|
170
|
+
updatedAt: number;
|
|
171
|
+
expiresAt: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function rowToMergePause(row: any): MergePauseRecord {
|
|
175
|
+
return {
|
|
176
|
+
id: row.id,
|
|
177
|
+
repoRoot: row.repo_root,
|
|
178
|
+
baseRef: row.base_ref,
|
|
179
|
+
holder: row.holder,
|
|
180
|
+
reason: row.reason ?? undefined,
|
|
181
|
+
createdAt: row.created_at,
|
|
182
|
+
updatedAt: row.updated_at,
|
|
183
|
+
expiresAt: row.expires_at,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getMergePause(id: string): MergePauseRecord | null {
|
|
188
|
+
const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE id = ?").get(id) as any;
|
|
189
|
+
return row ? rowToMergePause(row) : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getActiveMergePause(repoRoot: string, baseRef = "main", now: number = Date.now()): MergePauseRecord | null {
|
|
193
|
+
const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE repo_root = ? AND base_ref = ? AND expires_at > ?")
|
|
194
|
+
.get(repoRoot, baseRef, now) as any;
|
|
195
|
+
return row ? rowToMergePause(row) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function listMergePauses(): MergePauseRecord[] {
|
|
199
|
+
return (getDb().query("SELECT * FROM workspace_merge_pauses ORDER BY updated_at DESC").all() as any[]).map(rowToMergePause);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function acquireMergePause(input: {
|
|
203
|
+
repoRoot: string;
|
|
204
|
+
baseRef?: string;
|
|
205
|
+
holder: string;
|
|
206
|
+
reason?: string;
|
|
207
|
+
ttlMs?: number;
|
|
208
|
+
now?: number;
|
|
209
|
+
}): { ok: true; pause: MergePauseRecord } | { ok: false; pause: MergePauseRecord } {
|
|
210
|
+
return getDb().transaction(() => {
|
|
211
|
+
const now = input.now ?? Date.now();
|
|
212
|
+
const baseRef = input.baseRef || "main";
|
|
213
|
+
const existing = getActiveMergePause(input.repoRoot, baseRef, now);
|
|
214
|
+
if (existing) return { ok: false as const, pause: existing };
|
|
215
|
+
const id = randomUUID();
|
|
216
|
+
const expiresAt = now + (input.ttlMs ?? MAIN_MERGE_PAUSE_TTL_MS);
|
|
217
|
+
getDb().query(`
|
|
218
|
+
INSERT INTO workspace_merge_pauses (id, repo_root, base_ref, holder, reason, created_at, updated_at, expires_at)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
220
|
+
ON CONFLICT(repo_root, base_ref) DO UPDATE SET
|
|
221
|
+
id = excluded.id,
|
|
222
|
+
holder = excluded.holder,
|
|
223
|
+
reason = excluded.reason,
|
|
224
|
+
created_at = excluded.created_at,
|
|
225
|
+
updated_at = excluded.updated_at,
|
|
226
|
+
expires_at = excluded.expires_at
|
|
227
|
+
`).run(id, input.repoRoot, baseRef, input.holder, input.reason ?? null, now, now, expiresAt);
|
|
228
|
+
return { ok: true as const, pause: getMergePause(id)! };
|
|
229
|
+
})();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function renewMergePause(id: string, ttlMs: number = MAIN_MERGE_PAUSE_TTL_MS, now: number = Date.now()): MergePauseRecord | null {
|
|
233
|
+
const current = getMergePause(id);
|
|
234
|
+
if (!current || current.expiresAt <= now) return null;
|
|
235
|
+
getDb().query("UPDATE workspace_merge_pauses SET updated_at = ?, expires_at = ? WHERE id = ?").run(now, now + ttlMs, id);
|
|
236
|
+
return getMergePause(id);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function releaseMergePause(opts: { id?: string; repoRoot?: string; baseRef?: string }): boolean {
|
|
240
|
+
const where: string[] = [];
|
|
241
|
+
const params: string[] = [];
|
|
242
|
+
if (opts.id) { where.push("id = ?"); params.push(opts.id); }
|
|
243
|
+
if (opts.repoRoot) { where.push("repo_root = ?"); params.push(opts.repoRoot); }
|
|
244
|
+
if (opts.baseRef) { where.push("base_ref = ?"); params.push(opts.baseRef); }
|
|
245
|
+
if (!where.length) return false;
|
|
246
|
+
return getDb().query(`DELETE FROM workspace_merge_pauses WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function releaseExpiredMergePauses(now: number = Date.now()): string[] {
|
|
250
|
+
const expired = getDb().query("SELECT id FROM workspace_merge_pauses WHERE expires_at <= ?").all(now) as Array<{ id: string }>;
|
|
251
|
+
if (!expired.length) return [];
|
|
252
|
+
getDb().query("DELETE FROM workspace_merge_pauses WHERE expires_at <= ?").run(now);
|
|
253
|
+
return expired.map((r) => r.id);
|
|
254
|
+
}
|
package/src/db/message-reads.ts
CHANGED
|
@@ -204,6 +204,12 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
204
204
|
}
|
|
205
205
|
conditions.push("delivery_status != 'queued' AND delivery_status NOT IN ('failed', 'dead')");
|
|
206
206
|
|
|
207
|
+
// #451 — never surface a message past its TTL: a stale fan-out notification must not wake an
|
|
208
|
+
// agent that matches its target long after it was sent. NULL max_age_seconds never expires
|
|
209
|
+
// (direct DMs, reply-obligations, claimable work — store-ahead #234 still delivers those).
|
|
210
|
+
conditions.push("(max_age_seconds IS NULL OR created_at + max_age_seconds * 1000 > ?)");
|
|
211
|
+
params.push(Date.now());
|
|
212
|
+
|
|
207
213
|
// A claimable message is available to this agent when it is non-claimable, currently held by
|
|
208
214
|
// THIS agent, or unclaimed/expired AND its task is not already closed. The closed-task guard
|
|
209
215
|
// must cover BOTH the null-claim and the expired-claim branch: when a managed agent is
|
package/src/db/messages.ts
CHANGED
|
@@ -12,8 +12,9 @@ import {
|
|
|
12
12
|
parseRuntimePackage,
|
|
13
13
|
type RuntimeContracts,
|
|
14
14
|
} from "../contracts";
|
|
15
|
-
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
|
|
15
|
+
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, DEFAULT_FANOUT_TTL_SECONDS } from "../config";
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
|
+
import { isFanoutTarget } from "../channel-target";
|
|
17
18
|
import { getAgent, validateAgentSession } from "./agents.ts";
|
|
18
19
|
import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from "./artifacts.ts";
|
|
19
20
|
import { ClaimError, ValidationError, getDb } from "./connection.ts";
|
|
@@ -247,6 +248,10 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
247
248
|
const policyName = policyNameFromTarget(input.to);
|
|
248
249
|
let deliveryStatus: Message["deliveryStatus"] = "pending";
|
|
249
250
|
let queuedAt: number | null = null;
|
|
251
|
+
// Distinguish "unspecified" (apply the #451 fan-out default below) from an explicit `null`
|
|
252
|
+
// (authoritative never-expire — routeNotification sets this for store-ahead notification types,
|
|
253
|
+
// and the baseline must not override it). Both store as NULL, but only unspecified gets a default.
|
|
254
|
+
const ttlUnspecified = input.maxAgeSeconds === undefined;
|
|
250
255
|
let maxAgeSeconds = input.maxAgeSeconds ?? null;
|
|
251
256
|
let resolvedToAgent: string | null = null;
|
|
252
257
|
|
|
@@ -260,6 +265,16 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
260
265
|
}
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
// #451 — moderate default TTL for fire-and-forget FAN-OUT messages the sender didn't bound, so a
|
|
269
|
+
// stale broadcast/tag/cap/label/team push can't wake a late-joining agent hours later. Exempt
|
|
270
|
+
// (never auto-expire, preserving store-ahead #234): direct DMs and policy targets (single
|
|
271
|
+
// recipient), reply-obligations (replyExpected !== false), claimable work, and any message that
|
|
272
|
+
// already carried an explicit TTL — including an explicit `null` from routeNotification, whose
|
|
273
|
+
// per-type decision (e.g. a store-ahead `plan.bound_agent_exited` to `team:`) must win here.
|
|
274
|
+
if (ttlUnspecified && input.replyExpected === false && !claimable && isFanoutTarget(input.to)) {
|
|
275
|
+
maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
|
|
276
|
+
}
|
|
277
|
+
|
|
263
278
|
const id = getDb().transaction(() => {
|
|
264
279
|
const result = insert.run({
|
|
265
280
|
$from: input.from,
|
package/src/db/migrations.ts
CHANGED
|
@@ -117,6 +117,21 @@ export function applyMigrations(): void {
|
|
|
117
117
|
normalizeExistingMessageReactions();
|
|
118
118
|
|
|
119
119
|
// Migrations
|
|
120
|
+
getDb().run(`
|
|
121
|
+
CREATE TABLE IF NOT EXISTS workspace_merge_pauses (
|
|
122
|
+
id TEXT PRIMARY KEY,
|
|
123
|
+
repo_root TEXT NOT NULL,
|
|
124
|
+
base_ref TEXT NOT NULL,
|
|
125
|
+
holder TEXT NOT NULL,
|
|
126
|
+
reason TEXT,
|
|
127
|
+
created_at INTEGER NOT NULL,
|
|
128
|
+
updated_at INTEGER NOT NULL,
|
|
129
|
+
expires_at INTEGER NOT NULL,
|
|
130
|
+
UNIQUE(repo_root, base_ref)
|
|
131
|
+
)
|
|
132
|
+
`);
|
|
133
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_workspace_merge_pauses_expires ON workspace_merge_pauses(expires_at)");
|
|
134
|
+
|
|
120
135
|
const cols = getDb().query("PRAGMA table_info(messages)").all() as any[];
|
|
121
136
|
const colNames = cols.map((c: any) => c.name);
|
|
122
137
|
if (!colNames.includes("thread_id")) {
|
|
@@ -449,6 +464,21 @@ export function applyMigrations(): void {
|
|
|
449
464
|
getDb().run("ALTER TABLE agents ADD COLUMN team_id TEXT");
|
|
450
465
|
}
|
|
451
466
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_team ON agents(team_id)");
|
|
467
|
+
getDb().run(`
|
|
468
|
+
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
|
469
|
+
id TEXT PRIMARY KEY,
|
|
470
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
471
|
+
type TEXT NOT NULL,
|
|
472
|
+
mode TEXT NOT NULL CHECK (mode IN ('opt_in', 'opt_out', 'mute')),
|
|
473
|
+
scope_match TEXT,
|
|
474
|
+
created_at INTEGER NOT NULL
|
|
475
|
+
)
|
|
476
|
+
`);
|
|
477
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_agent ON notification_subscriptions(agent_id, type)");
|
|
478
|
+
getDb().run(`
|
|
479
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_subscriptions_unique
|
|
480
|
+
ON notification_subscriptions(agent_id, type, mode, coalesce(scope_match, ''))
|
|
481
|
+
`);
|
|
452
482
|
getDb().run(`
|
|
453
483
|
CREATE TABLE IF NOT EXISTS context_snapshots (
|
|
454
484
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|