@tea-agent/loop-agent 0.36.1-beta.0 → 0.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -15
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +7 -0
- package/dist/cli/program.js +6 -1
- package/dist/commands/dag-request-interrupt.js +20 -0
- package/dist/executors/pi-sdk-executor.js +34 -0
- package/dist/shared/operator/capabilities.js +76 -3
- package/dist/task/source-prepare/semantic-intake.js +144 -23
- package/dist/worker/console/chat/semantic-activity.js +6 -0
- package/dist/worker/console/chat/workspace-landing.js +1 -0
- package/dist/worker/console/operation-runner.js +48 -0
- package/dist/worker/console/operation-wait.js +41 -0
- package/dist/worker/console/operator-actions.js +235 -26
- package/dist/worker/console/operator-user-error.js +4 -0
- package/dist/worker/console/recovery-cta.js +50 -3
- package/dist/worker/console/recovery-error-copy.js +198 -0
- package/dist/worker/console/static/assets/index-D83DYAFG.css +1 -0
- package/dist/worker/console/static/assets/index-IXm7oYjL.js +59 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +13 -9
- package/dist/worker/console/static-src/app/useOperatorActions.js +4 -2
- package/dist/worker/console/static-src/app/useRecoveryActions.js +65 -56
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +68 -1
- package/dist/worker/observability/interrupt-eligibility.js +264 -0
- package/dist/worker/observe/static/operator-chrome.d.ts +1 -0
- package/dist/worker/observe/static/operator-chrome.js +5 -0
- package/dist/worker/observe/static/views/dag.js +12 -0
- package/dist/workflows/dag/failure-routing.js +4 -9
- package/dist/workflows/dag/init-hybrid.js +2 -4
- package/dist/workflows/dag/interrupt-request.js +559 -0
- package/dist/workflows/dag/lifecycle.js +0 -4
- package/dist/workflows/dag/node-execution.js +6 -16
- package/dist/workflows/dag/report.js +0 -6
- package/dist/workflows/dag/retry-policy.js +0 -11
- package/dist/workflows/dag/runner.js +72 -4
- package/docs/templates/frontend-task-constraints.md +7 -13
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
- package/dist/worker/console/static/assets/index-2OeZODxk.js +0 -57
- package/dist/worker/console/static/assets/index-DVJlUL8X.css +0 -1
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<link rel="stylesheet" href="/inspect/operator-chrome.css" />
|
|
8
8
|
<title>Loop 操作台 · Operator Console</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-IXm7oYjL.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-D83DYAFG.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatIneligibleRerunMessage, nextActionForIneligibleRerun, } from "../../recovery-error-copy.js";
|
|
1
2
|
/** Legacy step ids from older sessions map onto the merged contract step. */
|
|
2
3
|
export function normalizeWizardStep(step) {
|
|
3
4
|
if (step === "validate" || step === "apply" || step === "spine") {
|
|
@@ -57,6 +58,7 @@ export const RECOVERY_FACT_LABEL = {
|
|
|
57
58
|
"terminal-failed": "已终止(失败)",
|
|
58
59
|
"terminal-succeeded": "已终止(成功)",
|
|
59
60
|
"needs-reconcile": "需要对账",
|
|
61
|
+
"running-active": "正在运行",
|
|
60
62
|
};
|
|
61
63
|
export const LAYER_VALUE_LABEL = {
|
|
62
64
|
idle: "空闲",
|
|
@@ -108,18 +110,20 @@ export const LONG_RUNNING_ACTIONS = new Set([
|
|
|
108
110
|
"runDag",
|
|
109
111
|
"dagRerun",
|
|
110
112
|
"dagReconcileRun",
|
|
113
|
+
"dagRequestInterrupt",
|
|
111
114
|
"standaloneTaskRerun",
|
|
112
115
|
]);
|
|
113
|
-
export function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
export function suggestedRecoveryAfterIneligibleRerun(reasons) {
|
|
117
|
+
return nextActionForIneligibleRerun(reasons);
|
|
118
|
+
}
|
|
119
|
+
export function formatDagRerunIneligible(plan, fallbackMessage) {
|
|
120
|
+
return formatIneligibleRerunMessage({
|
|
121
|
+
blockedReasons: plan.blockedReasons,
|
|
122
|
+
blockingNodes: plan.blockingNodes,
|
|
123
|
+
fallbackMessage,
|
|
124
|
+
});
|
|
121
125
|
}
|
|
122
|
-
export async function waitForOperation(operationId, timeoutMs =
|
|
126
|
+
export async function waitForOperation(operationId, timeoutMs = 300_000) {
|
|
123
127
|
const started = Date.now();
|
|
124
128
|
while (Date.now() - started < timeoutMs) {
|
|
125
129
|
const res = await fetch(`/api/operator/v1/operations/${encodeURIComponent(operationId)}`, { credentials: "same-origin" });
|
|
@@ -6,8 +6,8 @@ import { confirmationToken, CONFIRMATION_HEADER, LONG_RUNNING_ACTIONS, postOpera
|
|
|
6
6
|
* seconds server-side (imports + intake). Prevents the "page looks frozen"
|
|
7
7
|
* gap between click and the terminal flash message. */
|
|
8
8
|
const ACTION_PROGRESS_COPY = {
|
|
9
|
-
bootstrapFromPrd: "正在导入 PRD 并创建任务:归档文档 → 确定性解析 →
|
|
10
|
-
importPrd: "正在导入 PRD:归档文档 → 确定性解析 →
|
|
9
|
+
bootstrapFromPrd: "正在导入 PRD 并创建任务:归档文档 → 确定性解析 → 语义结构化(MED→HIGH→LOW,每档最多 300 秒,请勿关闭页面)",
|
|
10
|
+
importPrd: "正在导入 PRD:归档文档 → 确定性解析 → 语义结构化(MED→HIGH→LOW,每档最多 300 秒,请勿关闭页面)",
|
|
11
11
|
interviewStart: "正在启动需求访谈…",
|
|
12
12
|
interviewTurn: "正在提交访谈回答…",
|
|
13
13
|
interviewSkip: "正在跳过访谈并生成评估…",
|
|
@@ -16,6 +16,8 @@ const ACTION_PROGRESS_COPY = {
|
|
|
16
16
|
contractApply: "正在应用契约(写入需求与执行约束)…",
|
|
17
17
|
spineCheck: "正在运行 Spine 审计…",
|
|
18
18
|
dagGenerate: "正在生成 DAG…",
|
|
19
|
+
standaloneTaskRerun: "正在完整重跑任务:服务端校验资格后启动新 run…",
|
|
20
|
+
workerTaskRetry: "正在重新排队 Worker Task…",
|
|
19
21
|
};
|
|
20
22
|
/** Central operator-action dispatcher: POST /api/operator/v1/operations with
|
|
21
23
|
* polling, long-running background waits, interview payload handoff and the
|
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { isAutoRecoveryReason } from "../../recovery-selection.js";
|
|
3
|
+
import { formatOperatorUserError } from "../../operator-user-error.js";
|
|
3
4
|
import { formatDagRerunIneligible, postOperator, requestId, } from "./console-types.js";
|
|
5
|
+
/** Destructive reconcile still consumes a browser Human Gate receipt.
|
|
6
|
+
* Same-task rerun / Worker retry are autonomous and must not enter here. */
|
|
7
|
+
async function consumeMutationGateAndRun(input) {
|
|
8
|
+
const prep = await input.runAction("prepareMutationGate", {
|
|
9
|
+
action: input.action,
|
|
10
|
+
actionParams: input.actionParams,
|
|
11
|
+
});
|
|
12
|
+
if (!input.isActionSuccess(prep))
|
|
13
|
+
return;
|
|
14
|
+
const receiptSource = (prep?.result ?? prep);
|
|
15
|
+
const receipt = receiptSource.receiptId
|
|
16
|
+
? receiptSource
|
|
17
|
+
: (receiptSource.result ?? receiptSource);
|
|
18
|
+
if (!receipt.receiptId || !receipt.humanGateToken) {
|
|
19
|
+
input.setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await input.runAction(input.action, {
|
|
23
|
+
...input.actionParams,
|
|
24
|
+
confirmationId: receipt.receiptId,
|
|
25
|
+
humanGateToken: receipt.humanGateToken,
|
|
26
|
+
}, { clientRequestId: input.clientRequestId });
|
|
27
|
+
}
|
|
4
28
|
/** Recovery CTA dispatch: diagnose/report/inspect shortcuts, reason-gated
|
|
5
|
-
* confirm flow with dagRerunPlan preflight,
|
|
29
|
+
* confirm flow with dagRerunPlan preflight, autonomous rerun/retry, and
|
|
30
|
+
* Human Gate only for destructive reconcile. */
|
|
6
31
|
export function useRecoveryActions(params) {
|
|
7
|
-
const { shell, actions, taskId, dagRunId, fromNodeId, featureId, recoveryReason, recoveryReasonTouched, recoveryConfirm, recoveryPlanSummary, setRecoveryConfirm, setRecoveryPlanSummary, setRecoveryReport, setRecoveryReason, setRecoveryReasonTouched, buildCurrentRecoveryReason, openFullCanvas, } = params;
|
|
32
|
+
const { shell, actions, taskId, dagRunId, fromNodeId, featureId, recoveryReason, recoveryReasonTouched, recoveryConfirm, recoveryPlanSummary, interruptReasonCode, setRecoveryConfirm, setRecoveryPlanSummary, setRecoveryReport, setRecoveryReason, setRecoveryReasonTouched, buildCurrentRecoveryReason, openFullCanvas, } = params;
|
|
8
33
|
const { setOpError, setOpErrorCode, setOpMessage, setBusy } = shell;
|
|
9
34
|
const { runAction, isActionSuccess } = actions;
|
|
10
35
|
const handleRecoveryCta = useCallback(async (cta) => {
|
|
@@ -83,7 +108,7 @@ export function useRecoveryActions(params) {
|
|
|
83
108
|
setOpMessage(null);
|
|
84
109
|
}
|
|
85
110
|
else if (plan.eligible === false) {
|
|
86
|
-
setOpError(body.error?.message
|
|
111
|
+
setOpError(formatOperatorUserError(formatDagRerunIneligible(plan, body.error?.message)));
|
|
87
112
|
}
|
|
88
113
|
else {
|
|
89
114
|
setOpMessage(body.error?.message ??
|
|
@@ -150,7 +175,7 @@ export function useRecoveryActions(params) {
|
|
|
150
175
|
planHash = plan.planHash?.trim() || planHash;
|
|
151
176
|
planEligible = plan.eligible;
|
|
152
177
|
if (plan.eligible === false) {
|
|
153
|
-
setOpError(planBody?.error?.message
|
|
178
|
+
setOpError(formatOperatorUserError(formatDagRerunIneligible(plan, planBody?.error?.message)));
|
|
154
179
|
return;
|
|
155
180
|
}
|
|
156
181
|
if (planBody && planBody.ok === false && !planHash) {
|
|
@@ -160,7 +185,7 @@ export function useRecoveryActions(params) {
|
|
|
160
185
|
}
|
|
161
186
|
}
|
|
162
187
|
if (planEligible === false) {
|
|
163
|
-
setOpError(formatDagRerunIneligible(recoveryPlanSummary ?? {}));
|
|
188
|
+
setOpError(formatOperatorUserError(formatDagRerunIneligible(recoveryPlanSummary ?? {})));
|
|
164
189
|
return;
|
|
165
190
|
}
|
|
166
191
|
if (!planHash) {
|
|
@@ -176,26 +201,10 @@ export function useRecoveryActions(params) {
|
|
|
176
201
|
setOpError("完整重跑任务需要 DAG 运行 ID");
|
|
177
202
|
return;
|
|
178
203
|
}
|
|
179
|
-
|
|
204
|
+
await runAction("standaloneTaskRerun", {
|
|
180
205
|
runId,
|
|
181
206
|
reason,
|
|
182
207
|
...(taskId.trim() ? { taskId: taskId.trim() } : {}),
|
|
183
|
-
};
|
|
184
|
-
const prep = await runAction("prepareMutationGate", {
|
|
185
|
-
action: "standaloneTaskRerun",
|
|
186
|
-
actionParams,
|
|
187
|
-
});
|
|
188
|
-
if (!isActionSuccess(prep))
|
|
189
|
-
return;
|
|
190
|
-
const receipt = (prep?.result ?? prep);
|
|
191
|
-
if (!receipt.receiptId || !receipt.humanGateToken) {
|
|
192
|
-
setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
await runAction("standaloneTaskRerun", {
|
|
196
|
-
...actionParams,
|
|
197
|
-
confirmationId: receipt.receiptId,
|
|
198
|
-
humanGateToken: receipt.humanGateToken,
|
|
199
208
|
}, { clientRequestId });
|
|
200
209
|
return;
|
|
201
210
|
}
|
|
@@ -205,27 +214,35 @@ export function useRecoveryActions(params) {
|
|
|
205
214
|
setOpError("重新排队 Worker Task 需要任务 ID");
|
|
206
215
|
return;
|
|
207
216
|
}
|
|
208
|
-
|
|
217
|
+
await runAction("workerTaskRetry", {
|
|
209
218
|
taskId: id,
|
|
210
219
|
reason,
|
|
211
220
|
...(featureId.trim() ? { featureId: featureId.trim() } : {}),
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (!
|
|
221
|
+
}, { clientRequestId });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (cta.id === "interrupt") {
|
|
225
|
+
const runId = dagRunId.trim();
|
|
226
|
+
if (!runId) {
|
|
227
|
+
setOpError("中止运行需要 DAG 运行 ID");
|
|
218
228
|
return;
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
setOpError("
|
|
229
|
+
}
|
|
230
|
+
if (reason.trim().length < 10 || reason.trim().length > 500) {
|
|
231
|
+
setOpError("中止原因去首尾空白后必须是 10–500 个字符,请手写说明,不要用占位句。");
|
|
222
232
|
return;
|
|
223
233
|
}
|
|
224
|
-
await
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
234
|
+
await consumeMutationGateAndRun({
|
|
235
|
+
runAction,
|
|
236
|
+
isActionSuccess,
|
|
237
|
+
setOpError,
|
|
238
|
+
action: "dagRequestInterrupt",
|
|
239
|
+
actionParams: {
|
|
240
|
+
runId,
|
|
241
|
+
reasonCode: interruptReasonCode.trim() || "operator-request",
|
|
242
|
+
reasonDetail: reason,
|
|
243
|
+
},
|
|
244
|
+
clientRequestId,
|
|
245
|
+
});
|
|
229
246
|
return;
|
|
230
247
|
}
|
|
231
248
|
if (cta.id === "reconcile") {
|
|
@@ -237,27 +254,18 @@ export function useRecoveryActions(params) {
|
|
|
237
254
|
// Default supersede: orphan/interrupted runs should not be resumed.
|
|
238
255
|
// The browser Human Gate binds the destructive action + reason; the CLI
|
|
239
256
|
// still fail-closes when runner is not proven dead/stopped.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
};
|
|
245
|
-
const prep = await runAction("prepareMutationGate", {
|
|
257
|
+
await consumeMutationGateAndRun({
|
|
258
|
+
runAction,
|
|
259
|
+
isActionSuccess,
|
|
260
|
+
setOpError,
|
|
246
261
|
action: "dagReconcileRun",
|
|
247
|
-
actionParams
|
|
262
|
+
actionParams: {
|
|
263
|
+
runId,
|
|
264
|
+
reason,
|
|
265
|
+
reconcileAction: "supersede",
|
|
266
|
+
},
|
|
267
|
+
clientRequestId,
|
|
248
268
|
});
|
|
249
|
-
if (!isActionSuccess(prep))
|
|
250
|
-
return;
|
|
251
|
-
const receipt = (prep?.result ?? prep);
|
|
252
|
-
if (!receipt.receiptId || !receipt.humanGateToken) {
|
|
253
|
-
setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
await runAction("dagReconcileRun", {
|
|
257
|
-
...actionParams,
|
|
258
|
-
confirmationId: receipt.receiptId,
|
|
259
|
-
humanGateToken: receipt.humanGateToken,
|
|
260
|
-
}, { clientRequestId });
|
|
261
269
|
return;
|
|
262
270
|
}
|
|
263
271
|
setOpMessage(`${cta.label}:请经 CLI — ${cta.commandHint ?? ""}`);
|
|
@@ -268,6 +276,7 @@ export function useRecoveryActions(params) {
|
|
|
268
276
|
recoveryConfirm,
|
|
269
277
|
recoveryPlanSummary?.planHash,
|
|
270
278
|
recoveryReason,
|
|
279
|
+
interruptReasonCode,
|
|
271
280
|
runAction,
|
|
272
281
|
isActionSuccess,
|
|
273
282
|
setOpError,
|
|
@@ -16,6 +16,9 @@ export function useRecoveryConsole(params) {
|
|
|
16
16
|
const [recoveryReasonTouched, setRecoveryReasonTouched] = useState(false);
|
|
17
17
|
const [recoveryPlanSummary, setRecoveryPlanSummary] = useState(null);
|
|
18
18
|
const [recoveryConfirm, setRecoveryConfirm] = useState(null);
|
|
19
|
+
const [interruptIntent, setInterruptIntent] = useState(false);
|
|
20
|
+
const [interruptReasonCode, setInterruptReasonCode] = useState("operator-request");
|
|
21
|
+
const [interruptEligibility, setInterruptEligibility] = useState(null);
|
|
19
22
|
const [recoveryRuns, setRecoveryRuns] = useState([]);
|
|
20
23
|
const [recoveryNodes, setRecoveryNodes] = useState([]);
|
|
21
24
|
const [recoveryRunsLoading, setRecoveryRunsLoading] = useState(false);
|
|
@@ -208,6 +211,37 @@ export function useRecoveryConsole(params) {
|
|
|
208
211
|
return;
|
|
209
212
|
void loadRecoveryNodes(dagRunId);
|
|
210
213
|
}, [nav, dagRunId, loadRecoveryNodes]);
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (nav !== "recovery")
|
|
216
|
+
return;
|
|
217
|
+
const runId = dagRunId.trim();
|
|
218
|
+
if (!runId) {
|
|
219
|
+
setInterruptEligibility(null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
let cancelled = false;
|
|
223
|
+
const load = async () => {
|
|
224
|
+
const { body } = await postOperator("dagInterruptEligibility", { runId });
|
|
225
|
+
if (cancelled)
|
|
226
|
+
return;
|
|
227
|
+
const result = (body.result ?? body);
|
|
228
|
+
setInterruptEligibility({
|
|
229
|
+
eligible: result.eligible === true,
|
|
230
|
+
ownerKind: result.ownerKind,
|
|
231
|
+
message: result.message,
|
|
232
|
+
interruptStatus: result.interruptStatus,
|
|
233
|
+
reasonCodes: result.reasonCodes,
|
|
234
|
+
targetOperationId: result.targetOperationId,
|
|
235
|
+
targetOperationState: result.targetOperationState,
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
void load();
|
|
239
|
+
const timer = window.setInterval(() => void load(), 4000);
|
|
240
|
+
return () => {
|
|
241
|
+
cancelled = true;
|
|
242
|
+
window.clearInterval(timer);
|
|
243
|
+
};
|
|
244
|
+
}, [nav, dagRunId]);
|
|
211
245
|
useEffect(() => {
|
|
212
246
|
if (nav !== "recovery")
|
|
213
247
|
return;
|
|
@@ -233,7 +267,14 @@ export function useRecoveryConsole(params) {
|
|
|
233
267
|
dagStatus: recoveryRunStatus,
|
|
234
268
|
failureCategory: recoveryFailureCategory,
|
|
235
269
|
});
|
|
236
|
-
const recoveryCtas = useMemo(() =>
|
|
270
|
+
const recoveryCtas = useMemo(() => {
|
|
271
|
+
const ctas = consoleRecoveryCtas(recoveryFact);
|
|
272
|
+
if (recoveryFact !== "running-active")
|
|
273
|
+
return ctas;
|
|
274
|
+
if (interruptEligibility?.eligible)
|
|
275
|
+
return ctas;
|
|
276
|
+
return ctas.filter((cta) => cta.id !== "interrupt");
|
|
277
|
+
}, [recoveryFact, interruptEligibility?.eligible]);
|
|
237
278
|
const recoveryLanes = useMemo(() => partitionRecoveryCtas(recoveryCtas), [recoveryCtas]);
|
|
238
279
|
const recommendedCtaId = useMemo(() => recommendedRecoveryCtaId(recoveryFact), [recoveryFact]);
|
|
239
280
|
const selectedRecoveryNode = useMemo(() => recoveryNodes.find((n) => n.nodeId === fromNodeId.trim()), [recoveryNodes, fromNodeId]);
|
|
@@ -299,6 +340,7 @@ export function useRecoveryConsole(params) {
|
|
|
299
340
|
recoveryReasonTouched,
|
|
300
341
|
recoveryConfirm,
|
|
301
342
|
recoveryPlanSummary,
|
|
343
|
+
interruptReasonCode,
|
|
302
344
|
setRecoveryConfirm,
|
|
303
345
|
setRecoveryPlanSummary,
|
|
304
346
|
setRecoveryReport,
|
|
@@ -307,6 +349,25 @@ export function useRecoveryConsole(params) {
|
|
|
307
349
|
buildCurrentRecoveryReason,
|
|
308
350
|
openFullCanvas,
|
|
309
351
|
});
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (nav !== "recovery" || !interruptIntent)
|
|
354
|
+
return;
|
|
355
|
+
if (!interruptEligibility?.eligible)
|
|
356
|
+
return;
|
|
357
|
+
const interruptCta = recoveryCtas.find((cta) => cta.id === "interrupt");
|
|
358
|
+
if (!interruptCta)
|
|
359
|
+
return;
|
|
360
|
+
setRecoveryConfirm(interruptCta);
|
|
361
|
+
setRecoveryReason(buildCurrentRecoveryReason(interruptCta.label));
|
|
362
|
+
setRecoveryReasonTouched(false);
|
|
363
|
+
setInterruptIntent(false);
|
|
364
|
+
}, [
|
|
365
|
+
nav,
|
|
366
|
+
interruptIntent,
|
|
367
|
+
interruptEligibility?.eligible,
|
|
368
|
+
recoveryCtas,
|
|
369
|
+
buildCurrentRecoveryReason,
|
|
370
|
+
]);
|
|
310
371
|
return {
|
|
311
372
|
dagRunId,
|
|
312
373
|
setDagRunId,
|
|
@@ -327,6 +388,7 @@ export function useRecoveryConsole(params) {
|
|
|
327
388
|
recoveryRunsLoading,
|
|
328
389
|
recoveryNodesLoading,
|
|
329
390
|
recoveryRunStatus,
|
|
391
|
+
recoveryFailureCategory,
|
|
330
392
|
frontendRecovery,
|
|
331
393
|
manualRunId,
|
|
332
394
|
setManualRunId,
|
|
@@ -350,6 +412,11 @@ export function useRecoveryConsole(params) {
|
|
|
350
412
|
recoveryCtas,
|
|
351
413
|
recoveryLanes,
|
|
352
414
|
recommendedCtaId,
|
|
415
|
+
interruptIntent,
|
|
416
|
+
setInterruptIntent,
|
|
417
|
+
interruptReasonCode,
|
|
418
|
+
setInterruptReasonCode,
|
|
419
|
+
interruptEligibility,
|
|
353
420
|
recoveryConfirmRef,
|
|
354
421
|
buildCurrentRecoveryReason,
|
|
355
422
|
handleRecoveryCta,
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { hostname as localHostname } from "node:os";
|
|
2
|
+
import { DagInterruptError, INTERRUPT_ERROR_MESSAGES, projectedInterruptStatus, readInterruptRequest, runnerIdentitiesMatch, runnerIdentityFromState, } from "../../workflows/dag/interrupt-request.js";
|
|
3
|
+
import { assessDagRunLiveness, locateDagRun, readDagRunState, } from "../../workflows/dag/lifecycle.js";
|
|
4
|
+
function operationControllerFingerprint(operation) {
|
|
5
|
+
const identity = operation.controllerIdentity;
|
|
6
|
+
if (!identity)
|
|
7
|
+
return undefined;
|
|
8
|
+
if ("fingerprint" in identity && typeof identity.fingerprint === "string") {
|
|
9
|
+
const value = identity.fingerprint.trim();
|
|
10
|
+
return value || undefined;
|
|
11
|
+
}
|
|
12
|
+
if ("packageFingerprint" in identity &&
|
|
13
|
+
identity.packageFingerprint &&
|
|
14
|
+
typeof identity.packageFingerprint === "object" &&
|
|
15
|
+
"value" in identity.packageFingerprint &&
|
|
16
|
+
typeof identity.packageFingerprint.value === "string") {
|
|
17
|
+
const value = identity.packageFingerprint.value.trim();
|
|
18
|
+
return value || undefined;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
function ineligible(partial) {
|
|
23
|
+
const code = partial.reasonCodes[0] ?? "RUN_NOT_ACTIVE";
|
|
24
|
+
return {
|
|
25
|
+
...partial,
|
|
26
|
+
eligible: false,
|
|
27
|
+
message: partial.message ?? INTERRUPT_ERROR_MESSAGES[code],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function bindLiveOperations(input) {
|
|
31
|
+
return input.operations.filter((operation) => {
|
|
32
|
+
if (operation.state !== "running" && operation.state !== "starting") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (operation.pid !== input.runner.pid)
|
|
36
|
+
return false;
|
|
37
|
+
if (operation.dagRunId && operation.dagRunId !== input.runId)
|
|
38
|
+
return false;
|
|
39
|
+
const fingerprint = operationControllerFingerprint(operation);
|
|
40
|
+
if (!fingerprint || fingerprint !== input.runner.controllerFingerprint) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export async function evaluateConsoleDagInterruptEligibility(input) {
|
|
47
|
+
const runId = input.runId.trim();
|
|
48
|
+
if (!runId) {
|
|
49
|
+
return ineligible({
|
|
50
|
+
runId: "",
|
|
51
|
+
ownerKind: "unknown",
|
|
52
|
+
reasonCodes: ["INVALID_INPUT"],
|
|
53
|
+
interruptStatus: "none",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const located = await locateDagRun(input.repoRoot, runId);
|
|
57
|
+
if (!located) {
|
|
58
|
+
return ineligible({
|
|
59
|
+
runId,
|
|
60
|
+
ownerKind: "unknown",
|
|
61
|
+
reasonCodes: ["RUN_NOT_ACTIVE"],
|
|
62
|
+
interruptStatus: "none",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
let state;
|
|
66
|
+
try {
|
|
67
|
+
state = await readDagRunState(located.runDir);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return ineligible({
|
|
71
|
+
runId,
|
|
72
|
+
lifecycle: located.lifecycle,
|
|
73
|
+
ownerKind: "unknown",
|
|
74
|
+
reasonCodes: ["RUN_NOT_ACTIVE"],
|
|
75
|
+
interruptStatus: "none",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
let interrupt;
|
|
79
|
+
try {
|
|
80
|
+
interrupt = await readInterruptRequest(located.runDir);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
interrupt = undefined;
|
|
84
|
+
}
|
|
85
|
+
const interruptStatus = projectedInterruptStatus(interrupt);
|
|
86
|
+
if (state.workerAssociation) {
|
|
87
|
+
return ineligible({
|
|
88
|
+
runId,
|
|
89
|
+
lifecycle: located.lifecycle,
|
|
90
|
+
dagStatus: state.status,
|
|
91
|
+
ownerKind: "worker-managed",
|
|
92
|
+
reasonCodes: ["RUN_NOT_CONSOLE_OWNED"],
|
|
93
|
+
interruptStatus,
|
|
94
|
+
interrupt,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (located.lifecycle !== "active" || state.status !== "running") {
|
|
98
|
+
const terminal = located.lifecycle === "completed" ||
|
|
99
|
+
located.lifecycle === "paused" ||
|
|
100
|
+
state.status === "paused" ||
|
|
101
|
+
state.status === "finished" ||
|
|
102
|
+
state.status === "failed" ||
|
|
103
|
+
state.status === "partial_failed" ||
|
|
104
|
+
state.status === "superseded" ||
|
|
105
|
+
state.status === "abandoned";
|
|
106
|
+
return ineligible({
|
|
107
|
+
runId,
|
|
108
|
+
lifecycle: located.lifecycle,
|
|
109
|
+
dagStatus: state.status,
|
|
110
|
+
ownerKind: "external-local",
|
|
111
|
+
reasonCodes: [terminal ? "RUN_ALREADY_TERMINAL" : "RUN_NOT_ACTIVE"],
|
|
112
|
+
interruptStatus,
|
|
113
|
+
interrupt,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const runner = runnerIdentityFromState(state);
|
|
117
|
+
if (!runner) {
|
|
118
|
+
return ineligible({
|
|
119
|
+
runId,
|
|
120
|
+
lifecycle: located.lifecycle,
|
|
121
|
+
dagStatus: state.status,
|
|
122
|
+
ownerKind: "unknown",
|
|
123
|
+
reasonCodes: ["RUNNER_IDENTITY_MISMATCH"],
|
|
124
|
+
interruptStatus,
|
|
125
|
+
interrupt,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (runner.hostname !== (input.hostname ?? localHostname())) {
|
|
129
|
+
return ineligible({
|
|
130
|
+
runId,
|
|
131
|
+
lifecycle: located.lifecycle,
|
|
132
|
+
dagStatus: state.status,
|
|
133
|
+
ownerKind: "external-local",
|
|
134
|
+
reasonCodes: ["RUN_NOT_CONSOLE_OWNED"],
|
|
135
|
+
interruptStatus,
|
|
136
|
+
interrupt,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const liveness = assessDagRunLiveness({
|
|
140
|
+
state,
|
|
141
|
+
hostname: input.hostname ?? localHostname(),
|
|
142
|
+
});
|
|
143
|
+
if (liveness.status === "orphaned" || liveness.status === "unknown-host") {
|
|
144
|
+
return ineligible({
|
|
145
|
+
runId,
|
|
146
|
+
lifecycle: located.lifecycle,
|
|
147
|
+
dagStatus: state.status,
|
|
148
|
+
ownerKind: "unknown",
|
|
149
|
+
reasonCodes: ["RUN_NOT_ACTIVE"],
|
|
150
|
+
interruptStatus,
|
|
151
|
+
interrupt,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const liveOps = await input.operations.list();
|
|
155
|
+
const matches = bindLiveOperations({
|
|
156
|
+
operations: liveOps,
|
|
157
|
+
runId,
|
|
158
|
+
runner,
|
|
159
|
+
});
|
|
160
|
+
const targetFromInterrupt = interrupt?.targetOperationId
|
|
161
|
+
? (liveOps.find((op) => op.operationId === interrupt.targetOperationId) ??
|
|
162
|
+
(await input.operations.get(interrupt.targetOperationId)))
|
|
163
|
+
: undefined;
|
|
164
|
+
const targetOperationState = (matches.length === 1 ? matches[0]?.state : undefined) ??
|
|
165
|
+
targetFromInterrupt?.state;
|
|
166
|
+
const expected = {
|
|
167
|
+
pid: runner.pid,
|
|
168
|
+
hostname: runner.hostname,
|
|
169
|
+
startedAt: runner.startedAt,
|
|
170
|
+
controllerFingerprint: runner.controllerFingerprint,
|
|
171
|
+
};
|
|
172
|
+
if (interrupt &&
|
|
173
|
+
(interrupt.status === "requested" || interrupt.status === "acknowledged")) {
|
|
174
|
+
return ineligible({
|
|
175
|
+
runId,
|
|
176
|
+
lifecycle: located.lifecycle,
|
|
177
|
+
dagStatus: state.status,
|
|
178
|
+
failureCategory: state.failureCategory,
|
|
179
|
+
ownerKind: "console-operation",
|
|
180
|
+
targetOperationId: interrupt.targetOperationId,
|
|
181
|
+
targetOperationState,
|
|
182
|
+
expectedRunnerIdentity: runner,
|
|
183
|
+
reasonCodes: ["INTERRUPT_ALREADY_PENDING"],
|
|
184
|
+
interruptStatus,
|
|
185
|
+
interrupt,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (interrupt?.status === "needs-reconcile") {
|
|
189
|
+
return ineligible({
|
|
190
|
+
runId,
|
|
191
|
+
lifecycle: located.lifecycle,
|
|
192
|
+
dagStatus: state.status,
|
|
193
|
+
failureCategory: state.failureCategory,
|
|
194
|
+
ownerKind: "console-operation",
|
|
195
|
+
targetOperationId: interrupt.targetOperationId,
|
|
196
|
+
targetOperationState,
|
|
197
|
+
expectedRunnerIdentity: runner,
|
|
198
|
+
reasonCodes: ["INTERRUPT_NEEDS_RECONCILE"],
|
|
199
|
+
interruptStatus,
|
|
200
|
+
interrupt,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (matches.length === 0) {
|
|
204
|
+
return ineligible({
|
|
205
|
+
runId,
|
|
206
|
+
lifecycle: located.lifecycle,
|
|
207
|
+
dagStatus: state.status,
|
|
208
|
+
failureCategory: state.failureCategory,
|
|
209
|
+
ownerKind: "external-local",
|
|
210
|
+
reasonCodes: ["RUN_NOT_CONSOLE_OWNED"],
|
|
211
|
+
interruptStatus,
|
|
212
|
+
interrupt,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (matches.length > 1) {
|
|
216
|
+
return ineligible({
|
|
217
|
+
runId,
|
|
218
|
+
lifecycle: located.lifecycle,
|
|
219
|
+
dagStatus: state.status,
|
|
220
|
+
failureCategory: state.failureCategory,
|
|
221
|
+
ownerKind: "unknown",
|
|
222
|
+
reasonCodes: ["RUNNER_IDENTITY_MISMATCH"],
|
|
223
|
+
message: "找到多个可能持有该运行的 Console operation,无法无歧义绑定。请用 Doctor 查看,不要猜测。",
|
|
224
|
+
interruptStatus,
|
|
225
|
+
interrupt,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const target = matches[0];
|
|
229
|
+
if (target.pid !== expected.pid ||
|
|
230
|
+
!runnerIdentitiesMatch(expected, runner)) {
|
|
231
|
+
return ineligible({
|
|
232
|
+
runId,
|
|
233
|
+
lifecycle: located.lifecycle,
|
|
234
|
+
dagStatus: state.status,
|
|
235
|
+
failureCategory: state.failureCategory,
|
|
236
|
+
ownerKind: "unknown",
|
|
237
|
+
reasonCodes: ["RUNNER_IDENTITY_MISMATCH"],
|
|
238
|
+
interruptStatus,
|
|
239
|
+
interrupt,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
eligible: true,
|
|
244
|
+
runId,
|
|
245
|
+
lifecycle: located.lifecycle,
|
|
246
|
+
dagStatus: state.status,
|
|
247
|
+
failureCategory: state.failureCategory,
|
|
248
|
+
ownerKind: "console-operation",
|
|
249
|
+
targetOperationId: target.operationId,
|
|
250
|
+
targetOperationState: target.state,
|
|
251
|
+
expectedRunnerIdentity: expected,
|
|
252
|
+
reasonCodes: [],
|
|
253
|
+
message: "可以由当前 Console 协作中止此运行。",
|
|
254
|
+
interruptStatus,
|
|
255
|
+
interrupt,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export function interruptEligibilityError(eligibility) {
|
|
259
|
+
const code = eligibility.reasonCodes[0] ?? "RUN_NOT_ACTIVE";
|
|
260
|
+
return new DagInterruptError(code, eligibility.message);
|
|
261
|
+
}
|
|
262
|
+
// Console-side re-exports: keep the console layer free of direct workflows/dag
|
|
263
|
+
// kernel imports (see test/worker/console/security-and-tools.test.ts).
|
|
264
|
+
export { INTERRUPT_ERROR_MESSAGES, isInterruptReasonCode, normalizeInterruptReasonDetail, } from "../../workflows/dag/interrupt-request.js";
|
|
@@ -49,6 +49,7 @@ export function parseInspectObjectView(
|
|
|
49
49
|
export function activeInspectTabHref(hashValue: string): string | null;
|
|
50
50
|
export function parseInspectContext(hashValue: string): InspectContext | null;
|
|
51
51
|
export function resolveOperateHrefFromInspectHash(hashValue: string): string;
|
|
52
|
+
export function buildOperateInterruptHref(dagRunId: string): string;
|
|
52
53
|
export function buildInspectDagHref(
|
|
53
54
|
dagRunId: string,
|
|
54
55
|
options?: { from?: string },
|
|
@@ -118,6 +118,11 @@ export function resolveOperateHrefFromInspectHash(hashValue) {
|
|
|
118
118
|
return "/#/";
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** Inspect DAG 详情 → Operate Recovery 协作中止深链(只改导航,不发 mutation)。 */
|
|
122
|
+
export function buildOperateInterruptHref(dagRunId) {
|
|
123
|
+
return `/#/recovery?dagRunId=${encodeURIComponent(dagRunId)}&intent=interrupt`;
|
|
124
|
+
}
|
|
125
|
+
|
|
121
126
|
/** 操作侧 → Inspect 的 canonical 深链(可携带 from 以激活上下文 chip)。 */
|
|
122
127
|
export function buildInspectDagHref(dagRunId, options = {}) {
|
|
123
128
|
const base = `/inspect/#/dag/${encodeURIComponent(dagRunId)}`;
|