agent-relay-server 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/public/index.html +25 -8
- package/src/branch-landed.ts +77 -0
- package/src/config-store.ts +31 -0
- package/src/maintenance.ts +16 -20
- package/src/notify.ts +31 -0
- package/src/routes.ts +15 -1
- package/src/workspace-phase.ts +51 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"CONTRIBUTING.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"agent-relay-sdk": "0.2.
|
|
36
|
+
"agent-relay-sdk": "0.2.14"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"prepack": "bun run build:dashboard:bundle >&2",
|
package/public/index.html
CHANGED
|
@@ -10168,6 +10168,8 @@ function parseSseFrame(frame) {
|
|
|
10168
10168
|
}
|
|
10169
10169
|
//#endregion
|
|
10170
10170
|
//#region src/lib/api.ts
|
|
10171
|
+
var API_TIMEOUT_MS = 2e4;
|
|
10172
|
+
var SSE_STALE_MS = 35e3;
|
|
10171
10173
|
var authToken = "";
|
|
10172
10174
|
function setAuthToken(token) {
|
|
10173
10175
|
authToken = token;
|
|
@@ -10211,11 +10213,11 @@ function openTerminalWebSocket(orchestratorId, session) {
|
|
|
10211
10213
|
return new WebSocket(url);
|
|
10212
10214
|
}
|
|
10213
10215
|
function openRelayEventStream(token, handlers) {
|
|
10214
|
-
const abort = new AbortController();
|
|
10215
10216
|
const eventUrl = new URL("api/events", baseUrl()).toString();
|
|
10216
10217
|
let closed = false;
|
|
10217
10218
|
let retryMs = 5e3;
|
|
10218
10219
|
let reconnectTimer = null;
|
|
10220
|
+
let activeAbort = null;
|
|
10219
10221
|
const scheduleReconnect = () => {
|
|
10220
10222
|
if (closed) return;
|
|
10221
10223
|
reconnectTimer = setTimeout(connect, retryMs);
|
|
@@ -10226,6 +10228,11 @@ function openRelayEventStream(token, handlers) {
|
|
|
10226
10228
|
if (data.length > 0) handlers.message(event, data.join("\n"));
|
|
10227
10229
|
};
|
|
10228
10230
|
const connect = async () => {
|
|
10231
|
+
if (closed) return;
|
|
10232
|
+
const ac = new AbortController();
|
|
10233
|
+
activeAbort = ac;
|
|
10234
|
+
let lastFrameAt = Date.now();
|
|
10235
|
+
let staleTimer = null;
|
|
10229
10236
|
try {
|
|
10230
10237
|
const headers = { Accept: "text/event-stream" };
|
|
10231
10238
|
const effectiveToken = token || getAuthToken();
|
|
@@ -10233,10 +10240,14 @@ function openRelayEventStream(token, handlers) {
|
|
|
10233
10240
|
const response = await fetch(eventUrl, {
|
|
10234
10241
|
headers,
|
|
10235
10242
|
cache: "no-store",
|
|
10236
|
-
signal:
|
|
10243
|
+
signal: ac.signal
|
|
10237
10244
|
});
|
|
10238
10245
|
if (!response.ok || !response.body) throw new Error(`SSE failed: ${response.status}`);
|
|
10239
10246
|
handlers.connected?.();
|
|
10247
|
+
lastFrameAt = Date.now();
|
|
10248
|
+
staleTimer = setInterval(() => {
|
|
10249
|
+
if (Date.now() - lastFrameAt > SSE_STALE_MS) ac.abort();
|
|
10250
|
+
}, 5e3);
|
|
10240
10251
|
const reader = response.body.getReader();
|
|
10241
10252
|
const decoder = new TextDecoder();
|
|
10242
10253
|
let buffer = "";
|
|
@@ -10246,6 +10257,7 @@ function openRelayEventStream(token, handlers) {
|
|
|
10246
10257
|
buffer += decoder.decode();
|
|
10247
10258
|
break;
|
|
10248
10259
|
}
|
|
10260
|
+
lastFrameAt = Date.now();
|
|
10249
10261
|
buffer += decoder.decode(value, { stream: true });
|
|
10250
10262
|
let frameEnd = buffer.indexOf("\n\n");
|
|
10251
10263
|
while (frameEnd >= 0) {
|
|
@@ -10256,6 +10268,7 @@ function openRelayEventStream(token, handlers) {
|
|
|
10256
10268
|
}
|
|
10257
10269
|
}
|
|
10258
10270
|
} catch {} finally {
|
|
10271
|
+
if (staleTimer) clearInterval(staleTimer);
|
|
10259
10272
|
if (closed) return;
|
|
10260
10273
|
handlers.disconnected?.();
|
|
10261
10274
|
scheduleReconnect();
|
|
@@ -10265,13 +10278,14 @@ function openRelayEventStream(token, handlers) {
|
|
|
10265
10278
|
return { close() {
|
|
10266
10279
|
closed = true;
|
|
10267
10280
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
10268
|
-
abort
|
|
10281
|
+
activeAbort?.abort();
|
|
10269
10282
|
} };
|
|
10270
10283
|
}
|
|
10271
10284
|
async function api(method, path, body) {
|
|
10272
10285
|
const opts = {
|
|
10273
10286
|
method,
|
|
10274
|
-
headers: {}
|
|
10287
|
+
headers: {},
|
|
10288
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
|
10275
10289
|
};
|
|
10276
10290
|
const headers = opts.headers;
|
|
10277
10291
|
if (authToken) headers["X-Agent-Relay-Token"] = authToken;
|
|
@@ -12991,10 +13005,13 @@ var useRelayStore = create$1()(persist((set, get) => ({
|
|
|
12991
13005
|
connectSSE() {
|
|
12992
13006
|
get().disconnectSSE();
|
|
12993
13007
|
set({ _es: openRelayEventStream(get().authToken, {
|
|
12994
|
-
connected: () =>
|
|
12995
|
-
|
|
12996
|
-
|
|
12997
|
-
|
|
13008
|
+
connected: () => {
|
|
13009
|
+
set(get().connectionError ? {
|
|
13010
|
+
connected: true,
|
|
13011
|
+
connectionError: false
|
|
13012
|
+
} : { connected: true });
|
|
13013
|
+
get().refreshLiveData();
|
|
13014
|
+
},
|
|
12998
13015
|
disconnected: () => set({ connected: false }),
|
|
12999
13016
|
message: (event, data) => {
|
|
13000
13017
|
if (event === "connected") return;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { emitRelayEvent } from "./events";
|
|
2
|
+
import { getNotificationsConfig } from "./config-store";
|
|
3
|
+
import { notifySystemMessage } from "./notify";
|
|
4
|
+
import type { WorkspaceRecord } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface BranchLandedInput {
|
|
7
|
+
/**
|
|
8
|
+
* The workspace as it was AT land time — `branch` must be the branch that landed,
|
|
9
|
+
* captured before any land-and-continue recycle repoints the row (#206). `ownerAgentId`
|
|
10
|
+
* is the author the "landed" notice is pushed to.
|
|
11
|
+
*/
|
|
12
|
+
workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
|
|
13
|
+
/** SHA the base now points at after the land. */
|
|
14
|
+
mergedSha?: string;
|
|
15
|
+
/** Subject line of the landed commit, when the orchestrator reported it. */
|
|
16
|
+
subject?: string;
|
|
17
|
+
/** Fresh branch the worktree was recycled onto (land-and-continue), if any. */
|
|
18
|
+
newBranch?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* #239 — turn an authoritative land completion into a relay-driven push so the author
|
|
23
|
+
* stops polling to learn it merged. Always emits the durable `branch.landed` event (the
|
|
24
|
+
* rest of the bus does the same); only the agent-facing push is gated, since it wakes the
|
|
25
|
+
* recipient. Offline authors get it on next poll via store-ahead (#234).
|
|
26
|
+
*
|
|
27
|
+
* Agents-on-main fan-out (the second #239 recipient class) lands in a follow-up commit.
|
|
28
|
+
*/
|
|
29
|
+
export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
30
|
+
const { workspace } = input;
|
|
31
|
+
const base = workspace.baseRef ?? "base";
|
|
32
|
+
const landedBranch = workspace.branch;
|
|
33
|
+
const shortSha = input.mergedSha ? input.mergedSha.slice(0, 12) : undefined;
|
|
34
|
+
|
|
35
|
+
emitRelayEvent({
|
|
36
|
+
type: "branch.landed",
|
|
37
|
+
source: "server",
|
|
38
|
+
subject: workspace.id,
|
|
39
|
+
data: {
|
|
40
|
+
workspaceId: workspace.id,
|
|
41
|
+
repoRoot: workspace.repoRoot,
|
|
42
|
+
branch: landedBranch,
|
|
43
|
+
base,
|
|
44
|
+
sha: input.mergedSha,
|
|
45
|
+
subject: input.subject,
|
|
46
|
+
author: workspace.ownerAgentId,
|
|
47
|
+
newBranch: input.newBranch,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const config = getNotificationsConfig();
|
|
52
|
+
if (!config.enabled || !config.branchLanded) return;
|
|
53
|
+
|
|
54
|
+
const author = workspace.ownerAgentId;
|
|
55
|
+
if (!author) return;
|
|
56
|
+
|
|
57
|
+
const branchLabel = landedBranch ? `\`${landedBranch}\`` : "Your branch";
|
|
58
|
+
const shaLabel = shortSha ? ` as \`${shortSha}\`` : "";
|
|
59
|
+
const subjectLabel = input.subject ? ` — "${input.subject}"` : "";
|
|
60
|
+
const continueLabel = input.newBranch
|
|
61
|
+
? ` You're now on \`${input.newBranch}\` — keep working there.`
|
|
62
|
+
: " Worktree reclaimed.";
|
|
63
|
+
|
|
64
|
+
notifySystemMessage(author, {
|
|
65
|
+
subject: "Your branch landed",
|
|
66
|
+
body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${continueLabel}`,
|
|
67
|
+
payload: {
|
|
68
|
+
kind: "branch.landed",
|
|
69
|
+
workspaceId: workspace.id,
|
|
70
|
+
repoRoot: workspace.repoRoot,
|
|
71
|
+
branch: landedBranch,
|
|
72
|
+
base,
|
|
73
|
+
sha: input.mergedSha,
|
|
74
|
+
newBranch: input.newBranch,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
package/src/config-store.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
InsightsConfig,
|
|
11
11
|
ManagedAgentState,
|
|
12
12
|
ManagedAgentStatus,
|
|
13
|
+
NotificationsConfig,
|
|
13
14
|
SpawnApprovalMode,
|
|
14
15
|
SpawnPolicy,
|
|
15
16
|
SpawnProvider,
|
|
@@ -24,6 +25,8 @@ const STEWARD_NAMESPACE = "steward";
|
|
|
24
25
|
const STEWARD_KEY = "default";
|
|
25
26
|
const INSIGHTS_NAMESPACE = "insights";
|
|
26
27
|
const INSIGHTS_KEY = "default";
|
|
28
|
+
const NOTIFICATIONS_NAMESPACE = "notifications";
|
|
29
|
+
const NOTIFICATIONS_KEY = "default";
|
|
27
30
|
const WORKSPACE_NAMESPACE = "workspace";
|
|
28
31
|
const WORKSPACE_KEY = "default";
|
|
29
32
|
const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
|
|
@@ -460,6 +463,26 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
|
|
|
460
463
|
};
|
|
461
464
|
}
|
|
462
465
|
|
|
466
|
+
// Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
|
|
467
|
+
// operator can flip the master switch or individual events off via the generic
|
|
468
|
+
// config route. Push messages wake recipients, so they must be suppressible.
|
|
469
|
+
const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
|
|
470
|
+
enabled: true,
|
|
471
|
+
branchLanded: true,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
function validateNotificationsConfig(value: unknown): NotificationsConfig {
|
|
475
|
+
if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
|
|
476
|
+
return {
|
|
477
|
+
enabled: value.enabled === undefined
|
|
478
|
+
? NOTIFICATIONS_CONFIG_DEFAULTS.enabled
|
|
479
|
+
: cleanBoolean(value.enabled, "enabled"),
|
|
480
|
+
branchLanded: value.branchLanded === undefined
|
|
481
|
+
? NOTIFICATIONS_CONFIG_DEFAULTS.branchLanded
|
|
482
|
+
: cleanBoolean(value.branchLanded, "branchLanded"),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
463
486
|
// Global workspace provisioning config for isolated worktrees (#159 follow-up).
|
|
464
487
|
// Defaults seed the two untracked paths an isolated agent almost always needs:
|
|
465
488
|
// the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
|
|
@@ -487,6 +510,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
|
|
|
487
510
|
if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
|
|
488
511
|
if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
|
|
489
512
|
if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
|
|
513
|
+
if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
|
|
490
514
|
if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
|
|
491
515
|
if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
|
|
492
516
|
return value;
|
|
@@ -620,6 +644,13 @@ export function getInsightsConfigEntry(): ConfigEntry<InsightsConfig> {
|
|
|
620
644
|
};
|
|
621
645
|
}
|
|
622
646
|
|
|
647
|
+
/** Lifecycle-notification config (#239), merged over defaults (always usable). */
|
|
648
|
+
export function getNotificationsConfig(): NotificationsConfig {
|
|
649
|
+
const entry = getConfig<Partial<NotificationsConfig>>(NOTIFICATIONS_NAMESPACE, NOTIFICATIONS_KEY);
|
|
650
|
+
if (!entry) return { ...NOTIFICATIONS_CONFIG_DEFAULTS };
|
|
651
|
+
return validateNotificationsConfig({ ...NOTIFICATIONS_CONFIG_DEFAULTS, ...entry.value });
|
|
652
|
+
}
|
|
653
|
+
|
|
623
654
|
export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEntry<InsightsConfig> {
|
|
624
655
|
return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
|
|
625
656
|
}
|
package/src/maintenance.ts
CHANGED
|
@@ -27,14 +27,13 @@ import {
|
|
|
27
27
|
releaseExpiredMergeLeases,
|
|
28
28
|
releaseOrphanedTasks,
|
|
29
29
|
runDbMaintenance,
|
|
30
|
-
sendMessage,
|
|
31
30
|
sweepArtifacts,
|
|
32
31
|
updateWorkspaceStatus,
|
|
33
32
|
} from "./db";
|
|
34
33
|
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
35
34
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
36
35
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
37
|
-
import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
36
|
+
import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
38
37
|
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
39
38
|
import { getStewardConfig } from "./config-store";
|
|
40
39
|
import { ensureRepoSteward } from "./steward";
|
|
@@ -46,11 +45,11 @@ import {
|
|
|
46
45
|
emitAgentStatus,
|
|
47
46
|
emitMessageClaimReleased,
|
|
48
47
|
emitMessageExpired,
|
|
49
|
-
emitNewMessage,
|
|
50
48
|
emitOrchestratorStatus,
|
|
51
49
|
emitPoolBindingChanged,
|
|
52
50
|
emitTaskChanged,
|
|
53
51
|
} from "./sse";
|
|
52
|
+
import { notifySystemMessage } from "./notify";
|
|
54
53
|
import { pruneExpiredTokenRecords } from "./token-db";
|
|
55
54
|
import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
|
|
56
55
|
|
|
@@ -83,7 +82,10 @@ const STEWARD_WAKE_COOLDOWN_MS = Number(process.env.AGENT_RELAY_STEWARD_WAKE_COO
|
|
|
83
82
|
const stewardEscalationMs = () => Number(process.env.AGENT_RELAY_WORKSPACE_STEWARD_ESCALATION_MS) || 60 * 60 * 1000;
|
|
84
83
|
const stewardFallbackTarget = () => (process.env.AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK || "").trim();
|
|
85
84
|
// Statuses that need an owner — a stranded one of these is what escalation rescues.
|
|
86
|
-
|
|
85
|
+
// Derived from the shared ready-to-land set (#242) plus `conflict`, so a stranded
|
|
86
|
+
// `ready` worktree (no online steward) escalates to the fallback target instead of
|
|
87
|
+
// rotting silently — same gap that left the original #242 branch parked.
|
|
88
|
+
const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict"]);
|
|
87
89
|
// Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
|
|
88
90
|
// in-flight (cleanup_requested) states are skipped.
|
|
89
91
|
const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
|
|
@@ -394,7 +396,7 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
394
396
|
{
|
|
395
397
|
id: "workspace-auto-merge",
|
|
396
398
|
title: "Workspace auto-merge",
|
|
397
|
-
description: "Auto-merge any non-conflicting review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
|
|
399
|
+
description: "Auto-merge any non-conflicting ready/review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
|
|
398
400
|
intervalMs: WORKSPACE_AUTO_MERGE_INTERVAL_MS,
|
|
399
401
|
runOnStart: false,
|
|
400
402
|
timeoutMs: 60 * 1000,
|
|
@@ -532,15 +534,11 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
|
|
|
532
534
|
const policyName = ensureRepoSteward(ws.repoRoot);
|
|
533
535
|
if (!policyName) return null;
|
|
534
536
|
try {
|
|
535
|
-
|
|
536
|
-
from: "system",
|
|
537
|
-
to: `policy:${policyName}`,
|
|
538
|
-
kind: "system",
|
|
537
|
+
notifySystemMessage(`policy:${policyName}`, {
|
|
539
538
|
subject: `Steward: ${ws.status} workspace needs attention`,
|
|
540
539
|
body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
|
|
541
540
|
payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
|
|
542
541
|
});
|
|
543
|
-
emitNewMessage(msg);
|
|
544
542
|
getLifecycleManager().onMessageForPolicy(policyName);
|
|
545
543
|
patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
|
|
546
544
|
return policyName;
|
|
@@ -631,15 +629,11 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
631
629
|
if (woke) notifiedStewards.push(woke);
|
|
632
630
|
} else if (ws.stewardAgentId) {
|
|
633
631
|
try {
|
|
634
|
-
|
|
635
|
-
from: "system",
|
|
636
|
-
to: ws.stewardAgentId,
|
|
637
|
-
kind: "system",
|
|
632
|
+
notifySystemMessage(ws.stewardAgentId, {
|
|
638
633
|
subject: "Workspace merge conflict",
|
|
639
634
|
body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
|
|
640
635
|
payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
|
|
641
636
|
});
|
|
642
|
-
emitNewMessage(msg);
|
|
643
637
|
notifiedStewards.push(ws.stewardAgentId);
|
|
644
638
|
} catch {
|
|
645
639
|
// Steward unregistered/stale — the activity event still records it.
|
|
@@ -657,9 +651,11 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
657
651
|
return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards };
|
|
658
652
|
}
|
|
659
653
|
|
|
660
|
-
// Deterministic auto-land (Layer 0, issue #167 / #207). Walk the "ready to
|
|
661
|
-
// queue (
|
|
662
|
-
//
|
|
654
|
+
// Deterministic auto-land (Layer 0, issue #167 / #207 / #242). Walk the "ready to
|
|
655
|
+
// land" queue (isolated worktrees in any READY_TO_LAND status — `ready` from
|
|
656
|
+
// `relay_workspace_ready`, or `review_requested` from a failed-merge retry) and
|
|
657
|
+
// land any whose merge is predicted conflict-free, via the shared lease-serialized
|
|
658
|
+
// merge helper — even
|
|
663
659
|
// when the base moved on (behind>0): mergeRebaseFf rebases onto the current base
|
|
664
660
|
// before fast-forwarding. Only a predicted conflict or an unknown merge state is
|
|
665
661
|
// left for the steward; clean parallel work lands with no agent in the loop.
|
|
@@ -669,7 +665,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
669
665
|
if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
|
|
670
666
|
|
|
671
667
|
const candidates = listWorkspaces().filter(
|
|
672
|
-
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && ws.status
|
|
668
|
+
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && READY_TO_LAND_STATUSES.has(ws.status),
|
|
673
669
|
);
|
|
674
670
|
const stewardEnabled = getStewardConfig().enabled;
|
|
675
671
|
const merged: string[] = [];
|
|
@@ -738,7 +734,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
738
734
|
function notifyTarget(target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
|
|
739
735
|
if (!target) return null;
|
|
740
736
|
try {
|
|
741
|
-
|
|
737
|
+
notifySystemMessage(target, { subject, body, payload });
|
|
742
738
|
return target;
|
|
743
739
|
} catch {
|
|
744
740
|
return null;
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sendMessage } from "./db";
|
|
2
|
+
import { emitNewMessage } from "./sse";
|
|
3
|
+
import type { Message, MessageKind } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface SystemNotifyOptions {
|
|
6
|
+
subject?: string;
|
|
7
|
+
body: string;
|
|
8
|
+
payload?: Record<string, unknown>;
|
|
9
|
+
/** Defaults to "system" — a bypass-targeting kind that wakes the recipient like a prompt. */
|
|
10
|
+
kind?: MessageKind;
|
|
11
|
+
/** Sender id; defaults to "system". */
|
|
12
|
+
from?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Post a system DM to one agent and fan it out over the bus. This is the one home for
|
|
17
|
+
* "relay tells an agent something happened" — store-ahead delivers it on next poll if the
|
|
18
|
+
* recipient is offline (#234). Used by the GC sweep (maintenance) and lifecycle events (#239).
|
|
19
|
+
*/
|
|
20
|
+
export function notifySystemMessage(to: string, opts: SystemNotifyOptions): Message {
|
|
21
|
+
const msg = sendMessage({
|
|
22
|
+
from: opts.from ?? "system",
|
|
23
|
+
to,
|
|
24
|
+
kind: opts.kind ?? "system",
|
|
25
|
+
subject: opts.subject,
|
|
26
|
+
body: opts.body,
|
|
27
|
+
payload: opts.payload,
|
|
28
|
+
});
|
|
29
|
+
emitNewMessage(msg);
|
|
30
|
+
return msg;
|
|
31
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -177,6 +177,7 @@ import {
|
|
|
177
177
|
WORKSPACE_ACTIONS,
|
|
178
178
|
} from "./workspace-actions";
|
|
179
179
|
import { describeWorkspacePhase, landReceipt, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
180
|
+
import { notifyBranchLanded } from "./branch-landed";
|
|
180
181
|
import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
|
|
181
182
|
import {
|
|
182
183
|
getComponentAuth,
|
|
@@ -4478,6 +4479,9 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4478
4479
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4479
4480
|
const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4480
4481
|
if (workspaceId && resultStatus) {
|
|
4482
|
+
// Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
|
|
4483
|
+
// branch name + author (#239 branch.landed push) come from this pre-mutation state.
|
|
4484
|
+
const landedWorkspace = getWorkspace(workspaceId);
|
|
4481
4485
|
updateWorkspaceStatus(workspaceId, resultStatus, {
|
|
4482
4486
|
mergeResult: command.result,
|
|
4483
4487
|
mergeCommandId: command.id,
|
|
@@ -4491,10 +4495,20 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4491
4495
|
// Land-and-continue (#206): the worktree was recycled onto a fresh branch.
|
|
4492
4496
|
// Repoint the row so the next merge targets the live branch, not the deleted one.
|
|
4493
4497
|
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
4498
|
+
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
4494
4499
|
if (newBranch) {
|
|
4495
|
-
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
4496
4500
|
setWorkspaceBranch(workspaceId, newBranch, mergedSha);
|
|
4497
4501
|
}
|
|
4502
|
+
// #239 — push the author a "your branch landed" notice (no polling). Only on a
|
|
4503
|
+
// real land; a no-op resolution (#230) merged nothing, so it earns no notice.
|
|
4504
|
+
if (command.result.merged === true && landedWorkspace) {
|
|
4505
|
+
notifyBranchLanded({
|
|
4506
|
+
workspace: landedWorkspace,
|
|
4507
|
+
mergedSha,
|
|
4508
|
+
subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
|
|
4509
|
+
newBranch,
|
|
4510
|
+
});
|
|
4511
|
+
}
|
|
4498
4512
|
}
|
|
4499
4513
|
} else if (command.status === "failed" && command.correlationId) {
|
|
4500
4514
|
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
package/src/workspace-phase.ts
CHANGED
|
@@ -21,6 +21,26 @@ import type { WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
|
21
21
|
// initialize primer (don't brief an agent on a dead workspace). Was duplicated.
|
|
22
22
|
export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
|
|
23
23
|
|
|
24
|
+
// The "handed off, waiting to land" statuses — an agent has finished and the
|
|
25
|
+
// auto-merge-back is responsible for getting the branch onto base. SINGLE HOME:
|
|
26
|
+
// the auto-land consumer (maintenance `autoMergeCleanFastForwards`) and the
|
|
27
|
+
// strand-escalation set MUST both derive from this. They drifted before (#242):
|
|
28
|
+
// `relay_workspace_ready` sets `ready`, but the consumer only scanned
|
|
29
|
+
// `review_requested`, so a clean `ready` worktree was never a merge candidate and
|
|
30
|
+
// parked forever while this phase view kept reporting "healthy, wait." Producer
|
|
31
|
+
// and consumer now read the same set so a `ready` can never silently fall out of
|
|
32
|
+
// the land queue again. (`review_requested` is the same healthy hand-off state —
|
|
33
|
+
// it's also where a failed auto-merge lands for a retry, see routes.ts.)
|
|
34
|
+
export const READY_TO_LAND_STATUSES = new Set<WorkspaceStatus>(["ready", "review_requested"]);
|
|
35
|
+
|
|
36
|
+
// How long a workspace may sit in a ready-to-land status before the directive
|
|
37
|
+
// projection stops saying "healthy, just wait" and surfaces it as needs-attention
|
|
38
|
+
// (#242 watchdog). A clean auto-merge runs ~every 2 min, so a handful of missed
|
|
39
|
+
// sweeps means something is wrong (wrong status filter, no online orchestrator,
|
|
40
|
+
// an unpushed branch, a wedged steward) and the agent/human should be told —
|
|
41
|
+
// instead of the old behavior where it looked healthy for 90 minutes.
|
|
42
|
+
export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
|
|
43
|
+
|
|
24
44
|
export type WorkspacePhase =
|
|
25
45
|
| "working" // active — your turn: commit, then mark ready
|
|
26
46
|
| "land-pending" // ready | review_requested — handed off; auto-merge will land it
|
|
@@ -66,7 +86,17 @@ const READY_ACTION: WorkspaceNextAction = {
|
|
|
66
86
|
// Map every WorkspaceStatus to the branch agent's mental model. Statuses that
|
|
67
87
|
// look scary but are healthy (review_requested, conflict) carry actionNeeded:false
|
|
68
88
|
// and an explicit "not your job" hint.
|
|
69
|
-
|
|
89
|
+
//
|
|
90
|
+
// `opts.now` (defaults to wall-clock) drives the #242 stall watchdog: a workspace
|
|
91
|
+
// pending-to-land past LAND_PENDING_STALL_MS flips from the "healthy, wait" view
|
|
92
|
+
// to needs-attention with a real blocker, so the status surface the agent polls
|
|
93
|
+
// can't keep masking a stuck land. The clock is `readyAt` (set once when the agent
|
|
94
|
+
// marks ready, immune to the heartbeat `updated_at` bump) — not `updatedAt`, which
|
|
95
|
+
// keeps ticking on every heartbeat and made the stall look fresh forever.
|
|
96
|
+
export function describeWorkspacePhase(
|
|
97
|
+
workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId" | "readyAt">,
|
|
98
|
+
opts: { now?: number; stallMs?: number } = {},
|
|
99
|
+
): WorkspacePhaseView {
|
|
70
100
|
switch (workspace.status) {
|
|
71
101
|
case "active":
|
|
72
102
|
return {
|
|
@@ -78,10 +108,27 @@ export function describeWorkspacePhase(workspace: Pick<WorkspaceRecord, "status"
|
|
|
78
108
|
blockers: [],
|
|
79
109
|
};
|
|
80
110
|
case "ready":
|
|
81
|
-
case "review_requested":
|
|
111
|
+
case "review_requested": {
|
|
82
112
|
// The #235 crux: these are the SAME healthy "handed off, waiting" state.
|
|
83
113
|
// `review_requested` reads like an escalation but is the normal post-ready
|
|
84
114
|
// node; an absent steward is the healthy case, not a stall.
|
|
115
|
+
const now = opts.now ?? Date.now();
|
|
116
|
+
const stallMs = opts.stallMs ?? LAND_PENDING_STALL_MS;
|
|
117
|
+
const pendingMs = typeof workspace.readyAt === "number" ? now - workspace.readyAt : undefined;
|
|
118
|
+
// #242 watchdog: past the bound this is no longer "healthy, wait." Surface it
|
|
119
|
+
// as needs-attention with a real blocker instead of the anti-panic view, so
|
|
120
|
+
// the agent (and the dashboard) stop reporting a wedged land as healthy.
|
|
121
|
+
if (pendingMs !== undefined && pendingMs > stallMs) {
|
|
122
|
+
const mins = Math.round(pendingMs / 60_000);
|
|
123
|
+
return {
|
|
124
|
+
phase: "land-pending",
|
|
125
|
+
headline: `Stalled — handed off ${mins} min ago but still hasn't landed. A clean auto-merge runs every ~2 min, so this is past the healthy window and likely stuck (no online orchestrator, an unpushed branch, or a wedged merge/steward).`,
|
|
126
|
+
hint: "Do NOT merge, push, rebase, or touch the main checkout yourself. Flag this to a human or the repo steward — the auto-merge/steward path isn't progressing and needs attention.",
|
|
127
|
+
actionNeeded: true,
|
|
128
|
+
nextActions: [WAIT_ACTION],
|
|
129
|
+
blockers: [`pending land for ~${mins} min with no progress — auto-merge/steward isn't landing it`],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
85
132
|
return {
|
|
86
133
|
phase: "land-pending",
|
|
87
134
|
headline: "Handed off — waiting for the auto-merge to land your branch. This is the normal, healthy post-ready state (not an escalation).",
|
|
@@ -90,6 +137,7 @@ export function describeWorkspacePhase(workspace: Pick<WorkspaceRecord, "status"
|
|
|
90
137
|
nextActions: [WAIT_ACTION],
|
|
91
138
|
blockers: [],
|
|
92
139
|
};
|
|
140
|
+
}
|
|
93
141
|
case "merge_planned":
|
|
94
142
|
return {
|
|
95
143
|
phase: "landing",
|
|
@@ -157,7 +205,7 @@ export function worktreeMcpInstructions(workspace: Pick<WorkspaceRecord, "branch
|
|
|
157
205
|
`You are in an isolated git worktree on branch ${branch}, based on ${base} — NOT the main checkout. ${base} moves under you as other agents land in parallel; that's expected.`,
|
|
158
206
|
"Changes reach the base via: commit your work, then call `relay_workspace_ready`. Relay rebases onto the latest base, lands, and pushes for you.",
|
|
159
207
|
"Do NOT push, rebase, merge, resolve conflicts, or `cd` into the main checkout — Relay (and a steward, spawned only if a clean auto-merge isn't possible) own all of that.",
|
|
160
|
-
"After `ready` the status is `
|
|
208
|
+
"After `ready` the status is `ready` (a normal, healthy hand-off state, not a stall). Call `relay_workspace_status` with `wait:true` to block until your branch lands; you'll then continue on a fresh rebased branch (name gains a `--N` suffix).",
|
|
161
209
|
"Call `relay_workspace_status` anytime to see where you are and the exact next step.",
|
|
162
210
|
].join("\n");
|
|
163
211
|
}
|