@tea-agent/loop-agent 0.36.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/cli/command-definitions.js +7 -0
  4. package/dist/cli/program.js +6 -1
  5. package/dist/commands/dag-request-interrupt.js +20 -0
  6. package/dist/executors/pi-sdk-executor.js +34 -0
  7. package/dist/shared/operator/capabilities.js +76 -3
  8. package/dist/task/source-prepare/semantic-intake.js +144 -23
  9. package/dist/worker/console/chat/semantic-activity.js +6 -0
  10. package/dist/worker/console/chat/workspace-landing.js +1 -0
  11. package/dist/worker/console/operation-runner.js +48 -0
  12. package/dist/worker/console/operation-wait.js +41 -0
  13. package/dist/worker/console/operator-actions.js +235 -26
  14. package/dist/worker/console/operator-user-error.js +4 -0
  15. package/dist/worker/console/recovery-cta.js +50 -3
  16. package/dist/worker/console/recovery-error-copy.js +198 -0
  17. package/dist/worker/console/static/assets/index-D83DYAFG.css +1 -0
  18. package/dist/worker/console/static/assets/index-IXm7oYjL.js +59 -0
  19. package/dist/worker/console/static/index.html +2 -2
  20. package/dist/worker/console/static-src/app/console-types.js +13 -9
  21. package/dist/worker/console/static-src/app/useOperatorActions.js +4 -2
  22. package/dist/worker/console/static-src/app/useRecoveryActions.js +65 -56
  23. package/dist/worker/console/static-src/app/useRecoveryConsole.js +68 -1
  24. package/dist/worker/observability/interrupt-eligibility.js +264 -0
  25. package/dist/worker/observe/static/operator-chrome.d.ts +1 -0
  26. package/dist/worker/observe/static/operator-chrome.js +5 -0
  27. package/dist/worker/observe/static/views/dag.js +12 -0
  28. package/dist/workflows/dag/interrupt-request.js +559 -0
  29. package/dist/workflows/dag/runner.js +72 -4
  30. package/package.json +1 -1
  31. package/skills/loop-agent/references/command-reference.md +1 -0
  32. package/dist/worker/console/static/assets/index-2OeZODxk.js +0 -57
  33. 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-2OeZODxk.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-DVJlUL8X.css">
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 formatDagRerunIneligible(plan) {
114
- const fromBlocked = (plan.blockedReasons ?? []).map(String);
115
- const fromNodes = (plan.blockingNodes ?? []).map((n) => [n.nodeId, n.reasonCode].filter(Boolean).join(":"));
116
- const reasons = [...fromBlocked, ...fromNodes].filter(Boolean);
117
- if (reasons.length === 0) {
118
- return "从节点重跑不具备安全资格(子图含 writer / unsafe shell / decision-gate 等)。可改用「完整重跑任务」,或另选只读安全子图的起始节点。";
119
- }
120
- return `从节点重跑不具备安全资格:${reasons.slice(0, 6).join("; ")}${reasons.length > 6 ? "…" : ""}。可改用「完整重跑任务」。`;
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 = 90_000) {
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–60 秒,请勿关闭页面)",
10
- importPrd: "正在导入 PRD:归档文档 → 确定性解析 → 语义结构化(约 10–60 秒,请勿关闭页面)",
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, and gated reruns/retries. */
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 ?? formatDagRerunIneligible(plan));
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 ?? formatDagRerunIneligible(plan));
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
- const actionParams = {
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
- const actionParams = {
217
+ await runAction("workerTaskRetry", {
209
218
  taskId: id,
210
219
  reason,
211
220
  ...(featureId.trim() ? { featureId: featureId.trim() } : {}),
212
- };
213
- const prep = await runAction("prepareMutationGate", {
214
- action: "workerTaskRetry",
215
- actionParams,
216
- });
217
- if (!isActionSuccess(prep))
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
- const receipt = (prep?.result ?? prep);
220
- if (!receipt.receiptId || !receipt.humanGateToken) {
221
- setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
229
+ }
230
+ if (reason.trim().length < 10 || reason.trim().length > 500) {
231
+ setOpError("中止原因去首尾空白后必须是 10–500 个字符,请手写说明,不要用占位句。");
222
232
  return;
223
233
  }
224
- await runAction("workerTaskRetry", {
225
- ...actionParams,
226
- confirmationId: receipt.receiptId,
227
- humanGateToken: receipt.humanGateToken,
228
- }, { clientRequestId });
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
- const actionParams = {
241
- runId,
242
- reason,
243
- reconcileAction: "supersede",
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(() => consoleRecoveryCtas(recoveryFact), [recoveryFact]);
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)}`;