agent-relay-server 0.119.6 → 0.119.9
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 +3 -3
- package/public/assets/{MessageBubble-Azda2GIf.js → MessageBubble-U5sX3SNZ.js} +2 -2
- package/public/assets/{MessageBubble-Azda2GIf.js.map → MessageBubble-U5sX3SNZ.js.map} +1 -1
- package/public/assets/PlanGraphPanel-Bi2gvXeE.js +2 -0
- package/public/assets/PlanGraphPanel-Bi2gvXeE.js.map +1 -0
- package/public/assets/{activity-B0hz4XPy.js → activity-Cwkz51pG.js} +2 -2
- package/public/assets/{activity-B0hz4XPy.js.map → activity-Cwkz51pG.js.map} +1 -1
- package/public/assets/{agent-profiles-BF0oSOTq.js → agent-profiles-BgddVqRp.js} +2 -2
- package/public/assets/{agent-profiles-BF0oSOTq.js.map → agent-profiles-BgddVqRp.js.map} +1 -1
- package/public/assets/{agents-CW4--j3Q.js → agents-CiGYBw0h.js} +2 -2
- package/public/assets/{agents-CW4--j3Q.js.map → agents-CiGYBw0h.js.map} +1 -1
- package/public/assets/{automation-S6_bQ2t-.js → automation-BBJB4RaE.js} +2 -2
- package/public/assets/{automation-S6_bQ2t-.js.map → automation-BBJB4RaE.js.map} +1 -1
- package/public/assets/{chat-hoFhhvgy.js → chat-DS578gZy.js} +2 -2
- package/public/assets/{chat-hoFhhvgy.js.map → chat-DS578gZy.js.map} +1 -1
- package/public/assets/{formatted-body-BHm145gP.js → formatted-body-DYUVqWym.js} +3 -3
- package/public/assets/{formatted-body-BHm145gP.js.map → formatted-body-DYUVqWym.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CvE31j9F.js → formatted-body-impl-DdcyxVO0.js} +2 -2
- package/public/assets/{formatted-body-impl-CvE31j9F.js.map → formatted-body-impl-DdcyxVO0.js.map} +1 -1
- package/public/assets/{index-TamtghSr.js → index-CqcAqQKW.js} +6 -6
- package/public/assets/{index-TamtghSr.js.map → index-CqcAqQKW.js.map} +1 -1
- package/public/assets/{insights-Ci9ftVfl.js → insights-BunHbsTA.js} +2 -2
- package/public/assets/{insights-Ci9ftVfl.js.map → insights-BunHbsTA.js.map} +1 -1
- package/public/assets/{maintenance-bVhNbr33.js → maintenance-cICBVcpJ.js} +2 -2
- package/public/assets/{maintenance-bVhNbr33.js.map → maintenance-cICBVcpJ.js.map} +1 -1
- package/public/assets/{managed-agents-D5HO4dhI.js → managed-agents-BK9_Gi-M.js} +2 -2
- package/public/assets/{managed-agents-D5HO4dhI.js.map → managed-agents-BK9_Gi-M.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DO-p83dz.js → markdown-preview-impl-EZfcTHG9.js} +2 -2
- package/public/assets/{markdown-preview-impl-DO-p83dz.js.map → markdown-preview-impl-EZfcTHG9.js.map} +1 -1
- package/public/assets/{memory-D72o8umQ.js → memory-Vi-8HvK3.js} +2 -2
- package/public/assets/{memory-D72o8umQ.js.map → memory-Vi-8HvK3.js.map} +1 -1
- package/public/assets/{messages-5L_Bc_9w.js → messages-DScIiBMI.js} +2 -2
- package/public/assets/{messages-5L_Bc_9w.js.map → messages-DScIiBMI.js.map} +1 -1
- package/public/assets/{orchestrators-CnfgkQpl.js → orchestrators-DLogldXx.js} +2 -2
- package/public/assets/{orchestrators-CnfgkQpl.js.map → orchestrators-DLogldXx.js.map} +1 -1
- package/public/assets/{overview-Bu39NIyR.js → overview-B_RQAQR8.js} +2 -2
- package/public/assets/{overview-Bu39NIyR.js.map → overview-B_RQAQR8.js.map} +1 -1
- package/public/assets/{pairs-WuamP5W5.js → pairs-CSiPQ7E2.js} +2 -2
- package/public/assets/{pairs-WuamP5W5.js.map → pairs-CSiPQ7E2.js.map} +1 -1
- package/public/assets/{prompt-settings-BCrQreU6.js → prompt-settings-DsDi_Xpd.js} +2 -2
- package/public/assets/{prompt-settings-BCrQreU6.js.map → prompt-settings-DsDi_Xpd.js.map} +1 -1
- package/public/assets/{registry-kHKn0xwg.js → registry-DHFUWIdb.js} +2 -2
- package/public/assets/{registry-kHKn0xwg.js.map → registry-DHFUWIdb.js.map} +1 -1
- package/public/assets/{security-Dk5t3r_K.js → security-D2nHkJH6.js} +2 -2
- package/public/assets/{security-Dk5t3r_K.js.map → security-D2nHkJH6.js.map} +1 -1
- package/public/assets/{select-BdsZQfU2.js → select-BjNcommA.js} +2 -2
- package/public/assets/{select-BdsZQfU2.js.map → select-BjNcommA.js.map} +1 -1
- package/public/assets/{settings-EpjzQHnZ.js → settings-DiUEq54n.js} +2 -2
- package/public/assets/{settings-EpjzQHnZ.js.map → settings-DiUEq54n.js.map} +1 -1
- package/public/assets/{tasks-CedMQHbg.js → tasks-Dx8GVQiR.js} +2 -2
- package/public/assets/{tasks-CedMQHbg.js.map → tasks-Dx8GVQiR.js.map} +1 -1
- package/public/assets/{teams-BlHBo5Qt.js → teams-DQtf7bha.js} +2 -2
- package/public/assets/{teams-BlHBo5Qt.js.map → teams-DQtf7bha.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-gGqYpsjq.js → terminal-viewer-impl-D5rhXV4u.js} +2 -2
- package/public/assets/{terminal-viewer-impl-gGqYpsjq.js.map → terminal-viewer-impl-D5rhXV4u.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-D7opMqtU.js → work-queue-BfWNDp7U.js} +2 -2
- package/public/assets/{work-queue-D7opMqtU.js.map → work-queue-BfWNDp7U.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/commands-db.ts +72 -20
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +7 -0
- package/src/db/orchestrators.ts +1 -1
- package/src/db/provider-quotas.ts +2 -34
- package/src/db/schema.ts +2 -0
- package/src/db/work-evidence.ts +38 -0
- package/src/db/workspaces.ts +48 -3
- package/src/lifecycle-manager.ts +2 -4
- package/src/maintenance/jobs.ts +10 -1
- package/src/maintenance/workspaces.ts +71 -13
- package/src/routes/commands.ts +20 -4
- package/src/routes/workspaces.ts +6 -11
- package/src/services/shutdown-agent.ts +6 -1
- package/src/services/spawn-agent.ts +50 -7
- package/src/services/transient-agent-reaper.ts +82 -23
- package/src/workspace-actions.ts +26 -16
- package/src/workspace-auto-merge.ts +95 -13
- package/src/workspace-host.ts +55 -0
- package/src/workspace-merge.ts +3 -4
- package/src/workspace-orphans.ts +78 -55
- package/src/workspace-probe.ts +2 -3
- package/public/assets/PlanGraphPanel-B9TN_KQr.js +0 -2
- package/public/assets/PlanGraphPanel-B9TN_KQr.js.map +0 -1
|
@@ -13,13 +13,14 @@ import {
|
|
|
13
13
|
updateWorkspaceStatus,
|
|
14
14
|
setWorkspaceBranch,
|
|
15
15
|
} from "../db";
|
|
16
|
-
import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
|
|
16
|
+
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "../types";
|
|
17
17
|
import { notifyStewardHandoff, reconcileLandedWorkspace } from "../branch-landed";
|
|
18
18
|
import { resolveStewardIdentity } from "../steward-identity";
|
|
19
19
|
import { getStewardConfig } from "../config-store";
|
|
20
20
|
import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-completion";
|
|
21
21
|
import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
|
|
22
22
|
import { wakeRepoSteward } from "../workspace-auto-merge";
|
|
23
|
+
import { workspaceOwnerOrchestrator } from "../workspace-host";
|
|
23
24
|
import {
|
|
24
25
|
CONFLICT_SCAN_STATUSES,
|
|
25
26
|
DAY_MS,
|
|
@@ -44,7 +45,7 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
|
|
|
44
45
|
const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
|
|
45
46
|
|
|
46
47
|
for (const ws of candidates) {
|
|
47
|
-
const orch =
|
|
48
|
+
const orch = workspaceOwnerOrchestrator(ws, orchestrators, { requireApiUrl: true });
|
|
48
49
|
if (!orch?.apiUrl) continue;
|
|
49
50
|
const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: LANDED_RECONCILE_STATUSES.has(ws.status) });
|
|
50
51
|
if (!preview || (preview as { available?: false }).available === false) continue;
|
|
@@ -129,6 +130,31 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
|
|
|
129
130
|
return { scanned: candidates.length, flagged, cleared, merged, prRecycled, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
type AutoAbandonDecision =
|
|
134
|
+
| { kind: "abandon" }
|
|
135
|
+
| { kind: "defer"; reason: string }
|
|
136
|
+
| { kind: "landed"; preview: WorkspaceMergePreview };
|
|
137
|
+
|
|
138
|
+
function previewProvesLanded(preview: WorkspaceMergePreview): boolean {
|
|
139
|
+
const cleanWorktree = (preview.dirtyCount ?? 0) === 0;
|
|
140
|
+
return preview.landed === true
|
|
141
|
+
|| preview.prMerged === true
|
|
142
|
+
|| preview.noop === true
|
|
143
|
+
|| (cleanWorktree && (preview.unmergedAhead === 0 || preview.ahead === 0));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function reviewAutoAbandonDecision(ws: WorkspaceRecord, orchestrators: ReturnType<typeof listOrchestrators>): Promise<AutoAbandonDecision> {
|
|
147
|
+
const owner = workspaceOwnerOrchestrator(ws, orchestrators, { requireApiUrl: true });
|
|
148
|
+
if (!owner?.apiUrl) return ws.orchestratorId ? { kind: "defer", reason: "owner-unavailable" } : { kind: "abandon" };
|
|
149
|
+
const preview = await fetchHostMergePreview(owner.apiUrl, ws, { checkPr: true });
|
|
150
|
+
if (!preview || (preview as { available?: false }).available === false) return { kind: "defer", reason: "owner-unavailable" };
|
|
151
|
+
const p = preview as WorkspaceMergePreview;
|
|
152
|
+
if (p.error) return { kind: "defer", reason: "preview-error" };
|
|
153
|
+
if (previewProvesLanded(p)) return { kind: "landed", preview: p };
|
|
154
|
+
if ((p.dirtyCount ?? 0) > 0) return { kind: "defer", reason: "dirty-worktree" };
|
|
155
|
+
return { kind: "abandon" };
|
|
156
|
+
}
|
|
157
|
+
|
|
132
158
|
export async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
133
159
|
const now = Date.now();
|
|
134
160
|
const cutoff = now - WORKSPACE_RETENTION_MS;
|
|
@@ -147,6 +173,7 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
|
147
173
|
const escalatedIds: string[] = [];
|
|
148
174
|
const reassignedIds: string[] = [];
|
|
149
175
|
const escalationTargets: string[] = [];
|
|
176
|
+
const reconciledLandedIds: string[] = [];
|
|
150
177
|
const escalationMs = stewardEscalationMs();
|
|
151
178
|
const fallbackTarget = stewardFallbackTarget();
|
|
152
179
|
for (const ws of all) {
|
|
@@ -159,6 +186,20 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
|
159
186
|
const stewardOnline = Boolean(steward && getAgent(steward) && getAgent(steward)!.status !== "offline");
|
|
160
187
|
const strandedAt = typeof meta.strandedAt === "number" ? meta.strandedAt : undefined;
|
|
161
188
|
|
|
189
|
+
if (!stewardOnline) {
|
|
190
|
+
const orch = workspaceOwnerOrchestrator(fresh, listOrchestrators().filter((o) => o.status === "online" && o.apiUrl), { requireApiUrl: true });
|
|
191
|
+
const preview = orch?.apiUrl ? await fetchHostMergePreview(orch.apiUrl, fresh, { checkPr: true }) : null;
|
|
192
|
+
if (preview && !("available" in preview) && !preview.error && !preview.missing) {
|
|
193
|
+
const landed = previewProvesLanded(preview);
|
|
194
|
+
if (landed) {
|
|
195
|
+
reconcileLandedWorkspace(fresh, preview);
|
|
196
|
+
patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: undefined });
|
|
197
|
+
reconciledLandedIds.push(fresh.id);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
162
203
|
if (stewardOnline) {
|
|
163
204
|
if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
|
|
164
205
|
const sent = notifyTarget(
|
|
@@ -206,30 +247,45 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
|
206
247
|
}
|
|
207
248
|
|
|
208
249
|
const abandonedIds: string[] = [];
|
|
250
|
+
const autoAbandonDeferred: string[] = [];
|
|
209
251
|
const notifiedStewards: string[] = [];
|
|
252
|
+
const reviewOrchestrators = listOrchestrators();
|
|
210
253
|
for (const ws of all) {
|
|
211
254
|
if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
255
|
+
const fresh = getWorkspace(ws.id);
|
|
256
|
+
if (!fresh || fresh.status !== "review_requested") continue;
|
|
257
|
+
const decision = await reviewAutoAbandonDecision(fresh, reviewOrchestrators);
|
|
258
|
+
if (decision.kind === "defer") {
|
|
259
|
+
autoAbandonDeferred.push(`${fresh.id}:${decision.reason}`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (decision.kind === "landed") {
|
|
263
|
+
reconcileLandedWorkspace(fresh, decision.preview);
|
|
264
|
+
reconciledLandedIds.push(fresh.id);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
updateWorkspaceStatus(fresh.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
|
|
269
|
+
abandonedIds.push(fresh.id);
|
|
270
|
+
const target = fresh.stewardAgentId ?? fallbackTarget;
|
|
215
271
|
const sent = notifyTarget(
|
|
216
272
|
"workspace.auto_abandoned",
|
|
217
|
-
{ workspaceId:
|
|
273
|
+
{ workspaceId: fresh.id, repoRoot: fresh.repoRoot },
|
|
218
274
|
target,
|
|
219
275
|
"Workspace auto-abandoned",
|
|
220
|
-
`Workspace \`${
|
|
221
|
-
{ kind: "workspace.auto-abandoned", workspaceId:
|
|
276
|
+
`Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
|
|
277
|
+
{ kind: "workspace.auto-abandoned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch },
|
|
222
278
|
);
|
|
223
279
|
if (sent) notifiedStewards.push(sent);
|
|
224
280
|
createActivityEvent({
|
|
225
|
-
clientId: `workspace-gc-abandon-${
|
|
281
|
+
clientId: `workspace-gc-abandon-${fresh.id}-${now}`,
|
|
226
282
|
kind: "state",
|
|
227
283
|
title: "Workspace auto-abandoned",
|
|
228
|
-
body: `${
|
|
229
|
-
meta:
|
|
284
|
+
body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - review_requested for ${Math.round((now - fresh.updatedAt) / DAY_MS)}d`,
|
|
285
|
+
meta: fresh.branch ?? fresh.id,
|
|
230
286
|
icon: "ti-clock-x",
|
|
231
287
|
view: "orchestrators",
|
|
232
|
-
metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId:
|
|
288
|
+
metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id },
|
|
233
289
|
});
|
|
234
290
|
}
|
|
235
291
|
}
|
|
@@ -255,12 +311,14 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
|
255
311
|
return {
|
|
256
312
|
prunedTerminal: terminalIds,
|
|
257
313
|
autoAbandoned: abandonedIds,
|
|
314
|
+
autoAbandonDeferred,
|
|
258
315
|
notifiedStewards,
|
|
259
316
|
pruneCommands,
|
|
260
317
|
releasedLeaseRepos,
|
|
261
318
|
escalated: escalatedIds,
|
|
262
319
|
reassigned: reassignedIds,
|
|
263
320
|
escalationTargets,
|
|
321
|
+
reconciledLanded: reconciledLandedIds,
|
|
264
322
|
};
|
|
265
323
|
}
|
|
266
324
|
|
|
@@ -288,7 +346,7 @@ export async function refreshIdleWorkspaces(): Promise<Record<string, unknown>>
|
|
|
288
346
|
const now = Date.now();
|
|
289
347
|
|
|
290
348
|
for (const ws of candidates) {
|
|
291
|
-
const orch =
|
|
349
|
+
const orch = workspaceOwnerOrchestrator(ws, orchestrators);
|
|
292
350
|
if (!orch?.agentId) { skipped.push(`${ws.id}:no-orchestrator`); continue; }
|
|
293
351
|
|
|
294
352
|
// Gate: owner must exist and not be busy.
|
package/src/routes/commands.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { handleReviewerMergeResult } from "../reviewer-pipeline";
|
|
|
22
22
|
import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
|
|
23
23
|
import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
|
|
24
24
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
25
|
+
import { commandTargetsWorkspaceOwner } from "../workspace-host";
|
|
25
26
|
|
|
26
27
|
const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
|
|
27
28
|
|
|
@@ -227,10 +228,13 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
227
228
|
if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
|
|
228
229
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
229
230
|
if (workspaceId) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
const workspace = getWorkspace(workspaceId);
|
|
232
|
+
if (workspace && commandTargetsWorkspaceOwner(command, workspace)) {
|
|
233
|
+
updateWorkspaceStatus(workspaceId, "cleaned", {
|
|
234
|
+
cleanupResult: command.result,
|
|
235
|
+
cleanupCommandId: command.id,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
234
238
|
}
|
|
235
239
|
}
|
|
236
240
|
if (command.type === "workspace.idle-refresh" && command.status === "succeeded" && isRecord(command.result)) {
|
|
@@ -261,6 +265,10 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
261
265
|
// Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
|
|
262
266
|
// branch name + author (#239 branch.landed push) come from this pre-mutation state.
|
|
263
267
|
const landedWorkspace = getWorkspace(workspaceId);
|
|
268
|
+
if (landedWorkspace && !commandTargetsWorkspaceOwner(command, landedWorkspace)) {
|
|
269
|
+
auditCommandOutcome(command);
|
|
270
|
+
return json(command);
|
|
271
|
+
}
|
|
264
272
|
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
265
273
|
const normalizedStatus = command.result.merged === true
|
|
266
274
|
&& !newBranch
|
|
@@ -361,6 +369,10 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
361
369
|
// auto-merge job backs off after N instead of busy-looping (#638).
|
|
362
370
|
const current = getWorkspace(command.correlationId);
|
|
363
371
|
if (current && current.status === "merge_planned") {
|
|
372
|
+
if (!commandTargetsWorkspaceOwner(command, current)) {
|
|
373
|
+
auditCommandOutcome(command);
|
|
374
|
+
return json(command);
|
|
375
|
+
}
|
|
364
376
|
const failedResultStatus = isRecord(command.result)
|
|
365
377
|
? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
|
|
366
378
|
: undefined;
|
|
@@ -420,6 +432,10 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
420
432
|
// a status a human/agent has since moved on (merge_planned, abandoned, …).
|
|
421
433
|
const current = getWorkspace(workspaceId);
|
|
422
434
|
if (current && (current.status === "active" || current.status === "ready")) {
|
|
435
|
+
if (!commandTargetsWorkspaceOwner(command, current)) {
|
|
436
|
+
auditCommandOutcome(command);
|
|
437
|
+
return json(command);
|
|
438
|
+
}
|
|
423
439
|
if (removed) {
|
|
424
440
|
// The owner exited and the worktree had no work — the orchestrator
|
|
425
441
|
// already deleted it on disk. Drop the DB row immediately instead of
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
|
15
15
|
import { workspaceActiveClaim } from "../workspace-claim";
|
|
16
16
|
import { resolveCoordinatorAgentId } from "../steward-identity";
|
|
17
17
|
import { relayToken } from "../config";
|
|
18
|
+
import { workspaceOwnerOrchestrator } from "../workspace-host";
|
|
18
19
|
|
|
19
20
|
export const getWorkspaces: Handler = (req) => {
|
|
20
21
|
try {
|
|
@@ -44,9 +45,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
44
45
|
if (workspace.mode !== "isolated" || !workspace.worktreePath) {
|
|
45
46
|
return json({ available: false, reason: "no isolated worktree" });
|
|
46
47
|
}
|
|
47
|
-
const orch = listOrchestrators()
|
|
48
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
49
|
-
);
|
|
48
|
+
const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
|
|
50
49
|
if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
|
|
51
50
|
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
52
51
|
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
@@ -287,9 +286,7 @@ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
287
286
|
|
|
288
287
|
async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
|
|
289
288
|
if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
|
|
290
|
-
const orch = listOrchestrators()
|
|
291
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
292
|
-
);
|
|
289
|
+
const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
|
|
293
290
|
if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
|
|
294
291
|
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
295
292
|
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
@@ -342,7 +339,7 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
|
|
|
342
339
|
// #640 — surface the branch agent's coordinator (owner's `spawnedBy` lineage) so a steward
|
|
343
340
|
// inspecting the workspace knows who to escalate to, not just `repoRoot`.
|
|
344
341
|
const coordinatorAgentId = resolveCoordinatorAgentId(workspace.ownerAgentId);
|
|
345
|
-
const orch = listOrchestrators()
|
|
342
|
+
const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { onlineOnly: false });
|
|
346
343
|
const orchOnline = Boolean(orch) && orch!.status === "online";
|
|
347
344
|
const fetched = await fetchWorkspaceGitState(workspace);
|
|
348
345
|
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
@@ -380,8 +377,7 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
380
377
|
// a destructive-action footgun. Now an explicit id narrows the candidate set.
|
|
381
378
|
const workspaceId = cleanString(body.workspaceId, "workspaceId", { max: 160 });
|
|
382
379
|
const dryRun = body.dryRun !== false; // safe by default
|
|
383
|
-
const landedOnly =
|
|
384
|
-
const offlineOwnerOnly = body.offlineOwnerOnly !== false;
|
|
380
|
+
const landedOnly = true;
|
|
385
381
|
|
|
386
382
|
const candidates = listWorkspaces().filter((ws) =>
|
|
387
383
|
ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status)
|
|
@@ -395,7 +391,6 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
395
391
|
const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
|
|
396
392
|
const ownerOnline = isOwnerAlive(ws.ownerAgentId);
|
|
397
393
|
if (ownerOnline) continue; // never clean a live owner's worktree
|
|
398
|
-
if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
|
|
399
394
|
if (workspaceActiveClaim(ws)) continue; // respect steward claims
|
|
400
395
|
const fetched = await fetchWorkspaceGitState(ws);
|
|
401
396
|
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
@@ -420,7 +415,7 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
|
420
415
|
}
|
|
421
416
|
rows.push(row);
|
|
422
417
|
}
|
|
423
|
-
return json({ dryRun, landedOnly,
|
|
418
|
+
return json({ dryRun, landedOnly, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
|
|
424
419
|
};
|
|
425
420
|
|
|
426
421
|
export const postWorkspaceAction: Handler = async (req, params) => {
|
|
@@ -79,6 +79,11 @@ function str(v: unknown): string | undefined {
|
|
|
79
79
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export function callerOwnsSpawnedAgent(ctx: AuthContext, target: AgentCard | null | undefined): boolean {
|
|
83
|
+
const callerId = ctx.callerAgentId;
|
|
84
|
+
return Boolean(callerId && target?.spawnedBy === callerId);
|
|
85
|
+
}
|
|
86
|
+
|
|
82
87
|
/** Build a ShutdownAgentInput from a WS-bus `agent.shutdown` command frame's target +
|
|
83
88
|
* params. Lives here (not in bus.ts) so the bus transport stays a thin dispatcher and the
|
|
84
89
|
* shutdown-input shape has one home. */
|
|
@@ -123,7 +128,7 @@ export function authorizeShutdown(ctx: AuthContext, input: ShutdownAgentInput):
|
|
|
123
128
|
const target = input.agentId
|
|
124
129
|
? getAgent(input.agentId)
|
|
125
130
|
: listAgents().find((a) => a.meta?.spawnRequestId === input.spawnRequestId);
|
|
126
|
-
if (!target || target
|
|
131
|
+
if (!target || !callerOwnsSpawnedAgent(ctx, target)) {
|
|
127
132
|
throw new ShutdownAuthError(
|
|
128
133
|
input.agentId
|
|
129
134
|
? `agent ${input.agentId} is not one of your spawned children`
|
|
@@ -28,7 +28,7 @@ import { DEFAULT_USER_ID, errMessage, isRecord, stringValue } from "agent-relay-
|
|
|
28
28
|
import { statSync } from "node:fs";
|
|
29
29
|
import { dirname, resolve } from "node:path";
|
|
30
30
|
import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, getTaskDetail, listAgents, listProjects, listWorkspaces, recordTaskSpawnRequested, resolveAgentOwnership, resolveProjectForCwd, resolveScopedAnchor, rootsForProject, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
|
|
31
|
-
import { createCommand } from "../commands-db";
|
|
31
|
+
import { countInFlightSpawnCommands, createCommand, createSpawnCommandWithinQuota, findSpawnCommandByRequestId } from "../commands-db";
|
|
32
32
|
import { emitCommandEvent } from "../command-events";
|
|
33
33
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
|
|
34
34
|
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
@@ -48,12 +48,14 @@ import { hasComponentScope, isComponentAuthorizedFor } from "../security";
|
|
|
48
48
|
import { isPathWithinBase } from "../utils";
|
|
49
49
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
50
50
|
import type { AuthContext } from "./auth-context";
|
|
51
|
-
import
|
|
51
|
+
import { callerOwnsSpawnedAgent } from "./shutdown-agent";
|
|
52
|
+
import type { AgentCard, AgentLifecycle, Command, CommandStatus, CreateCommandInput, SpawnApprovalMode, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode } from "../types";
|
|
52
53
|
import { isOwnerAlive, liveAgentCwds, worktreeInUseByLiveAgent } from "../workspace-merge";
|
|
53
54
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
54
55
|
|
|
55
56
|
/** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
|
|
56
57
|
const SPAWN_SCOPE = "command:spawn";
|
|
58
|
+
const ACTIVE_SPAWN_COMMAND_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
|
|
57
59
|
|
|
58
60
|
function hasGitMarker(dir: string): boolean {
|
|
59
61
|
try {
|
|
@@ -362,11 +364,14 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
362
364
|
throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
|
|
363
365
|
}
|
|
364
366
|
const quota = ctx.constraints?.maxSpawnedAgents ?? 0;
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
367
|
-
throw new ValidationError(`spawn quota reached (${
|
|
367
|
+
const occupied = countLiveSpawnedAgents(callerId) + countInFlightSpawnCommands(callerId);
|
|
368
|
+
if (occupied >= quota) {
|
|
369
|
+
throw new ValidationError(`spawn quota reached (${occupied}/${quota} live or in-flight children) — shut one down or wait for one to exit`);
|
|
368
370
|
}
|
|
369
371
|
}
|
|
372
|
+
const spawnQuota = callerId
|
|
373
|
+
? { parentId: callerId, maxSpawnedAgents: ctx.constraints?.maxSpawnedAgents ?? 0 }
|
|
374
|
+
: undefined;
|
|
370
375
|
// Gate the child spawn on the token's legit bounds: the target orchestrator AND the spawn cwd
|
|
371
376
|
// (`cwdPrefixes`/`cwd` constraint). Excludes the #323 footguns (spawnRequestIds/policies) — those
|
|
372
377
|
// are self-scoping per-spawn ids the CHILD gets fresh, so gating on them makes the quota
|
|
@@ -379,6 +384,31 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
379
384
|
// the raw token actor. Recorded in the command payload, the token mint, and the audit row.
|
|
380
385
|
const requestedBy = callerId ?? taskCreatorAgentId ?? input.requestedBy ?? ctx.actor.id;
|
|
381
386
|
|
|
387
|
+
const requestedSpawnRequestId = input.spawnRequestId?.trim();
|
|
388
|
+
if (requestedSpawnRequestId) {
|
|
389
|
+
const registeredAgent = listAgents().find((agent) => agent.meta?.spawnRequestId === requestedSpawnRequestId);
|
|
390
|
+
const activeCommand = findSpawnCommandByRequestId(requestedSpawnRequestId, ACTIVE_SPAWN_COMMAND_STATUSES);
|
|
391
|
+
const existingCommand = activeCommand ?? (registeredAgent ? findSpawnCommandByRequestId(requestedSpawnRequestId) : null);
|
|
392
|
+
if (existingCommand || registeredAgent) {
|
|
393
|
+
const existingParent = existingCommand ? stringValue(existingCommand.params.spawnedBy) : undefined;
|
|
394
|
+
if (callerId && existingParent !== callerId && !callerOwnsSpawnedAgent(ctx, registeredAgent)) {
|
|
395
|
+
throw new ValidationError(`spawnRequestId ${requestedSpawnRequestId} is already in use`);
|
|
396
|
+
}
|
|
397
|
+
if (!existingCommand) {
|
|
398
|
+
throw new ValidationError(`spawnRequestId ${requestedSpawnRequestId} is already registered to agent ${registeredAgent!.id}`);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
spawnRequestId: requestedSpawnRequestId,
|
|
403
|
+
orchestratorId: stringValue(existingCommand.params.orchestratorId) ?? orchestrator.id,
|
|
404
|
+
provider: (stringValue(existingCommand.params.provider) as SpawnProvider | undefined) ?? input.provider,
|
|
405
|
+
agentId: registeredAgent?.id ?? null,
|
|
406
|
+
registered: Boolean(registeredAgent),
|
|
407
|
+
command: existingCommand,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
382
412
|
// #405 — durable project binding: the project whose registered root contains the spawn cwd.
|
|
383
413
|
// Stamped on both the child token and an auto-scaffolded Task row so issueless spawned work
|
|
384
414
|
// stays filterable by project.
|
|
@@ -579,7 +609,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
579
609
|
// command payload.
|
|
580
610
|
const expectReply = input.expectReply === true;
|
|
581
611
|
|
|
582
|
-
const
|
|
612
|
+
const commandInput: CreateCommandInput = {
|
|
583
613
|
type: "agent.spawn",
|
|
584
614
|
source: "system",
|
|
585
615
|
target: orchestrator.agentId,
|
|
@@ -619,7 +649,20 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
619
649
|
// so there's no agent row to resolve `spawnedBy` from) can be routed back to it.
|
|
620
650
|
...(lineageAgentId ? { extra: { spawnedBy: lineageAgentId } } : {}),
|
|
621
651
|
}),
|
|
622
|
-
}
|
|
652
|
+
};
|
|
653
|
+
let command: Command;
|
|
654
|
+
if (spawnQuota) {
|
|
655
|
+
const quotaResult = createSpawnCommandWithinQuota(commandInput, {
|
|
656
|
+
...spawnQuota,
|
|
657
|
+
liveChildren: () => countLiveSpawnedAgents(spawnQuota.parentId),
|
|
658
|
+
});
|
|
659
|
+
if (!quotaResult.command) {
|
|
660
|
+
throw new ValidationError(`spawn quota reached (${quotaResult.occupiedSlots}/${spawnQuota.maxSpawnedAgents} live or in-flight children) — shut one down or wait for one to exit`);
|
|
661
|
+
}
|
|
662
|
+
command = quotaResult.command;
|
|
663
|
+
} else {
|
|
664
|
+
command = createCommand(commandInput);
|
|
665
|
+
}
|
|
623
666
|
emitCommandEvent(command, "command.requested");
|
|
624
667
|
|
|
625
668
|
// ONE spawn-request audit row, identical across transports (the MCP path previously emitted
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
1
2
|
import { createCommand } from "../commands-db";
|
|
2
3
|
import { emitRelayEvent } from "../events";
|
|
3
|
-
import { createActivityEvent, getAgent, listAgents, listWorkspaces, recordTerminalEvent } from "../db";
|
|
4
|
+
import { createActivityEvent, getAgent, hasArtifactCreatedBy, listAgents, listFinalResponseCaptureMessages, listOrchestrators, listWorkspaces, recordTerminalEvent } from "../db";
|
|
4
5
|
import { routeNotification } from "../notification-router";
|
|
5
6
|
import { isProviderBlocked } from "../provider-state";
|
|
7
|
+
import { relayToken } from "../config";
|
|
8
|
+
import { workspaceOwnerOrchestrator } from "../workspace-host";
|
|
6
9
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
7
10
|
import { finalizeEphemeralTasksOnReap } from "./task-entity";
|
|
8
|
-
import type { AgentCard, Command, WorkspaceRecord } from "../types";
|
|
11
|
+
import type { AgentCard, Command, WorkspaceGitState, WorkspaceRecord } from "../types";
|
|
9
12
|
|
|
10
13
|
// The single home for "dispose a transient/one-shot agent" — the canonical `agent.shutdown`
|
|
11
14
|
// command + its events/terminal-marker/activity. Extracted so the idle-grace scan below AND
|
|
@@ -65,19 +68,28 @@ export function resetTransientTrackerForTests(): void {
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
// How many commits the agent's branch has ahead of its base. Returns null when
|
|
68
|
-
// the check can't run (missing fields,
|
|
71
|
+
// the check can't run (missing fields, host/probe failure) — callers treat null as
|
|
69
72
|
// "unknown" and preserve the safe #614 hold-pending bias: only 0 is fast-reaped.
|
|
70
|
-
function defaultCommitsAhead(ws: WorkspaceRecord): number | null {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
async function defaultCommitsAhead(ws: WorkspaceRecord): Promise<number | null> {
|
|
74
|
+
if (!ws.branch || !ws.worktreePath) return null;
|
|
75
|
+
const orch = workspaceOwnerOrchestrator(ws, listOrchestrators(), { requireApiUrl: true });
|
|
76
|
+
if (!orch?.apiUrl) return null;
|
|
77
|
+
const query = new URLSearchParams({ path: ws.worktreePath });
|
|
78
|
+
if (ws.baseRef) query.set("baseRef", ws.baseRef);
|
|
79
|
+
if (ws.baseSha) query.set("baseSha", ws.baseSha);
|
|
80
|
+
const headers: Record<string, string> = {};
|
|
81
|
+
const token = relayToken();
|
|
82
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
73
83
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
78
|
-
if (
|
|
79
|
-
const
|
|
80
|
-
|
|
84
|
+
const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, {
|
|
85
|
+
headers,
|
|
86
|
+
signal: AbortSignal.timeout(8_000),
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) return null;
|
|
89
|
+
const state = await res.json() as WorkspaceGitState;
|
|
90
|
+
if (state.missing || state.error || (state.dirtyCount ?? 0) > 0) return null;
|
|
91
|
+
const n = state.landed === true ? 0 : (state.unmergedAhead ?? state.ahead);
|
|
92
|
+
return typeof n === "number" && Number.isFinite(n) ? n : null;
|
|
81
93
|
} catch {
|
|
82
94
|
return null;
|
|
83
95
|
}
|
|
@@ -96,9 +108,9 @@ function reapProtected(agent: ReturnType<typeof listAgents>[number]): boolean {
|
|
|
96
108
|
return agent.status !== "idle" || !agent.ready || isProviderBlocked(agent);
|
|
97
109
|
}
|
|
98
110
|
|
|
99
|
-
let _commitsAheadFn: (ws: WorkspaceRecord) => number | null = defaultCommitsAhead;
|
|
111
|
+
let _commitsAheadFn: (ws: WorkspaceRecord) => number | null | Promise<number | null> = defaultCommitsAhead;
|
|
100
112
|
|
|
101
|
-
export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null): void {
|
|
113
|
+
export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null | Promise<number | null>): void {
|
|
102
114
|
_commitsAheadFn = fn;
|
|
103
115
|
}
|
|
104
116
|
export function resetCommitsAheadForTests(): void {
|
|
@@ -116,14 +128,61 @@ function workspaceProducedWork(ws: WorkspaceRecord | undefined): boolean {
|
|
|
116
128
|
return typeof ws.metadata.mergedAt === "number";
|
|
117
129
|
}
|
|
118
130
|
|
|
119
|
-
|
|
131
|
+
const ACK_ONLY_RESPONSES = new Set([
|
|
132
|
+
"ok",
|
|
133
|
+
"okay",
|
|
134
|
+
"done",
|
|
135
|
+
"finished",
|
|
136
|
+
"complete",
|
|
137
|
+
"completed",
|
|
138
|
+
"ack",
|
|
139
|
+
"acknowledged",
|
|
140
|
+
"thanks",
|
|
141
|
+
"thank you",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const REPORT_SIGNAL_RE = /\b(ship|hold|pass|fail|finding|findings|issue|issues|blocker|blockers|reviewed|verified|tested|tests?|risk|regression|artifact|summary|audited|inspected)\b/i;
|
|
145
|
+
const GENERIC_COMPLETION_RE = /\b(?:i(?:'m| am)|we(?:'re| are))?\s*(?:done|finished|complete|completed)\b|\b(?:task|work)\s+(?:is\s+)?(?:done|finished|complete|completed)\b|\b(?:all set|nothing else|nothing more|no further action|no more to do|do not need to do anything else|don't need to do anything else)\b/i;
|
|
146
|
+
|
|
147
|
+
function genericCompletionOnly(normalized: string): boolean {
|
|
148
|
+
return GENERIC_COMPLETION_RE.test(normalized) && !REPORT_SIGNAL_RE.test(normalized);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function substantiveFinalReport(body: string): boolean {
|
|
152
|
+
const normalized = body.trim().replace(/\s+/g, " ");
|
|
153
|
+
if (!normalized) return false;
|
|
154
|
+
const ackKey = normalized.toLowerCase().replace(/[.!?]+$/g, "");
|
|
155
|
+
if (ACK_ONLY_RESPONSES.has(ackKey)) return false;
|
|
156
|
+
if (genericCompletionOnly(normalized)) return false;
|
|
157
|
+
const words = normalized.match(/[A-Za-z0-9][A-Za-z0-9'_-]*/g) ?? [];
|
|
158
|
+
if (normalized.length >= 80 && words.length >= 6) return true;
|
|
159
|
+
if (normalized.length >= 40 && words.length >= 8) return true;
|
|
160
|
+
return normalized.length >= 30
|
|
161
|
+
&& words.length >= 5
|
|
162
|
+
&& REPORT_SIGNAL_RE.test(normalized);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function reportOnlyWorkEvidence(agent: AgentCard): { producedWork: boolean; reason?: string } {
|
|
166
|
+
const since = typeof agent.createdAt === "number" && Number.isFinite(agent.createdAt) ? agent.createdAt : 0;
|
|
167
|
+
if (hasArtifactCreatedBy(agent.id, since)) return { producedWork: true, reason: "artifact-upload" };
|
|
168
|
+
const reports = listFinalResponseCaptureMessages(agent.id, since);
|
|
169
|
+
const hasSubstantiveReport = reports.some((message) => {
|
|
170
|
+
if (agent.spawnedBy && message.to !== agent.spawnedBy) return false;
|
|
171
|
+
return substantiveFinalReport(message.body);
|
|
172
|
+
});
|
|
173
|
+
return hasSubstantiveReport ? { producedWork: true, reason: "final-response-report" } : { producedWork: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function transientLandComplete(agent: AgentCard): Promise<{ complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string }> {
|
|
120
177
|
const agentId = agent.id;
|
|
178
|
+
const reportEvidence = reportOnlyWorkEvidence(agent);
|
|
121
179
|
const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
|
|
122
|
-
if (!isolated.length) return { complete: true, producedWork:
|
|
180
|
+
if (!isolated.length) return { complete: true, producedWork: reportEvidence.producedWork, reason: reportEvidence.reason ?? "no-isolated-workspace" };
|
|
123
181
|
const live = isolated.find((ws) => !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
124
182
|
if (!live) {
|
|
125
183
|
const latest = isolated[0];
|
|
126
|
-
|
|
184
|
+
const producedWork = workspaceProducedWork(latest);
|
|
185
|
+
return { complete: true, producedWork: producedWork || reportEvidence.producedWork, workspaceId: latest?.id, status: latest?.status, reason: producedWork ? "terminal-workspace" : reportEvidence.reason ?? "terminal-workspace" };
|
|
127
186
|
}
|
|
128
187
|
const mergedAt = typeof live.metadata.mergedAt === "number" ? live.metadata.mergedAt : undefined;
|
|
129
188
|
const landReportedAt = typeof live.metadata.landReportedAt === "number" ? live.metadata.landReportedAt : undefined;
|
|
@@ -134,8 +193,8 @@ function transientLandComplete(agent: AgentCard): { complete: boolean; producedW
|
|
|
134
193
|
// instead of holding it pending a land that will never happen. Only fires when the
|
|
135
194
|
// branch is provably empty (count === 0); null (unknown/git error) falls through to
|
|
136
195
|
// the safe #614 hold-pending bias below.
|
|
137
|
-
if (live.status === "active" && _commitsAheadFn(live) === 0) {
|
|
138
|
-
return { complete: true, producedWork:
|
|
196
|
+
if (live.status === "active" && await _commitsAheadFn(live) === 0) {
|
|
197
|
+
return { complete: true, producedWork: reportEvidence.producedWork, workspaceId: live.id, status: live.status, reason: reportEvidence.reason ?? "empty-branch" };
|
|
139
198
|
}
|
|
140
199
|
return { complete: false, producedWork: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
|
|
141
200
|
}
|
|
@@ -171,7 +230,7 @@ function notifyTransientHeld(agentId: string, workspaceId: string | undefined, s
|
|
|
171
230
|
}
|
|
172
231
|
}
|
|
173
232
|
|
|
174
|
-
export function reapTransientAgents(): Record<string, unknown
|
|
233
|
+
export async function reapTransientAgents(): Promise<Record<string, unknown>> {
|
|
175
234
|
const now = Date.now();
|
|
176
235
|
const grace = transientGraceMs();
|
|
177
236
|
const maxLandWait = transientMaxLandWaitMs();
|
|
@@ -193,7 +252,7 @@ export function reapTransientAgents(): Record<string, unknown> {
|
|
|
193
252
|
transientTracker.set(agent.id, entry);
|
|
194
253
|
const idleForMs = now - entry.firstIdleAt;
|
|
195
254
|
if (idleForMs < grace) { waiting.push(agent.id); continue; }
|
|
196
|
-
const land = transientLandComplete(agent);
|
|
255
|
+
const land = await transientLandComplete(agent);
|
|
197
256
|
if (!land.complete) {
|
|
198
257
|
if (idleForMs >= grace + maxLandWait) {
|
|
199
258
|
held.push(agent.id);
|
|
@@ -212,7 +271,7 @@ export function reapTransientAgents(): Record<string, unknown> {
|
|
|
212
271
|
workspaceId: land.workspaceId,
|
|
213
272
|
workspaceStatus: land.status,
|
|
214
273
|
activityTitle: "Transient agent complete",
|
|
215
|
-
activityBody: "Agent idle grace elapsed and
|
|
274
|
+
activityBody: "Agent idle grace elapsed and transient work is complete; shutdown requested",
|
|
216
275
|
landReason: land.reason,
|
|
217
276
|
now,
|
|
218
277
|
});
|