agent-relay-server 0.120.4 → 0.120.6
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-Cqi3XsZ7.js → MessageBubble-So86mU9-.js} +2 -2
- package/public/assets/{MessageBubble-Cqi3XsZ7.js.map → MessageBubble-So86mU9-.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-D6Um05oW.js → PlanGraphPanel-DBZYAsww.js} +2 -2
- package/public/assets/{PlanGraphPanel-D6Um05oW.js.map → PlanGraphPanel-DBZYAsww.js.map} +1 -1
- package/public/assets/{activity-B0LsQHEz.js → activity-CZNwmXg1.js} +2 -2
- package/public/assets/{activity-B0LsQHEz.js.map → activity-CZNwmXg1.js.map} +1 -1
- package/public/assets/{agent-profiles-O--pe5Ic.js → agent-profiles-BhadoGwi.js} +2 -2
- package/public/assets/{agent-profiles-O--pe5Ic.js.map → agent-profiles-BhadoGwi.js.map} +1 -1
- package/public/assets/agents-DWn1xx9x.js +2 -0
- package/public/assets/{agents-Ii6XlVwR.js.map → agents-DWn1xx9x.js.map} +1 -1
- package/public/assets/{analytics-BCzdYWRT.js → analytics-DifGnk7q.js} +2 -2
- package/public/assets/{analytics-BCzdYWRT.js.map → analytics-DifGnk7q.js.map} +1 -1
- package/public/assets/{automation-Crr3Xzui.js → automation-BExrtKkb.js} +2 -2
- package/public/assets/{automation-Crr3Xzui.js.map → automation-BExrtKkb.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bhw2rloy.js → branch-state-badge-CyC5Qrhc.js} +2 -2
- package/public/assets/{branch-state-badge-Bhw2rloy.js.map → branch-state-badge-CyC5Qrhc.js.map} +1 -1
- package/public/assets/{channels-CKs9rvsn.js → channels-BLkdt2XR.js} +2 -2
- package/public/assets/{channels-CKs9rvsn.js.map → channels-BLkdt2XR.js.map} +1 -1
- package/public/assets/chat-C7OhKtr6.js +2 -0
- package/public/assets/{chat-BCwEUj5R.js.map → chat-C7OhKtr6.js.map} +1 -1
- package/public/assets/{connectors-D_kQR_vw.js → connectors-C0n12Je8.js} +2 -2
- package/public/assets/{connectors-D_kQR_vw.js.map → connectors-C0n12Je8.js.map} +1 -1
- package/public/assets/{formatted-body-q_qzt1L-.js → formatted-body-BCQT2qSb.js} +3 -3
- package/public/assets/{formatted-body-q_qzt1L-.js.map → formatted-body-BCQT2qSb.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CSRGuMcM.js → formatted-body-impl-Bk7wnESl.js} +2 -2
- package/public/assets/{formatted-body-impl-CSRGuMcM.js.map → formatted-body-impl-Bk7wnESl.js.map} +1 -1
- package/public/assets/index-CRraCeo_.js +25 -0
- package/public/assets/index-CRraCeo_.js.map +1 -0
- package/public/assets/{insights-DUhXqZTZ.js → insights-DEgES6jz.js} +2 -2
- package/public/assets/{insights-DUhXqZTZ.js.map → insights-DEgES6jz.js.map} +1 -1
- package/public/assets/{integrations-lbg5CjtE.js → integrations-v14ehskT.js} +2 -2
- package/public/assets/{integrations-lbg5CjtE.js.map → integrations-v14ehskT.js.map} +1 -1
- package/public/assets/{maintenance-CQgneSVf.js → maintenance-BgVB6_gO.js} +2 -2
- package/public/assets/{maintenance-CQgneSVf.js.map → maintenance-BgVB6_gO.js.map} +1 -1
- package/public/assets/{managed-agents-DsnYf6fm.js → managed-agents-8_NKSkhI.js} +3 -3
- package/public/assets/{managed-agents-DsnYf6fm.js.map → managed-agents-8_NKSkhI.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C1ZtGJp6.js → markdown-preview-impl-yxQkrd2M.js} +2 -2
- package/public/assets/{markdown-preview-impl-C1ZtGJp6.js.map → markdown-preview-impl-yxQkrd2M.js.map} +1 -1
- package/public/assets/{memory-SSLUTOI2.js → memory-CJD8eekg.js} +2 -2
- package/public/assets/{memory-SSLUTOI2.js.map → memory-CJD8eekg.js.map} +1 -1
- package/public/assets/{messages-BY26OP4K.js → messages-DLHMZbHZ.js} +2 -2
- package/public/assets/{messages-BY26OP4K.js.map → messages-DLHMZbHZ.js.map} +1 -1
- package/public/assets/{orchestrators-OY7yPzVI.js → orchestrators-DEOep7FX.js} +2 -2
- package/public/assets/{orchestrators-OY7yPzVI.js.map → orchestrators-DEOep7FX.js.map} +1 -1
- package/public/assets/{overview-Dj0u1tPG.js → overview-BgpfO6mt.js} +2 -2
- package/public/assets/{overview-Dj0u1tPG.js.map → overview-BgpfO6mt.js.map} +1 -1
- package/public/assets/{pairs-CrUR50ou.js → pairs-8NVKHh96.js} +2 -2
- package/public/assets/{pairs-CrUR50ou.js.map → pairs-8NVKHh96.js.map} +1 -1
- package/public/assets/{projects-B_AtxHtH.js → projects-CZJW35C7.js} +2 -2
- package/public/assets/{projects-B_AtxHtH.js.map → projects-CZJW35C7.js.map} +1 -1
- package/public/assets/{prompt-settings-CPtYSban.js → prompt-settings-fqyLwUa-.js} +2 -2
- package/public/assets/{prompt-settings-CPtYSban.js.map → prompt-settings-fqyLwUa-.js.map} +1 -1
- package/public/assets/{registry-BgUALWn5.js → registry-BKbLL4mO.js} +2 -2
- package/public/assets/{registry-BgUALWn5.js.map → registry-BKbLL4mO.js.map} +1 -1
- package/public/assets/{security-5lHfGk7g.js → security-BlTFTbq0.js} +2 -2
- package/public/assets/{security-5lHfGk7g.js.map → security-BlTFTbq0.js.map} +1 -1
- package/public/assets/{select-Cgx4QSLj.js → select-Zd_qh0uU.js} +2 -2
- package/public/assets/{select-Cgx4QSLj.js.map → select-Zd_qh0uU.js.map} +1 -1
- package/public/assets/{settings-DFGgxyun.js → settings-nHuSVRkL.js} +2 -2
- package/public/assets/{settings-DFGgxyun.js.map → settings-nHuSVRkL.js.map} +1 -1
- package/public/assets/store-nXGxgz2Q.js +9 -0
- package/public/assets/store-nXGxgz2Q.js.map +1 -0
- package/public/assets/{tasks-BvH5rluu.js → tasks-C3JhQ8vB.js} +2 -2
- package/public/assets/{tasks-BvH5rluu.js.map → tasks-C3JhQ8vB.js.map} +1 -1
- package/public/assets/{teams-VZOIOdoE.js → teams-C9E9Yt39.js} +2 -2
- package/public/assets/{teams-VZOIOdoE.js.map → teams-C9E9Yt39.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js → terminal-viewer-impl-FP0JlsvS.js} +22 -22
- package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js.map → terminal-viewer-impl-FP0JlsvS.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-B6Xk7QNe.js → work-queue-Ema7Hy3s.js} +2 -2
- package/public/assets/{work-queue-B6Xk7QNe.js.map → work-queue-Ema7Hy3s.js.map} +1 -1
- package/public/assets/{workspaces-CPIzAHAe.js → workspaces-DI317fMk.js} +2 -2
- package/public/assets/{workspaces-CPIzAHAe.js.map → workspaces-DI317fMk.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/auto-spawn-dispatch.ts +2 -2
- package/src/commands-db.ts +14 -0
- package/src/db/delivery.ts +85 -3
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +2 -1
- package/src/db/provisioning-callmux.ts +180 -0
- package/src/db/provisioning-registry.ts +45 -374
- package/src/db/provisioning-resolve.ts +245 -0
- package/src/db/stats.ts +10 -8
- package/src/db/task-entity.ts +7 -8
- package/src/lifecycle-manager.ts +11 -12
- package/src/maintenance/jobs.ts +3 -1
- package/src/maintenance/workspaces.ts +28 -47
- package/src/managed-policy-escalation.ts +47 -0
- package/src/mcp-task-tools.ts +4 -5
- package/src/routes/commands.ts +8 -245
- package/src/routes/tasks.ts +3 -5
- package/src/routes/workspaces.ts +58 -22
- package/src/services/issue-lifecycle.ts +23 -8
- package/src/services/task-entity.ts +54 -2
- package/src/task-dispatcher.ts +12 -3
- package/src/workspace-auto-merge.ts +94 -34
- package/src/workspace-command-settle.ts +227 -0
- package/src/workspace-escalation.ts +45 -0
- package/src/workspace-merge-progress.ts +16 -0
- package/src/workspace-orphans.ts +96 -4
- package/public/assets/agents-Ii6XlVwR.js +0 -2
- package/public/assets/chat-BCwEUj5R.js +0 -2
- package/public/assets/index-Ceiwd02X.js +0 -25
- package/public/assets/index-Ceiwd02X.js.map +0 -1
- package/public/assets/store-BM4zM89B.js +0 -9
- package/public/assets/store-BM4zM89B.js.map +0 -1
package/src/routes/commands.ts
CHANGED
|
@@ -1,76 +1,28 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: commands.
|
|
2
2
|
import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authorizeRoute, emitCommand, error, json, parseBody, parseQueryInt, type AgentControlAction, type Handler } from "./_shared";
|
|
3
3
|
import { VALID_WORKSPACE_STATUSES, cleanParams, cleanString, optionalEnum } from "../validation";
|
|
4
|
-
import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchWorkspaceMetadata,
|
|
4
|
+
import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchWorkspaceMetadata, setOrchestratorUpgradeState, updateWorkspaceStatus } from "../db";
|
|
5
5
|
import { applyCommandToRecipe } from "../recipe-runner";
|
|
6
|
-
import { claimMetadataPatch } from "../workspace-claim";
|
|
7
6
|
import { clearActiveMemories, injectAlwaysReloadMemories } from "../memory-service";
|
|
8
7
|
import { getCommand, listCommands, updateCommand } from "../commands-db";
|
|
9
8
|
import { emitOrchestratorStatus } from "../sse";
|
|
10
9
|
import { getCompactionWatch } from "../compaction-watch";
|
|
11
|
-
import { isRecord
|
|
12
|
-
import { storeTextArtifact } from "../artifact-text";
|
|
10
|
+
import { isRecord } from "agent-relay-sdk";
|
|
13
11
|
import { authContextFromRequest } from "../services/auth-context";
|
|
14
12
|
import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
|
|
15
13
|
import { prepareScheduledCommandResultForStorage, settleScheduledCommandTimer } from "../services/scheduler-command";
|
|
16
|
-
import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
|
|
17
14
|
import { ServiceAuthError } from "../services/errors";
|
|
18
|
-
import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded, notifyLandGateFailed, notifyLandGateWarnings } from "../branch-landed";
|
|
19
15
|
import { applyIdleRefreshResult } from "../maintenance/workspaces";
|
|
20
16
|
import { seedProviderConfigs } from "../provider-config-store";
|
|
21
17
|
import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
|
|
22
|
-
import {
|
|
23
|
-
import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
|
|
18
|
+
import { scheduleRepoMerge } from "../workspace-auto-merge";
|
|
24
19
|
import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
|
|
25
20
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
26
21
|
import { commandTargetsWorkspaceOwner } from "../workspace-host";
|
|
22
|
+
import { settleWorkspaceCommand } from "../workspace-command-settle";
|
|
27
23
|
|
|
28
24
|
const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
|
|
29
25
|
|
|
30
|
-
// #902 — normalize a land-gate run result that round-tripped through the command bus
|
|
31
|
-
// (JSON, loosely typed) back into a LandGateRunResult. Returns undefined when the value
|
|
32
|
-
// isn't a gate result, so the caller falls back to the generic land-failed path.
|
|
33
|
-
function normalizeGateRunResult(value: unknown): LandGateRunResult | undefined {
|
|
34
|
-
if (!isRecord(value)) return undefined;
|
|
35
|
-
const name = typeof value.name === "string" ? value.name : undefined;
|
|
36
|
-
const command = typeof value.command === "string" ? value.command : "";
|
|
37
|
-
if (!name) return undefined;
|
|
38
|
-
return {
|
|
39
|
-
name,
|
|
40
|
-
command,
|
|
41
|
-
optional: value.optional === true,
|
|
42
|
-
passed: value.passed === true,
|
|
43
|
-
exitCode: typeof value.exitCode === "number" ? value.exitCode : null,
|
|
44
|
-
timedOut: value.timedOut === true,
|
|
45
|
-
durationMs: typeof value.durationMs === "number" ? value.durationMs : 0,
|
|
46
|
-
outputTail: typeof value.outputTail === "string" ? value.outputTail : "",
|
|
47
|
-
output: typeof value.output === "string" ? value.output : (typeof value.outputTail === "string" ? value.outputTail : ""),
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizeGateWarnings(value: unknown): LandGateRunResult[] {
|
|
52
|
-
if (!Array.isArray(value)) return [];
|
|
53
|
-
return value.map(normalizeGateRunResult).filter((g): g is LandGateRunResult => Boolean(g));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Upload a gate's full output to a relay artifact; returns its id, or undefined on any
|
|
57
|
-
// failure (artifact upload is best-effort — the tail still rides in the notification).
|
|
58
|
-
async function uploadGateOutputArtifact(workspaceId: string, gate: LandGateRunResult): Promise<string | undefined> {
|
|
59
|
-
try {
|
|
60
|
-
const safeName = sanitizeFsName(gate.name, { replacement: "-", maxLen: 60, trimEdge: true, fallback: "gate" });
|
|
61
|
-
const stored = await storeTextArtifact({
|
|
62
|
-
text: gate.output || gate.outputTail || "(no output)",
|
|
63
|
-
filename: `land-gate-${safeName}-${workspaceId}.log`,
|
|
64
|
-
createdBy: "server",
|
|
65
|
-
metadata: { kind: "land-gate-output", workspaceId, gateName: gate.name, gateCommand: gate.command, exitCode: gate.exitCode, timedOut: gate.timedOut },
|
|
66
|
-
});
|
|
67
|
-
return stored.id;
|
|
68
|
-
} catch (error) {
|
|
69
|
-
console.warn(`land-gate artifact upload failed for ${workspaceId} gate "${gate.name}": ${String(error)}`);
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
26
|
function auditCommandOutcome(command: Command): void {
|
|
75
27
|
if (command.status !== "succeeded" && command.status !== "failed") return;
|
|
76
28
|
if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
|
|
@@ -232,204 +184,15 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
232
184
|
if (command.type === "scheduler.command") {
|
|
233
185
|
await settleScheduledCommandTimer(command);
|
|
234
186
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (workspace && commandTargetsWorkspaceOwner(command, workspace)) {
|
|
240
|
-
updateWorkspaceStatus(workspaceId, "cleaned", {
|
|
241
|
-
cleanupResult: command.result,
|
|
242
|
-
cleanupCommandId: command.id,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
187
|
+
const workspaceSettle = settleWorkspaceCommand(command, { onMergeSettled: scheduleRepoMerge });
|
|
188
|
+
if (workspaceSettle.ignoredWrongOwner) {
|
|
189
|
+
auditCommandOutcome(command);
|
|
190
|
+
return json(command);
|
|
246
191
|
}
|
|
247
192
|
if (command.type === "workspace.idle-refresh" && command.status === "succeeded" && isRecord(command.result)) {
|
|
248
193
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
249
194
|
if (workspaceId) applyIdleRefreshResult(workspaceId, command.result);
|
|
250
195
|
}
|
|
251
|
-
if (command.type === "workspace.merge") {
|
|
252
|
-
// Merge settled (either way) — free the per-repo merge lease so the next
|
|
253
|
-
// base merge can proceed (issue #157).
|
|
254
|
-
if (command.status === "succeeded" || command.status === "failed") {
|
|
255
|
-
releaseMergeLease({ commandId: command.id });
|
|
256
|
-
// #237: the lease just freed — drain the next ready/review_requested row for
|
|
257
|
-
// this repo right now instead of waiting for the backstop sweep. This is the
|
|
258
|
-
// on-settle link of the sequential per-repo drain chain (no-op if nothing else
|
|
259
|
-
// is queued, or if AUTO_MERGE is disabled).
|
|
260
|
-
const settledRepoRoot = typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined;
|
|
261
|
-
scheduleRepoMerge(settledRepoRoot);
|
|
262
|
-
}
|
|
263
|
-
if (command.status === "succeeded" && isRecord(command.result)) {
|
|
264
|
-
if (handleReviewerMergeResult(command, command.result)) {
|
|
265
|
-
emitCommand(command);
|
|
266
|
-
auditCommandOutcome(command);
|
|
267
|
-
return json(command);
|
|
268
|
-
}
|
|
269
|
-
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
270
|
-
const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
271
|
-
if (workspaceId && resultStatus) {
|
|
272
|
-
// Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
|
|
273
|
-
// branch name + author (#239 branch.landed push) come from this pre-mutation state.
|
|
274
|
-
const landedWorkspace = getWorkspace(workspaceId);
|
|
275
|
-
if (landedWorkspace && !commandTargetsWorkspaceOwner(command, landedWorkspace)) {
|
|
276
|
-
auditCommandOutcome(command);
|
|
277
|
-
return json(command);
|
|
278
|
-
}
|
|
279
|
-
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
280
|
-
const normalizedStatus = command.result.merged === true
|
|
281
|
-
&& !newBranch
|
|
282
|
-
&& ["review_requested", "merge_planned", "conflict"].includes(resultStatus)
|
|
283
|
-
? "merged"
|
|
284
|
-
: resultStatus;
|
|
285
|
-
updateWorkspaceStatus(workspaceId, normalizedStatus, {
|
|
286
|
-
mergeResult: command.result,
|
|
287
|
-
mergeCommandId: command.id,
|
|
288
|
-
mergedAt: Date.now(),
|
|
289
|
-
// A merge that settles `succeeded` made genuine progress (land / recycle /
|
|
290
|
-
// noop-resolve / PR-opened) — clear any prior no-progress back-off so the lane
|
|
291
|
-
// is attempted normally next time (#638).
|
|
292
|
-
...CLEAR_MERGE_NO_PROGRESS,
|
|
293
|
-
mergeError: undefined,
|
|
294
|
-
...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
|
|
295
|
-
// The land consumes the steward's claim (#208 steward contract / vent
|
|
296
|
-
// #62): a successful land must release it, or `steward inspect` keeps
|
|
297
|
-
// showing a stale claim on the recycled workspace and blocks the next
|
|
298
|
-
// automation. Clearing a null claim (auto-merge path) is a harmless no-op.
|
|
299
|
-
...claimMetadataPatch(true),
|
|
300
|
-
});
|
|
301
|
-
// Land-and-continue (#206): the worktree was recycled onto a fresh branch.
|
|
302
|
-
// Repoint the row so the next merge targets the live branch, not the deleted one.
|
|
303
|
-
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
304
|
-
// base_sha tracks the tip the recycled workspace forks from. On a no-ff land
|
|
305
|
-
// (#287) that's the merge commit (result.baseSha), not the preserved landed
|
|
306
|
-
// commit (mergedSha); fall back to mergedSha for older orchestrators.
|
|
307
|
-
const baseSha = cleanString(command.result.baseSha, "result.baseSha", { max: 64 });
|
|
308
|
-
if (newBranch) {
|
|
309
|
-
setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
|
|
310
|
-
}
|
|
311
|
-
// #239 — push the author a "your branch landed" notice (no polling). Fires on a
|
|
312
|
-
// real land; a no-op resolution (#230) merged nothing, so it earns no notice.
|
|
313
|
-
// #423 — a PR-land RECYCLE is a no-op merge (`merged:false`) but a land DID happen
|
|
314
|
-
// on the remote: the relay carries that ground truth in `params.prLanded`, so the
|
|
315
|
-
// notice fires from it with the recycled `newBranch` — full rebase-ff parity.
|
|
316
|
-
const prLanded = isRecord(command.params.prLanded) ? command.params.prLanded : undefined;
|
|
317
|
-
// #824 — the land advanced a DIRTY base by ref plumbing and the shared checkout was
|
|
318
|
-
// left in a MIXED state (HEAD moved, some landed working files didn't). The land
|
|
319
|
-
// succeeded, but surface the strand LOUDLY so it's reconciled before anything reads/
|
|
320
|
-
// builds/publishes from the stale checkout — never silently as before.
|
|
321
|
-
const baseWorktreeSync = isRecord(command.result.baseWorktreeSync) ? command.result.baseWorktreeSync : undefined;
|
|
322
|
-
if (baseWorktreeSync && baseWorktreeSync.reconciled === false && landedWorkspace) {
|
|
323
|
-
notifyBaseWorktreeStrand({
|
|
324
|
-
workspace: landedWorkspace,
|
|
325
|
-
sync: {
|
|
326
|
-
reconciled: false,
|
|
327
|
-
unsyncedPaths: Array.isArray(baseWorktreeSync.unsyncedPaths) ? baseWorktreeSync.unsyncedPaths.filter((p): p is string => typeof p === "string") : undefined,
|
|
328
|
-
preservedWipPaths: Array.isArray(baseWorktreeSync.preservedWipPaths) ? baseWorktreeSync.preservedWipPaths.filter((p): p is string => typeof p === "string") : undefined,
|
|
329
|
-
message: typeof baseWorktreeSync.message === "string" ? baseWorktreeSync.message : undefined,
|
|
330
|
-
},
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
if ((command.result.merged === true || prLanded) && landedWorkspace) {
|
|
334
|
-
notifyBranchLanded({
|
|
335
|
-
workspace: landedWorkspace,
|
|
336
|
-
mergedSha: prLanded ? cleanString(prLanded.sha, "params.prLanded.sha", { max: 64 }) ?? mergedSha : mergedSha,
|
|
337
|
-
subject: prLanded
|
|
338
|
-
? cleanString(prLanded.subject, "params.prLanded.subject", { max: 200 })
|
|
339
|
-
: cleanString(command.result.subject, "result.subject", { max: 200 }),
|
|
340
|
-
// PR-land subjects carry a trailing `(#PR)` GitHub stamps on the squash — not an
|
|
341
|
-
// issue. Tell the lifecycle hook so it won't close the PR number as an issue (#482).
|
|
342
|
-
prLand: Boolean(prLanded),
|
|
343
|
-
newBranch,
|
|
344
|
-
pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
// #902 — OPTIONAL land gates that failed on a SUCCESSFUL land (merged:true, ref
|
|
348
|
-
// advanced). Warn the worker (tail + combined-output artifact) without blocking.
|
|
349
|
-
// Best-effort + isolated so a warn-notify throw can't undo the completed land.
|
|
350
|
-
const gateWarnings = command.result.merged === true ? normalizeGateWarnings(command.result.gateWarnings) : [];
|
|
351
|
-
if (gateWarnings.length && landedWorkspace) {
|
|
352
|
-
const warnWorkspace = landedWorkspace;
|
|
353
|
-
void (async () => {
|
|
354
|
-
const combined = gateWarnings.map((g) => `### ${g.name} (${g.command}) — ${g.timedOut ? "timed out" : g.exitCode === null ? "could not run" : `exited ${g.exitCode}`}\n${g.output || g.outputTail || "(no output)"}`).join("\n\n");
|
|
355
|
-
let artifactId: string | undefined;
|
|
356
|
-
try {
|
|
357
|
-
artifactId = (await storeTextArtifact({ text: combined, filename: `land-gate-warnings-${warnWorkspace.id}.log`, createdBy: "server", metadata: { kind: "land-gate-warnings", workspaceId: warnWorkspace.id, gateNames: gateWarnings.map((g) => g.name) } })).id;
|
|
358
|
-
} catch (error) {
|
|
359
|
-
console.warn(`land-gate-warnings artifact upload failed for ${warnWorkspace.id}: ${String(error)}`);
|
|
360
|
-
}
|
|
361
|
-
try {
|
|
362
|
-
notifyLandGateWarnings({ workspace: warnWorkspace, warnings: gateWarnings, artifactId });
|
|
363
|
-
} catch (error) {
|
|
364
|
-
console.warn(`land-gate-warnings notify threw for ${warnWorkspace.id}: ${String(error)}`);
|
|
365
|
-
}
|
|
366
|
-
})();
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
} else if (command.status === "failed" && command.correlationId) {
|
|
370
|
-
// Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
|
|
371
|
-
// merge now settles `failed` (orchestrator #638) carrying the result; honor the
|
|
372
|
-
// orchestrator's intended target status when it gave a valid one so a real
|
|
373
|
-
// `conflict` routes to the steward (a non-landable status the auto-merge job
|
|
374
|
-
// skips, which itself stops the loop) rather than bouncing back to the landable
|
|
375
|
-
// `review_requested`. Surface the error and count the no-progress attempt so the
|
|
376
|
-
// auto-merge job backs off after N instead of busy-looping (#638).
|
|
377
|
-
const current = getWorkspace(command.correlationId);
|
|
378
|
-
if (current && current.status === "merge_planned") {
|
|
379
|
-
if (!commandTargetsWorkspaceOwner(command, current)) {
|
|
380
|
-
auditCommandOutcome(command);
|
|
381
|
-
return json(command);
|
|
382
|
-
}
|
|
383
|
-
const failedResultStatus = isRecord(command.result)
|
|
384
|
-
? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
|
|
385
|
-
: undefined;
|
|
386
|
-
const nextStatus: WorkspaceStatus = failedResultStatus && failedResultStatus !== "merge_planned"
|
|
387
|
-
? failedResultStatus
|
|
388
|
-
: "review_requested";
|
|
389
|
-
updateWorkspaceStatus(command.correlationId, nextStatus, {
|
|
390
|
-
mergeCommandId: command.id,
|
|
391
|
-
mergeError: command.error ?? "merge failed",
|
|
392
|
-
...noteMergeNoProgress(current.metadata),
|
|
393
|
-
});
|
|
394
|
-
if (nextStatus === "conflict") {
|
|
395
|
-
emitWorkspaceTaskSemanticNotifications({
|
|
396
|
-
workspace: current,
|
|
397
|
-
transition: "land-conflict",
|
|
398
|
-
actor: "workspace-merge",
|
|
399
|
-
occurrenceKey: `workspace-merge-conflict:${current.id}:${command.id}`,
|
|
400
|
-
outcome: {
|
|
401
|
-
reason: command.error ?? "merge failed",
|
|
402
|
-
commandId: command.id,
|
|
403
|
-
status: nextStatus,
|
|
404
|
-
},
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
const gateFailure = isRecord(command.result) ? normalizeGateRunResult(command.result.gateFailure) : undefined;
|
|
408
|
-
if (gateFailure) {
|
|
409
|
-
// #902 — a REQUIRED land gate failed: the orchestrator aborted WITHOUT advancing
|
|
410
|
-
// the ref and settled status "review_requested" (not "conflict", so the steward
|
|
411
|
-
// path above is never taken). Send the richer worker+coordinator notice (gate name
|
|
412
|
-
// + output tail + full-output artifact) INSTEAD of the generic land-failed wake.
|
|
413
|
-
// Best-effort + isolated: the land already (didn't) happen; a notify/upload throw
|
|
414
|
-
// must not break command settling.
|
|
415
|
-
void (async () => {
|
|
416
|
-
const artifactId = await uploadGateOutputArtifact(current.id, gateFailure);
|
|
417
|
-
try {
|
|
418
|
-
notifyLandGateFailed({ workspace: current, gate: gateFailure, artifactId });
|
|
419
|
-
} catch (error) {
|
|
420
|
-
console.warn(`land-gate-failed notify threw for ${current.id}: ${String(error)}`);
|
|
421
|
-
}
|
|
422
|
-
})();
|
|
423
|
-
} else {
|
|
424
|
-
notifyBranchLandFailed({
|
|
425
|
-
workspace: current,
|
|
426
|
-
commandId: command.id,
|
|
427
|
-
reason: command.error ?? "merge failed",
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
196
|
if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
|
|
434
197
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
435
198
|
const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
package/src/routes/tasks.ts
CHANGED
|
@@ -130,14 +130,12 @@ export const postTaskCreate: Handler = async (req) => {
|
|
|
130
130
|
teamId: cleanString(body.teamId, "teamId", { max: 200 }),
|
|
131
131
|
projectId: cleanString(body.projectId, "projectId", { max: 120 }),
|
|
132
132
|
repo: cleanString(body.repo, "repo", { max: 500 }),
|
|
133
|
+
branch: cleanString(body.branch, "branch", { max: 500 }),
|
|
133
134
|
dispatch: body.dispatch as TaskDispatch | undefined,
|
|
135
|
+
issueRefs: Array.isArray(body.issueRefs) ? body.issueRefs.filter((ref): ref is string => typeof ref === "string") : undefined,
|
|
136
|
+
defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }),
|
|
134
137
|
createdBy: actorOf(req, body.createdBy),
|
|
135
138
|
});
|
|
136
|
-
if (Array.isArray(body.issueRefs)) {
|
|
137
|
-
for (const ref of body.issueRefs) {
|
|
138
|
-
if (typeof ref === "string" && ref.trim()) addTaskEntityRef(created.id, { issue: ref, defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }), actor: actorOf(req, body.createdBy) });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
139
|
if (Array.isArray(body.dependsOn)) {
|
|
142
140
|
for (const dep of body.dependsOn) {
|
|
143
141
|
const depId = parseId(String(dep));
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -104,12 +104,27 @@ function latestWorkspaceByBranch(workspaces: WorkspaceRecord[]): Map<string, Wor
|
|
|
104
104
|
return byBranch;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function
|
|
108
|
-
|
|
107
|
+
function owningOrchestratorsForRepo(repoRoot: string, orchestratorId?: string) {
|
|
108
|
+
const candidates = listOrchestrators().filter((candidate) => candidate.status === "online" && candidate.apiUrl);
|
|
109
|
+
if (orchestratorId) {
|
|
110
|
+
return candidates.filter((candidate) => candidate.id === orchestratorId);
|
|
111
|
+
}
|
|
112
|
+
const ownerIds = new Set(
|
|
113
|
+
listWorkspaces()
|
|
114
|
+
.filter((workspace) => workspace.orchestratorId && resolve(workspace.repoRoot) === resolve(repoRoot))
|
|
115
|
+
.map((workspace) => workspace.orchestratorId!),
|
|
116
|
+
);
|
|
117
|
+
const owners = candidates.filter((candidate) => ownerIds.has(candidate.id));
|
|
118
|
+
if (owners.length > 0) return owners;
|
|
119
|
+
const legacy = candidates.find((candidate) => isPathWithinBase(repoRoot, candidate.baseDir));
|
|
120
|
+
return legacy ? [legacy] : [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function owningOrchestratorForRepo(repoRoot: string, orchestratorId?: string) {
|
|
124
|
+
return owningOrchestratorsForRepo(repoRoot, orchestratorId)[0];
|
|
109
125
|
}
|
|
110
126
|
|
|
111
|
-
async function
|
|
112
|
-
const orch = owningOrchestratorForRepo(repoRoot);
|
|
127
|
+
async function fetchRecoveryBranchesFromOrchestrator(repoRoot: string, orch: NonNullable<ReturnType<typeof owningOrchestratorForRepo>>, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
|
|
113
128
|
if (!orch?.apiUrl) return { branches: [], unavailable: "owning orchestrator offline" };
|
|
114
129
|
const query = new URLSearchParams({ repoRoot });
|
|
115
130
|
if (opts.baseRef) query.set("baseRef", opts.baseRef);
|
|
@@ -122,16 +137,36 @@ async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?:
|
|
|
122
137
|
if (!res.ok) return { branches: [], unavailable: `host returned ${res.status}` };
|
|
123
138
|
const payload = await res.json() as unknown;
|
|
124
139
|
if (!isRecord(payload) || !Array.isArray(payload.branches)) return { branches: [], unavailable: "host returned malformed recovery branch list" };
|
|
125
|
-
return {
|
|
140
|
+
return {
|
|
141
|
+
branches: (payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[]).map((branch) => ({
|
|
142
|
+
...branch,
|
|
143
|
+
orchestratorId: orch.id,
|
|
144
|
+
orchestratorAgentId: orch.agentId,
|
|
145
|
+
})),
|
|
146
|
+
};
|
|
126
147
|
} catch (e) {
|
|
127
148
|
return { branches: [], unavailable: `orchestrator unreachable: ${(e as Error).message}` };
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
151
|
|
|
152
|
+
async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string; orchestratorId?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
|
|
153
|
+
const hosts = owningOrchestratorsForRepo(repoRoot, opts.orchestratorId);
|
|
154
|
+
if (hosts.length === 0) return { branches: [], unavailable: "owning orchestrator offline" };
|
|
155
|
+
const branches: WorkspaceRecoveryBranch[] = [];
|
|
156
|
+
const unavailable: string[] = [];
|
|
157
|
+
for (const orch of hosts) {
|
|
158
|
+
const fetched = await fetchRecoveryBranchesFromOrchestrator(repoRoot, orch, opts);
|
|
159
|
+
branches.push(...fetched.branches);
|
|
160
|
+
if (fetched.unavailable) unavailable.push(`${orch.id}: ${fetched.unavailable}`);
|
|
161
|
+
}
|
|
162
|
+
return { branches, ...(branches.length === 0 && unavailable.length > 0 ? { unavailable: unavailable.join("; ") } : {}) };
|
|
163
|
+
}
|
|
164
|
+
|
|
131
165
|
export const getWorkspaceRecoveryBranches: Handler = async (req) => {
|
|
132
166
|
try {
|
|
133
167
|
const url = new URL(req.url);
|
|
134
168
|
const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
|
|
169
|
+
const orchestratorId = cleanString(url.searchParams.get("orchestratorId") ?? undefined, "orchestratorId", { max: 200 });
|
|
135
170
|
const baseRef = cleanString(url.searchParams.get("baseRef") ?? undefined, "baseRef", { max: 240 });
|
|
136
171
|
const baseSha = cleanString(url.searchParams.get("baseSha") ?? undefined, "baseSha", { max: 80 });
|
|
137
172
|
const includeActive = url.searchParams.get("includeActive") === "1";
|
|
@@ -142,7 +177,7 @@ export const getWorkspaceRecoveryBranches: Handler = async (req) => {
|
|
|
142
177
|
const unavailable: Record<string, string> = {};
|
|
143
178
|
|
|
144
179
|
for (const repo of repoRoots) {
|
|
145
|
-
const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha });
|
|
180
|
+
const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha, orchestratorId });
|
|
146
181
|
if (fetched.unavailable) {
|
|
147
182
|
unavailable[repo] = fetched.unavailable;
|
|
148
183
|
continue;
|
|
@@ -186,6 +221,7 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
|
|
|
186
221
|
if (!isRecord(parsed.body)) return error("body required");
|
|
187
222
|
const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
|
|
188
223
|
const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
|
|
224
|
+
const requestedOrchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 });
|
|
189
225
|
const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 });
|
|
190
226
|
const baseSha = cleanString(parsed.body.baseSha, "baseSha", { max: 80 });
|
|
191
227
|
const reason = cleanString(parsed.body.reason, "reason", { max: 1000 });
|
|
@@ -193,11 +229,11 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
|
|
|
193
229
|
if (!repoRoot || !branch) return error("repoRoot and branch required", 400);
|
|
194
230
|
if (!branch.startsWith("agent/")) return error("only agent/* branches can be discarded through recovery cleanup", 400);
|
|
195
231
|
if (force && !reason) return error("reason required for forced recovery branch discard", 400);
|
|
196
|
-
const
|
|
232
|
+
const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha, orchestratorId: requestedOrchestratorId });
|
|
233
|
+
const target = fetched.branches.find((item) => item.branch === branch);
|
|
234
|
+
const orch = target?.orchestratorId ? owningOrchestratorForRepo(repoRoot, target.orchestratorId) : owningOrchestratorForRepo(repoRoot);
|
|
197
235
|
if (!orch?.agentId) return error("no online orchestrator owns this repo", 409);
|
|
198
|
-
const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha });
|
|
199
236
|
if (fetched.unavailable) return error(fetched.unavailable, 409);
|
|
200
|
-
const target = fetched.branches.find((item) => item.branch === branch);
|
|
201
237
|
if (!target) return error("recovery branch not found", 404);
|
|
202
238
|
if (target.safeToDelete !== true && !force) {
|
|
203
239
|
return error(`branch holds work or cannot be proven safe (${target.preserveReason ?? `${target.unmergedAhead ?? target.ahead ?? "?"} unlanded commit(s)`}); land it first or pass force:true with reason to discard`, 409);
|
|
@@ -242,25 +278,25 @@ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
242
278
|
// Refuse to reclaim a path that still backs a live workspace row.
|
|
243
279
|
const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
244
280
|
if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
|
|
245
|
-
const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
246
|
-
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
247
281
|
// Land-safety gate (#244): reclaim force-removes the worktree, so refuse when
|
|
248
282
|
// it holds un-landed work unless the caller explicitly opts into discarding
|
|
249
283
|
// it. Mirrors the scheduled reaper — never destroy work on uncertainty.
|
|
250
284
|
let baseRef: string | undefined;
|
|
251
285
|
let baseSha: string | undefined;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
|
|
260
|
-
}
|
|
261
|
-
baseRef = target?.baseRef;
|
|
262
|
-
baseSha = target?.baseSha;
|
|
286
|
+
const { orphans } = await collectWorkspaceOrphans();
|
|
287
|
+
const target = orphans.find((o) => resolve(o.worktreePath) === resolve(worktreePath));
|
|
288
|
+
if (target && !force && target.safeToReap !== true) {
|
|
289
|
+
const why = target.safeToReap === undefined
|
|
290
|
+
? "land-state could not be determined"
|
|
291
|
+
: target.dirty ? "uncommitted changes" : `${target.unmergedAhead ?? target.ahead ?? "?"} un-landed commit(s)`;
|
|
292
|
+
return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
|
|
263
293
|
}
|
|
294
|
+
baseRef = target?.baseRef;
|
|
295
|
+
baseSha = target?.baseSha;
|
|
296
|
+
const orch = target?.orchestratorId
|
|
297
|
+
? listOrchestrators().find((candidate) => candidate.id === target.orchestratorId && candidate.status === "online")
|
|
298
|
+
: listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
299
|
+
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
264
300
|
const command = createCommand({
|
|
265
301
|
type: "workspace.cleanup",
|
|
266
302
|
source: "system",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errMessage } from "agent-relay-sdk";
|
|
2
|
-
import { getAgent, listPlansWithBoundOwner, deliverableTaskIdsByBranch } from "../db";
|
|
2
|
+
import { getAgent, listPlansWithBoundOwner, deliverableTaskIdsByBranch, deliverableTaskIdsByOwnerAgent, deliverableTaskIdsBySpawnRequestId } from "../db";
|
|
3
3
|
import { listIssuesForEntity, planNodeIssueEntityId } from "../db/issue-associations";
|
|
4
4
|
import {
|
|
5
5
|
getSuccessfulIssueLifecycleAction,
|
|
@@ -142,17 +142,28 @@ function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// advances the Task itself on land is #831.
|
|
148
|
-
if (workspace.branch) {
|
|
149
|
-
for (const taskId of deliverableTaskIdsByBranch(workspace.branch)) {
|
|
150
|
-
refs.push(...listIssuesForEntity("task", String(taskId)).map((item) => item.issueRef));
|
|
151
|
-
}
|
|
145
|
+
for (const taskId of associatedTaskIds(workspace)) {
|
|
146
|
+
refs.push(...listIssuesForEntity("task", String(taskId)).map((item) => item.issueRef));
|
|
152
147
|
}
|
|
153
148
|
return issueRefMap(refs);
|
|
154
149
|
}
|
|
155
150
|
|
|
151
|
+
function associatedTaskIds(workspace: LifecycleWorkspace): number[] {
|
|
152
|
+
const ids = new Set<number>();
|
|
153
|
+
const ownerId = workspace.ownerAgentId?.trim();
|
|
154
|
+
if (ownerId) {
|
|
155
|
+
for (const id of deliverableTaskIdsByOwnerAgent(ownerId)) ids.add(id);
|
|
156
|
+
const spawnRequestId = getAgent(ownerId)?.meta?.spawnRequestId;
|
|
157
|
+
if (typeof spawnRequestId === "string" && spawnRequestId.trim()) {
|
|
158
|
+
for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (workspace.branch) {
|
|
162
|
+
for (const id of deliverableTaskIdsByBranch(workspace.branch)) ids.add(id);
|
|
163
|
+
}
|
|
164
|
+
return [...ids].sort((a, b) => a - b);
|
|
165
|
+
}
|
|
166
|
+
|
|
156
167
|
const CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([^\n\r]+)/gi;
|
|
157
168
|
const ISSUE_REF_AT_START_RE = /^(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
|
|
158
169
|
const REF_SEPARATOR_RE = /^(?:\s*,\s*|\s+(?:and|&)\s+)/i;
|
|
@@ -400,6 +411,10 @@ export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInpu
|
|
|
400
411
|
}> {
|
|
401
412
|
if ((input.workspace.baseRef ?? "main") !== "main") return { source: "commit", closed: [], reopened: [], skipped: ["non-default-branch"] };
|
|
402
413
|
const resolved = await resolveIssueRefsForLand(input);
|
|
414
|
+
const taskIds = associatedTaskIds(input.workspace);
|
|
415
|
+
if (resolved.refs.length === 0 && taskIds.length > 0) {
|
|
416
|
+
logFailure(opts, `issue lifecycle found landed task(s) without linked issue refs: tasks=${taskIds.join(",")} workspace=${input.workspace.id} branch=${input.workspace.branch ?? "unknown"}`);
|
|
417
|
+
}
|
|
403
418
|
const closed: CloseIssueLifecycleResult[] = [];
|
|
404
419
|
for (const ref of resolved.refs) {
|
|
405
420
|
closed.push(await closeIssueRefForLand(ref, resolved.source, input, opts));
|
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
type SetTaskStatusInput,
|
|
35
35
|
type UpdateDeliverableTaskInput,
|
|
36
36
|
} from "../db";
|
|
37
|
+
import { errMessage } from "agent-relay-sdk";
|
|
37
38
|
import { findSpawnCommandByRequestId } from "../commands-db";
|
|
39
|
+
import { defaultIssueRepo } from "../config";
|
|
38
40
|
import { emitTaskChanged } from "../sse";
|
|
39
41
|
import type { CommandStatus, Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
|
|
40
42
|
import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
|
|
@@ -43,8 +45,58 @@ import { routeNotification } from "../notification-router";
|
|
|
43
45
|
import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
|
|
44
46
|
import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
type CreateTaskEntityInput = CreateDeliverableTaskInput & {
|
|
49
|
+
issueRefs?: string[];
|
|
50
|
+
defaultRepo?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const EXPLICIT_ISSUE_REF_RE = /(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
|
|
54
|
+
const ISSUE_SLUG_RE = /(?:^|[\/_\-\s])(?:fix|feat|bug|issue|debug|chore|reverify|reenable)-([1-9][0-9]{0,6})(?=$|[\/_\-\s])/i;
|
|
55
|
+
|
|
56
|
+
function explicitIssueRefIn(text: string | undefined): string | undefined {
|
|
57
|
+
return text?.match(EXPLICIT_ISSUE_REF_RE)?.[1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function slugIssueRefIn(text: string | undefined): string | undefined {
|
|
61
|
+
const match = text?.match(ISSUE_SLUG_RE)?.[1];
|
|
62
|
+
return match ? `#${match}` : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function explicitIssueRefs(input: CreateTaskEntityInput): string[] {
|
|
66
|
+
return (input.issueRefs ?? []).map((ref) => ref.trim()).filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inferIssueRef(input: CreateTaskEntityInput): string | undefined {
|
|
70
|
+
for (const text of [input.title, input.branch, input.intent, input.context, input.latestSteer]) {
|
|
71
|
+
const explicit = explicitIssueRefIn(text);
|
|
72
|
+
if (explicit) return explicit;
|
|
73
|
+
}
|
|
74
|
+
for (const text of [input.title, input.branch]) {
|
|
75
|
+
const slug = slugIssueRefIn(text);
|
|
76
|
+
if (slug) return slug;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function addInitialIssueRefs(taskId: number, input: CreateTaskEntityInput): TaskDetail {
|
|
82
|
+
const refs = explicitIssueRefs(input);
|
|
83
|
+
const inferred = refs.length > 0 ? undefined : inferIssueRef(input);
|
|
84
|
+
const issueRefs = refs.length > 0 ? refs : inferred ? [inferred] : [];
|
|
85
|
+
const defaultRepo = input.defaultRepo?.trim() || input.repo?.trim() || defaultIssueRepo();
|
|
86
|
+
let detail = getTaskDetail(taskId)!;
|
|
87
|
+
for (const issue of issueRefs) {
|
|
88
|
+
try {
|
|
89
|
+
detail = addTaskRef(taskId, { issue, defaultRepo, actor: input.createdBy });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn(`task ${taskId} issue ref '${issue}' could not be linked on create: ${errMessage(error)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return detail;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createTaskEntity(input: CreateTaskEntityInput): TaskDetail {
|
|
98
|
+
const created = createDeliverableTask(input);
|
|
99
|
+
const detail = addInitialIssueRefs(created.id, input);
|
|
48
100
|
autoSubscribeTaskCoordinator(input.createdBy, detail.id);
|
|
49
101
|
emitTaskChanged(detail, "task.created");
|
|
50
102
|
emitTaskSemanticNotification({
|
package/src/task-dispatcher.ts
CHANGED
|
@@ -382,7 +382,8 @@ function rerouteForBand(
|
|
|
382
382
|
// no burn data yet. Prefer "prefer" band, then "normal"; never a blocked one.
|
|
383
383
|
//
|
|
384
384
|
// When mustResolveAvailable is set (#901 degrade-never-refuse), widens search in tiers when
|
|
385
|
-
// no prefer/normal provider is available: caution → avoid-large →
|
|
385
|
+
// no prefer/normal provider is available: caution → avoid-large → hard-avoid-but-not-blocked
|
|
386
|
+
// → least-utilized blockedNow.
|
|
386
387
|
// A `degraded` flag + `degradedReason` signals to the caller to emit a louder warning.
|
|
387
388
|
function healthiestAlternative(
|
|
388
389
|
avoid: SpawnProvider,
|
|
@@ -415,11 +416,19 @@ function healthiestAlternative(
|
|
|
415
416
|
.sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
416
417
|
if (avoidLargeAlt[0]) return { ...avoidLargeAlt[0], degraded: true, degradedReason: "avoid-large band (tier 2 degraded)" };
|
|
417
418
|
|
|
418
|
-
// Tier 3 degradation:
|
|
419
|
+
// Tier 3 degradation: hard-avoid is still better than a provider that is actively blocked.
|
|
420
|
+
// This is the exact shape a release auto-heal can hit: Claude session limit is blockedNow,
|
|
421
|
+
// Codex is over pace (hard-avoid) but still runnable.
|
|
422
|
+
const hardAvoidAlt = all
|
|
423
|
+
.filter((c) => !c.blockedNow && c.band === "hard-avoid")
|
|
424
|
+
.sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
425
|
+
if (hardAvoidAlt[0]) return { ...hardAvoidAlt[0], degraded: true, degradedReason: "hard-avoid band (tier 3 degraded)" };
|
|
426
|
+
|
|
427
|
+
// Tier 4 degradation: least-utilized blockedNow (last resort — emit the loudest warning).
|
|
419
428
|
// Sort by bandRank so the healthiest (lowest penalty) blocked provider is picked first,
|
|
420
429
|
// not whichever happens to be earliest in catalog order.
|
|
421
430
|
const blockedAlts = all.filter((c) => c.blockedNow).sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
422
|
-
if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier
|
|
431
|
+
if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 4 last-resort — ALL providers at capacity)" };
|
|
423
432
|
|
|
424
433
|
return undefined;
|
|
425
434
|
}
|