@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
|
@@ -18,19 +18,23 @@ import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
|
|
|
18
18
|
import { deriveTaskIdentityFromPrd, nextTaskIdRevision, } from "./prd-identity.js";
|
|
19
19
|
import { appendEngineeringCliArgs, buildDraftFromTaskIntake, parseEngineeringBoundaryFromParams, } from "./prd-intake-bridge.js";
|
|
20
20
|
import { discoverPrdReferences } from "./prd-reference-discovery.js";
|
|
21
|
-
import { clearSemanticIntakeAttempt } from "../../task/source-prepare/semantic-intake.js";
|
|
21
|
+
import { clearSemanticIntakeAttempt, SEMANTIC_INTAKE_CLI_TIMEOUT_MS, } from "../../task/source-prepare/semantic-intake.js";
|
|
22
22
|
import nodePath from "node:path";
|
|
23
23
|
import { resolveSiblingAgentWorkerBin } from "./sibling-controller.js";
|
|
24
24
|
import { projectOperationEventSummary, projectOperationForChat, } from "./chat/chat-event-store.js";
|
|
25
25
|
import { OperationWaitError, waitForOperationChange, } from "./operation-wait.js";
|
|
26
26
|
import { MutationGateReceiptStore } from "./mutation-gate-receipt-store.js";
|
|
27
|
+
import { formatIneligibleRerunMessage } from "./recovery-error-copy.js";
|
|
28
|
+
import { evaluateConsoleDagInterruptEligibility } from "../observability/interrupt-eligibility.js";
|
|
29
|
+
import { INTERRUPT_ERROR_MESSAGES, isInterruptReasonCode, normalizeInterruptReasonDetail, } from "../observability/interrupt-eligibility.js";
|
|
27
30
|
import { assessAutonomousExecutionEligibility, deriveEligibilityFactsFromDagSpec, } from "./dag-execution-receipt.js";
|
|
28
31
|
import { issueHumanGateToken, mutationGatePayloadHash, verifyHumanGateToken, } from "./human-gate-token.js";
|
|
29
32
|
const MUTATION_GATE_ACTIONS = new Set([
|
|
30
33
|
// 2026-08-11: standaloneTaskRerun / workerTaskRetry are autonomous (server
|
|
31
|
-
// run-facts checks). Destructive DAG reconciliation
|
|
32
|
-
// mutations still need the browser Human Gate.
|
|
34
|
+
// run-facts checks). Destructive DAG reconciliation, live cooperative
|
|
35
|
+
// interrupt, and Night Scheduler mutations still need the browser Human Gate.
|
|
33
36
|
"dagReconcileRun",
|
|
37
|
+
"dagRequestInterrupt",
|
|
34
38
|
"workerAdmissionPrepare",
|
|
35
39
|
"workerSchedulerAdd",
|
|
36
40
|
"workerSchedulerCancel",
|
|
@@ -48,6 +52,7 @@ const MUTATION_OR_LONG = new Set([
|
|
|
48
52
|
"runDag",
|
|
49
53
|
"dagRerun",
|
|
50
54
|
"dagReconcileRun",
|
|
55
|
+
"dagRequestInterrupt",
|
|
51
56
|
"standaloneTaskRerun",
|
|
52
57
|
"workerTaskRetry",
|
|
53
58
|
]);
|
|
@@ -138,27 +143,46 @@ function gateError(action, code, message, details) {
|
|
|
138
143
|
}),
|
|
139
144
|
};
|
|
140
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* `dag report --json` is `{ schemaVersion, runs: [...] }` (no Operator `ok`).
|
|
148
|
+
* `runReadCli` used to wrap that as `{ stdout, raw }`, which made the
|
|
149
|
+
* autonomous rerun gate miss `primaryFailure` even after the UI showed the
|
|
150
|
+
* report (2026-08-16). Accept both the native report shape and the wrap.
|
|
151
|
+
*/
|
|
152
|
+
function extractDagReportRunFacts(reportResult, runId) {
|
|
153
|
+
const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value)
|
|
154
|
+
? value
|
|
155
|
+
: undefined;
|
|
156
|
+
const result = asRecord(reportResult);
|
|
157
|
+
const envelope = Array.isArray(result?.runs)
|
|
158
|
+
? result
|
|
159
|
+
: asRecord(result?.raw);
|
|
160
|
+
if (!envelope)
|
|
161
|
+
return undefined;
|
|
162
|
+
const runs = Array.isArray(envelope.runs) ? envelope.runs : [];
|
|
163
|
+
const matched = runs.find((entry) => asRecord(entry)?.runId === runId) ?? runs[0];
|
|
164
|
+
const matchedRec = asRecord(matched);
|
|
165
|
+
if (matchedRec?.primaryFailure) {
|
|
166
|
+
return {
|
|
167
|
+
primaryFailure: asRecord(matchedRec.primaryFailure),
|
|
168
|
+
primaryRecovery: asRecord(matchedRec.primaryRecovery),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (envelope.primaryFailure) {
|
|
172
|
+
return {
|
|
173
|
+
primaryFailure: asRecord(envelope.primaryFailure),
|
|
174
|
+
primaryRecovery: asRecord(envelope.primaryRecovery),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
141
179
|
/**
|
|
142
180
|
* 2026-08-11 R2 run-facts check for standaloneTaskRerun (read-only CLI
|
|
143
181
|
* evidence only — no browser Human Gate). Fails closed when facts are missing.
|
|
144
182
|
*/
|
|
145
183
|
async function assertAutonomousRerunEligible(ctx, runId, taskId) {
|
|
146
184
|
const report = await runReadCli(ctx, ["dag", "report", "--run-id", runId, "--json"], "dag report");
|
|
147
|
-
|
|
148
|
-
// (primaryFailure / primaryRecovery) live on each entry of `runs`, not on
|
|
149
|
-
// the envelope top level. Reading them from the top level made every
|
|
150
|
-
// standaloneTaskRerun call fail closed with "cannot read dagReport run
|
|
151
|
-
// facts" even though the evidence was present (2026-08-15 parse drift).
|
|
152
|
-
const reportEnvelope = (report.ok ? report.result : undefined);
|
|
153
|
-
const reportRuns = reportEnvelope?.runs ?? [];
|
|
154
|
-
const matchedRun = reportRuns.find((entry) => entry.runId === runId) ?? reportRuns[0];
|
|
155
|
-
const reportResult = matchedRun ??
|
|
156
|
-
(reportEnvelope?.primaryFailure
|
|
157
|
-
? {
|
|
158
|
-
primaryFailure: reportEnvelope.primaryFailure,
|
|
159
|
-
primaryRecovery: reportEnvelope.primaryRecovery,
|
|
160
|
-
}
|
|
161
|
-
: undefined);
|
|
185
|
+
const reportResult = extractDagReportRunFacts(report.ok ? report.result : undefined, runId);
|
|
162
186
|
const recovery = reportResult?.primaryRecovery;
|
|
163
187
|
if (!report.ok || !reportResult?.primaryFailure || !recovery) {
|
|
164
188
|
return gateError("standaloneTaskRerun", "INVALID_INPUT", "cannot read dagReport run facts; standaloneTaskRerun requires server-side run evidence", { runId });
|
|
@@ -304,6 +328,9 @@ async function runReadCli(ctx, args, command) {
|
|
|
304
328
|
if (env.schemaVersion === 1 && typeof env.ok === "boolean") {
|
|
305
329
|
return env;
|
|
306
330
|
}
|
|
331
|
+
if (raw.schemaVersion === 1 && Array.isArray(raw.runs)) {
|
|
332
|
+
return operatorSucceeded(command, raw);
|
|
333
|
+
}
|
|
307
334
|
if (command === "dag rerun plan" &&
|
|
308
335
|
raw.plan &&
|
|
309
336
|
typeof raw.plan === "object") {
|
|
@@ -315,9 +342,8 @@ async function runReadCli(ctx, args, command) {
|
|
|
315
342
|
? plan.blockedReasons.map(String)
|
|
316
343
|
: [];
|
|
317
344
|
const blockingNodes = Array.isArray(plan.blockingNodes)
|
|
318
|
-
? plan.blockingNodes
|
|
345
|
+
? plan.blockingNodes
|
|
319
346
|
: [];
|
|
320
|
-
const reasons = [...blockedReasons, ...blockingNodes].filter(Boolean);
|
|
321
347
|
return buildOperatorResult({
|
|
322
348
|
command,
|
|
323
349
|
ok: false,
|
|
@@ -325,9 +351,10 @@ async function runReadCli(ctx, args, command) {
|
|
|
325
351
|
result: plan,
|
|
326
352
|
error: {
|
|
327
353
|
code: "DAG_RERUN_INELIGIBLE",
|
|
328
|
-
message:
|
|
329
|
-
|
|
330
|
-
|
|
354
|
+
message: formatIneligibleRerunMessage({
|
|
355
|
+
blockedReasons,
|
|
356
|
+
blockingNodes,
|
|
357
|
+
}),
|
|
331
358
|
details: {
|
|
332
359
|
eligible: false,
|
|
333
360
|
planHash: typeof plan.planHash === "string" ? plan.planHash : undefined,
|
|
@@ -564,6 +591,30 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
564
591
|
afterSeq,
|
|
565
592
|
maxWaitMs,
|
|
566
593
|
...(wakeOn ? { wakeOn } : {}),
|
|
594
|
+
poll: async () => {
|
|
595
|
+
const current = await ctx.operations.get(operationId);
|
|
596
|
+
const runId = current?.dagRunId ??
|
|
597
|
+
(typeof current?.actionParams?.runId === "string"
|
|
598
|
+
? current.actionParams.runId
|
|
599
|
+
: undefined);
|
|
600
|
+
if (!current || !runId)
|
|
601
|
+
return false;
|
|
602
|
+
const eligibility = await evaluateConsoleDagInterruptEligibility({
|
|
603
|
+
repoRoot: ctx.repoRoot,
|
|
604
|
+
operations: ctx.operations,
|
|
605
|
+
runId,
|
|
606
|
+
});
|
|
607
|
+
if (!eligibility.interrupt)
|
|
608
|
+
return false;
|
|
609
|
+
const targetId = eligibility.targetOperationId ?? current.operationId;
|
|
610
|
+
return syncTargetInterruptProjection({
|
|
611
|
+
operations: ctx.operations,
|
|
612
|
+
events: ctx.events,
|
|
613
|
+
operationId: targetId,
|
|
614
|
+
runId,
|
|
615
|
+
interrupt: eligibility.interrupt,
|
|
616
|
+
});
|
|
617
|
+
},
|
|
567
618
|
});
|
|
568
619
|
return {
|
|
569
620
|
kind: "sync",
|
|
@@ -1557,7 +1608,7 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
1557
1608
|
cwd: ctx.repoRoot,
|
|
1558
1609
|
artifactName: "import-prd-task-advance-intake",
|
|
1559
1610
|
expectJson: true,
|
|
1560
|
-
timeoutMs:
|
|
1611
|
+
timeoutMs: SEMANTIC_INTAKE_CLI_TIMEOUT_MS,
|
|
1561
1612
|
});
|
|
1562
1613
|
const intakeJson = intake.json ?? null;
|
|
1563
1614
|
const intakeOk = intake.ok && intake.exitCode === 0 && !intake.timedOut;
|
|
@@ -1983,6 +2034,99 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
1983
2034
|
],
|
|
1984
2035
|
});
|
|
1985
2036
|
}
|
|
2037
|
+
case "dagInterruptEligibility": {
|
|
2038
|
+
const runId = str(p.runId) ?? str(p.dagRunId);
|
|
2039
|
+
if (!runId)
|
|
2040
|
+
return invalid(action, "runId is required");
|
|
2041
|
+
const eligibility = await evaluateConsoleDagInterruptEligibility({
|
|
2042
|
+
repoRoot: ctx.repoRoot,
|
|
2043
|
+
operations: ctx.operations,
|
|
2044
|
+
runId,
|
|
2045
|
+
});
|
|
2046
|
+
if (eligibility.interrupt && eligibility.targetOperationId) {
|
|
2047
|
+
await syncTargetInterruptProjection({
|
|
2048
|
+
operations: ctx.operations,
|
|
2049
|
+
events: ctx.events,
|
|
2050
|
+
operationId: eligibility.targetOperationId,
|
|
2051
|
+
runId,
|
|
2052
|
+
interrupt: eligibility.interrupt,
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
return {
|
|
2056
|
+
kind: "sync",
|
|
2057
|
+
status: 200,
|
|
2058
|
+
body: operatorSucceeded(action, eligibility),
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
case "dagRequestInterrupt": {
|
|
2062
|
+
const runId = str(p.runId);
|
|
2063
|
+
const reasonCode = str(p.reasonCode);
|
|
2064
|
+
const reasonDetail = str(p.reasonDetail) ?? str(p.reason);
|
|
2065
|
+
const clientRequestId = str(req.clientRequestId);
|
|
2066
|
+
if (!runId || !reasonCode || !reasonDetail || !clientRequestId) {
|
|
2067
|
+
return invalid(action, "runId, reasonCode, reasonDetail, and clientRequestId are required");
|
|
2068
|
+
}
|
|
2069
|
+
if (!isInterruptReasonCode(reasonCode)) {
|
|
2070
|
+
return invalid(action, `reasonCode must be one of requirements-changed | suspected-runaway | resource-protection | operator-request | other`);
|
|
2071
|
+
}
|
|
2072
|
+
try {
|
|
2073
|
+
normalizeInterruptReasonDetail(reasonDetail);
|
|
2074
|
+
}
|
|
2075
|
+
catch (error) {
|
|
2076
|
+
return invalid(action, error instanceof Error ? error.message : String(error));
|
|
2077
|
+
}
|
|
2078
|
+
const gated = await consumeMutationGateReceipt(ctx, action, p);
|
|
2079
|
+
if (gated.kind === "error")
|
|
2080
|
+
return gated;
|
|
2081
|
+
if (gated.kind === "idempotent")
|
|
2082
|
+
return gated.result;
|
|
2083
|
+
const eligibility = await evaluateConsoleDagInterruptEligibility({
|
|
2084
|
+
repoRoot: ctx.repoRoot,
|
|
2085
|
+
operations: ctx.operations,
|
|
2086
|
+
runId,
|
|
2087
|
+
});
|
|
2088
|
+
if (!eligibility.eligible || !eligibility.targetOperationId || !eligibility.expectedRunnerIdentity) {
|
|
2089
|
+
const failed = interruptActionFailed(action, eligibility.reasonCodes[0] ?? "RUN_NOT_ACTIVE", eligibility.message);
|
|
2090
|
+
return failed;
|
|
2091
|
+
}
|
|
2092
|
+
const expected = eligibility.expectedRunnerIdentity;
|
|
2093
|
+
const accepted = await acceptOperation(ctx, {
|
|
2094
|
+
action,
|
|
2095
|
+
actionParams: {
|
|
2096
|
+
runId,
|
|
2097
|
+
reasonCode,
|
|
2098
|
+
reasonDetail,
|
|
2099
|
+
targetOperationId: eligibility.targetOperationId,
|
|
2100
|
+
expectedRunnerIdentity: expected,
|
|
2101
|
+
},
|
|
2102
|
+
clientRequestId,
|
|
2103
|
+
cliArgs: [
|
|
2104
|
+
"dag",
|
|
2105
|
+
"request-interrupt",
|
|
2106
|
+
"--run-id",
|
|
2107
|
+
runId,
|
|
2108
|
+
"--request-id",
|
|
2109
|
+
clientRequestId,
|
|
2110
|
+
"--target-operation-id",
|
|
2111
|
+
eligibility.targetOperationId,
|
|
2112
|
+
"--reason-code",
|
|
2113
|
+
reasonCode,
|
|
2114
|
+
"--reason",
|
|
2115
|
+
reasonDetail,
|
|
2116
|
+
"--expected-runner-pid",
|
|
2117
|
+
String(expected.pid),
|
|
2118
|
+
"--expected-runner-hostname",
|
|
2119
|
+
expected.hostname,
|
|
2120
|
+
"--expected-runner-started-at",
|
|
2121
|
+
expected.startedAt,
|
|
2122
|
+
"--expected-controller-fingerprint",
|
|
2123
|
+
expected.controllerFingerprint,
|
|
2124
|
+
"--json",
|
|
2125
|
+
],
|
|
2126
|
+
});
|
|
2127
|
+
await finalizeMutationGateReceipt(ctx, gated.receiptId, accepted);
|
|
2128
|
+
return accepted;
|
|
2129
|
+
}
|
|
1986
2130
|
case "dagReconcileRun": {
|
|
1987
2131
|
// S6 Recovery: orphan/interrupted runs — structured reason is the human
|
|
1988
2132
|
// confirmation surface (CLI eligibility still fail-closed).
|
|
@@ -2276,7 +2420,7 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
2276
2420
|
return invalid(action, "action and actionParams are required");
|
|
2277
2421
|
}
|
|
2278
2422
|
if (!MUTATION_GATE_ACTIONS.has(targetAction)) {
|
|
2279
|
-
return invalid(action, "action must be a mutation-gate target (dagReconcileRun | workerAdmissionPrepare | workerSchedulerAdd | workerSchedulerCancel | workerSchedulerHarvest | workerSchedulerDiscard)");
|
|
2423
|
+
return invalid(action, "action must be a mutation-gate target (dagReconcileRun | dagRequestInterrupt | workerAdmissionPrepare | workerSchedulerAdd | workerSchedulerCancel | workerSchedulerHarvest | workerSchedulerDiscard)");
|
|
2280
2424
|
}
|
|
2281
2425
|
const secret = ctx.humanGateSecret;
|
|
2282
2426
|
if (!secret) {
|
|
@@ -2847,7 +2991,7 @@ async function dispatchBootstrapFromPrd(ctx, p) {
|
|
|
2847
2991
|
cwd: ctx.repoRoot,
|
|
2848
2992
|
artifactName: "bootstrap-task-advance-intake",
|
|
2849
2993
|
expectJson: true,
|
|
2850
|
-
timeoutMs:
|
|
2994
|
+
timeoutMs: SEMANTIC_INTAKE_CLI_TIMEOUT_MS,
|
|
2851
2995
|
});
|
|
2852
2996
|
const intakeJson = intake.json ?? null;
|
|
2853
2997
|
const intakeOk = intake.ok && intake.exitCode === 0 && !intake.timedOut;
|
|
@@ -3321,6 +3465,71 @@ function invalid(action, message) {
|
|
|
3321
3465
|
}),
|
|
3322
3466
|
};
|
|
3323
3467
|
}
|
|
3468
|
+
function interruptActionFailed(action, code, message) {
|
|
3469
|
+
const blocked = code === "INTERRUPT_ALREADY_PENDING" ||
|
|
3470
|
+
code === "INTERRUPT_NEEDS_RECONCILE" ||
|
|
3471
|
+
code === "RUN_ALREADY_TERMINAL" ||
|
|
3472
|
+
code === "RUN_NOT_CONSOLE_OWNED" ||
|
|
3473
|
+
code === "RUNNER_IDENTITY_MISMATCH" ||
|
|
3474
|
+
code === "RUN_NOT_ACTIVE";
|
|
3475
|
+
return {
|
|
3476
|
+
kind: "error",
|
|
3477
|
+
status: code === "INVALID_INPUT" ? 400 : 409,
|
|
3478
|
+
body: operatorFailed({
|
|
3479
|
+
command: action,
|
|
3480
|
+
outcome: blocked ? "blocked" : "invalid",
|
|
3481
|
+
code,
|
|
3482
|
+
message: message || INTERRUPT_ERROR_MESSAGES[code] || message,
|
|
3483
|
+
}),
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
async function syncTargetInterruptProjection(input) {
|
|
3487
|
+
const current = await input.operations.get(input.operationId);
|
|
3488
|
+
if (!current)
|
|
3489
|
+
return false;
|
|
3490
|
+
if (current.interrupt?.status === input.interrupt.status &&
|
|
3491
|
+
current.interrupt?.requestId === input.interrupt.requestId) {
|
|
3492
|
+
return false;
|
|
3493
|
+
}
|
|
3494
|
+
const nextStatus = input.interrupt.status;
|
|
3495
|
+
if (nextStatus !== "requested" &&
|
|
3496
|
+
nextStatus !== "acknowledged" &&
|
|
3497
|
+
nextStatus !== "settled" &&
|
|
3498
|
+
nextStatus !== "needs-reconcile") {
|
|
3499
|
+
return false;
|
|
3500
|
+
}
|
|
3501
|
+
await input.operations.update(input.operationId, {
|
|
3502
|
+
dagRunId: current.dagRunId ?? input.runId,
|
|
3503
|
+
interrupt: {
|
|
3504
|
+
status: nextStatus,
|
|
3505
|
+
...(input.interrupt.requestId
|
|
3506
|
+
? { requestId: input.interrupt.requestId }
|
|
3507
|
+
: {}),
|
|
3508
|
+
...(input.interrupt.reasonCode
|
|
3509
|
+
? { reasonCode: input.interrupt.reasonCode }
|
|
3510
|
+
: {}),
|
|
3511
|
+
...(input.interrupt.requestedAt
|
|
3512
|
+
? { requestedAt: input.interrupt.requestedAt }
|
|
3513
|
+
: {}),
|
|
3514
|
+
...(input.interrupt.acknowledgedAt
|
|
3515
|
+
? { acknowledgedAt: input.interrupt.acknowledgedAt }
|
|
3516
|
+
: {}),
|
|
3517
|
+
...(input.interrupt.settledAt
|
|
3518
|
+
? { settledAt: input.interrupt.settledAt }
|
|
3519
|
+
: {}),
|
|
3520
|
+
...(input.interrupt.outcome ? { outcome: input.interrupt.outcome } : {}),
|
|
3521
|
+
},
|
|
3522
|
+
});
|
|
3523
|
+
input.events.append(input.operationId, {
|
|
3524
|
+
kind: "state",
|
|
3525
|
+
message: `interrupt ${nextStatus}`,
|
|
3526
|
+
data: {
|
|
3527
|
+
interruptStatus: nextStatus,
|
|
3528
|
+
runId: input.runId,
|
|
3529
|
+
},
|
|
3530
|
+
});
|
|
3531
|
+
return true;
|
|
3532
|
+
}
|
|
3324
3533
|
function confirmationErrorStatus(code) {
|
|
3325
3534
|
if (code === "NOT_FOUND")
|
|
3326
3535
|
return 404;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { rewriteRecoveryOperatorError } from "./recovery-error-copy.js";
|
|
1
2
|
/** Map internal spawn/controller errors to actionable Chinese copy. */
|
|
2
3
|
export function formatOperatorUserError(message) {
|
|
3
4
|
const raw = (message ?? "").trim();
|
|
4
5
|
if (!raw)
|
|
5
6
|
return "操作失败";
|
|
7
|
+
const recovery = rewriteRecoveryOperatorError(raw);
|
|
8
|
+
if (recovery)
|
|
9
|
+
return recovery;
|
|
6
10
|
if (/controller identity changed|package fingerprint changed|entry binary sha256 changed/i.test(raw)) {
|
|
7
11
|
return "控制器版本已变更(Console 启动后代码被重新编译)。请重启 Console 后再试,一次会话中途不要升级/重建控制器。";
|
|
8
12
|
}
|
|
@@ -63,7 +63,7 @@ const CTA = {
|
|
|
63
63
|
requiresReason: true,
|
|
64
64
|
action: "dagRerun",
|
|
65
65
|
lane: "dag",
|
|
66
|
-
guide: "终端失败默认首选:先 dagRerunPlan,plan
|
|
66
|
+
guide: "终端失败默认首选:先 dagRerunPlan,plan 合格后直接执行(无需 Human Gate)。Worker 持有的 run、workspace/controller 漂移或含 unsafe shell 时不要强跑子图",
|
|
67
67
|
commandHint: "loop-agent dag rerun --run-id <run-id> --from-node <node-id> --plan",
|
|
68
68
|
},
|
|
69
69
|
standaloneTaskRerun: {
|
|
@@ -72,7 +72,7 @@ const CTA = {
|
|
|
72
72
|
requiresReason: true,
|
|
73
73
|
action: "standaloneTaskRerun",
|
|
74
74
|
lane: "dag",
|
|
75
|
-
guide: "整单重跑:仅当 from-node plan
|
|
75
|
+
guide: "独立 DAG run 整单重跑:仅当 from-node plan 不合格时直调执行,服务端校验 run facts,无需 Human Gate。Worker 持有的 run 请用下方「重新排队 Worker Task」",
|
|
76
76
|
commandHint: "loop-agent dag rerun-task --run-id <run-id> --reason <text> --json",
|
|
77
77
|
},
|
|
78
78
|
workerTaskRetry: {
|
|
@@ -81,9 +81,18 @@ const CTA = {
|
|
|
81
81
|
requiresReason: true,
|
|
82
82
|
action: "workerTaskRetry",
|
|
83
83
|
lane: "worker",
|
|
84
|
-
guide: "仅 Worker
|
|
84
|
+
guide: "仅 Worker 队列任务失败时直调执行,服务端校验 Failed 状态,无需 Human Gate;不要与 DAG 完整重跑混用",
|
|
85
85
|
commandHint: "agent-worker task retry <task-id> --feature-id <feature-id> --repo . --reason <reason>",
|
|
86
86
|
},
|
|
87
|
+
interrupt: {
|
|
88
|
+
id: "interrupt",
|
|
89
|
+
label: "中止运行",
|
|
90
|
+
requiresReason: true,
|
|
91
|
+
action: "dagRequestInterrupt",
|
|
92
|
+
lane: "dag",
|
|
93
|
+
guide: "仅本机 Console 持有的正在运行 DAG:经 Human Gate 登记协作中止,等待当前节点安全收敛。不会立即强杀进程",
|
|
94
|
+
commandHint: "loop-agent dag request-interrupt --run-id <run-id> --reason-code operator-request --reason <structured-reason>",
|
|
95
|
+
},
|
|
87
96
|
};
|
|
88
97
|
const MATRIX = {
|
|
89
98
|
"validation-failed": ["report", "doctor", "regenerate"],
|
|
@@ -111,6 +120,7 @@ const MATRIX = {
|
|
|
111
120
|
],
|
|
112
121
|
"terminal-succeeded": ["report", "observeLink"],
|
|
113
122
|
"needs-reconcile": ["report", "doctor", "reconcile", "regenerate"],
|
|
123
|
+
"running-active": ["interrupt", "report", "doctor", "observeLink"],
|
|
114
124
|
};
|
|
115
125
|
const FORBIDDEN_LABELS = [
|
|
116
126
|
"直接改代码",
|
|
@@ -118,6 +128,38 @@ const FORBIDDEN_LABELS = [
|
|
|
118
128
|
"Cancel",
|
|
119
129
|
"cancel operation",
|
|
120
130
|
];
|
|
131
|
+
const TERMINAL_OPERATION_STATES = new Set([
|
|
132
|
+
"succeeded",
|
|
133
|
+
"failed",
|
|
134
|
+
"timed-out",
|
|
135
|
+
]);
|
|
136
|
+
/**
|
|
137
|
+
* Orthogonal interrupt progress copy. "已中止" only when run terminal facts and
|
|
138
|
+
* the target operation exit are both confirmed; otherwise keep requested /
|
|
139
|
+
* converging / reconcile wording.
|
|
140
|
+
*/
|
|
141
|
+
export function interruptProgressCopy(input) {
|
|
142
|
+
const status = (input.interruptStatus ?? "").trim();
|
|
143
|
+
if (!status || status === "none")
|
|
144
|
+
return null;
|
|
145
|
+
if (status === "requested")
|
|
146
|
+
return "请求已登记,等待 runner 确认。";
|
|
147
|
+
if (status === "acknowledged") {
|
|
148
|
+
return "等待当前节点安全收敛(不会立即强杀)。";
|
|
149
|
+
}
|
|
150
|
+
if (status === "needs-reconcile") {
|
|
151
|
+
return "需要对账:中止请求未能确认。";
|
|
152
|
+
}
|
|
153
|
+
if (status === "settled") {
|
|
154
|
+
const terminalRun = input.dagStatus === "failed" || input.dagStatus === "partial_failed";
|
|
155
|
+
const interrupted = (input.failureCategory ?? "").includes("controller-interrupted");
|
|
156
|
+
const operationExited = TERMINAL_OPERATION_STATES.has(input.targetOperationState ?? "");
|
|
157
|
+
if (terminalRun && interrupted && operationExited)
|
|
158
|
+
return "已中止。";
|
|
159
|
+
return "正在对账,尚未确认退出。";
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
121
163
|
/**
|
|
122
164
|
* Return allowed recovery CTAs for a fact/failure class.
|
|
123
165
|
* Never includes Cancel or primary "edit code".
|
|
@@ -173,6 +215,8 @@ export function recommendedRecoveryCtaId(factClass) {
|
|
|
173
215
|
case "needs-reconcile":
|
|
174
216
|
// Prefer doctor first; reconcile is available when operator confirms reason.
|
|
175
217
|
return pick("doctor", "reconcile", "report");
|
|
218
|
+
case "running-active":
|
|
219
|
+
return pick("interrupt", "doctor", "report");
|
|
176
220
|
case "runtime-mismatch":
|
|
177
221
|
case "contract-drift":
|
|
178
222
|
return pick("doctor", "report");
|
|
@@ -251,6 +295,9 @@ export function classifyRecoveryFact(input) {
|
|
|
251
295
|
state.includes("suspected-stall")) {
|
|
252
296
|
return "orphan-unknown";
|
|
253
297
|
}
|
|
298
|
+
if (dag === "running" || dag === "active") {
|
|
299
|
+
return "running-active";
|
|
300
|
+
}
|
|
254
301
|
if (state === "succeeded" ||
|
|
255
302
|
state === "completed" ||
|
|
256
303
|
state === "finished" ||
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-facing Chinese copy for recovery failures.
|
|
3
|
+
* Reason codes stay in plan JSON; flash / chat must not dump them raw.
|
|
4
|
+
*/
|
|
5
|
+
const REASON_LABELS = {
|
|
6
|
+
"worker-managed-rerun-unsupported": "这次运行由 Console/Worker 启动,不能只从某个节点续跑",
|
|
7
|
+
"workspace-drift": "工作区在上次运行后又改过文件,旧结果不能接着用",
|
|
8
|
+
"controller-drift": "控制器版本已变化,不能接到上次运行上继续",
|
|
9
|
+
"binding-drift": "任务契约或源文件已变化,不能只续跑旧计划",
|
|
10
|
+
"restart-subgraph-contains-unsafe-shell": "下游包含会执行命令的验证步骤,不能只重跑这一段",
|
|
11
|
+
"restart-subgraph-contains-writer": "下游包含会改代码的写入步骤,不能只重跑这一段",
|
|
12
|
+
"restart-subgraph-contains-decision-gate": "下游包含需要人工决策的门禁,不能只重跑这一段",
|
|
13
|
+
"dynamic-rerun-unsupported": "这次运行含动态展开节点,不支持从节点续跑",
|
|
14
|
+
"parent-lifecycle-ineligible": "当前运行状态不允许从节点续跑",
|
|
15
|
+
"convergence-rerun-unsupported": "收敛型运行不支持从节点续跑",
|
|
16
|
+
"evaluation-rerun-unsupported": "带评估的运行不支持从节点续跑",
|
|
17
|
+
"budget-rerun-unsupported": "硬预算运行不支持从节点续跑",
|
|
18
|
+
"workspace-checkpoint-missing": "缺少工作区快照,无法安全地只续跑一段",
|
|
19
|
+
"skill-snapshot-invalid": "技能快照缺失或损坏,不能接到上次运行上继续",
|
|
20
|
+
"unresolved-errors-outside-reset-closure": "选中节点之外还有未解决的失败,只重跑这一段不够",
|
|
21
|
+
"selected-node-missing": "找不到要续跑的起始节点",
|
|
22
|
+
"ambiguous-skipped-rewrite": "跳过的节点有多个上游失败,无法自动选定起点",
|
|
23
|
+
"skipped-without-upstream-error": "该节点被跳过且没有明确的上游失败",
|
|
24
|
+
"parent-facts-invalid": "上次运行的证据不完整或已损坏,不能只续跑一段",
|
|
25
|
+
RUN_NOT_ACTIVE: "这次运行不在活动状态,不能协作中止",
|
|
26
|
+
RUN_NOT_CONSOLE_OWNED: "此运行不由当前 Console 持有,暂不支持协作中止",
|
|
27
|
+
RUNNER_IDENTITY_MISMATCH: "运行身份已变化,不能中止这份过期请求",
|
|
28
|
+
INTERRUPT_ALREADY_PENDING: "已有一条中止请求等待结算",
|
|
29
|
+
RUN_ALREADY_TERMINAL: "运行已经结束,没有可中止的活 runner",
|
|
30
|
+
INTERRUPT_NEEDS_RECONCILE: "中止请求已登记但无法确认 runner 已处理,需要对账",
|
|
31
|
+
};
|
|
32
|
+
function knownCodes() {
|
|
33
|
+
return Object.keys(REASON_LABELS);
|
|
34
|
+
}
|
|
35
|
+
export function parseIneligibleReasonToken(token) {
|
|
36
|
+
const trimmed = token.trim().replace(/[.…]+$/, "");
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return null;
|
|
39
|
+
if (REASON_LABELS[trimmed])
|
|
40
|
+
return { code: trimmed };
|
|
41
|
+
const colon = trimmed.lastIndexOf(":");
|
|
42
|
+
if (colon > 0) {
|
|
43
|
+
const code = trimmed.slice(colon + 1);
|
|
44
|
+
const nodeId = trimmed.slice(0, colon);
|
|
45
|
+
if (REASON_LABELS[code])
|
|
46
|
+
return { code, nodeId };
|
|
47
|
+
const match = knownCodes().find((known) => code.length >= 3 && known.startsWith(code));
|
|
48
|
+
if (match)
|
|
49
|
+
return { code: match, nodeId };
|
|
50
|
+
}
|
|
51
|
+
return REASON_LABELS[trimmed] ? { code: trimmed } : null;
|
|
52
|
+
}
|
|
53
|
+
function collectParsedReasons(input) {
|
|
54
|
+
const parsed = [];
|
|
55
|
+
for (const token of input.blockedReasons ?? []) {
|
|
56
|
+
const item = parseIneligibleReasonToken(String(token));
|
|
57
|
+
if (item)
|
|
58
|
+
parsed.push(item);
|
|
59
|
+
}
|
|
60
|
+
for (const node of input.blockingNodes ?? []) {
|
|
61
|
+
const code = node.reasonCode?.trim();
|
|
62
|
+
if (!code)
|
|
63
|
+
continue;
|
|
64
|
+
const item = parseIneligibleReasonToken(node.nodeId ? `${node.nodeId}:${code}` : code);
|
|
65
|
+
if (item)
|
|
66
|
+
parsed.push(item);
|
|
67
|
+
}
|
|
68
|
+
if (parsed.length === 0 && input.fallbackMessage?.trim()) {
|
|
69
|
+
const raw = input.fallbackMessage
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/^从节点重跑不具备安全资格[::]?\s*/, "")
|
|
72
|
+
.replace(/[。..]+$/, "");
|
|
73
|
+
for (const part of raw.split(/[;;]/)) {
|
|
74
|
+
const item = parseIneligibleReasonToken(part);
|
|
75
|
+
if (item)
|
|
76
|
+
parsed.push(item);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
function uniqueReasonLines(parsed) {
|
|
82
|
+
const nodesByCode = new Map();
|
|
83
|
+
for (const item of parsed) {
|
|
84
|
+
const nodes = nodesByCode.get(item.code) ?? [];
|
|
85
|
+
if (item.nodeId && !nodes.includes(item.nodeId))
|
|
86
|
+
nodes.push(item.nodeId);
|
|
87
|
+
nodesByCode.set(item.code, nodes);
|
|
88
|
+
}
|
|
89
|
+
const lines = [];
|
|
90
|
+
for (const [code, nodes] of nodesByCode) {
|
|
91
|
+
const label = REASON_LABELS[code];
|
|
92
|
+
if (!label)
|
|
93
|
+
continue;
|
|
94
|
+
if (nodes.length > 0 && code.startsWith("restart-subgraph-contains-")) {
|
|
95
|
+
lines.push(`${label}(${nodes.join("、")})`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
lines.push(label);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
export function nextActionForIneligibleRerun(reasons) {
|
|
104
|
+
const haystack = reasons.join("; ");
|
|
105
|
+
if (haystack.includes("worker-managed-rerun-unsupported") ||
|
|
106
|
+
reasons.some((reason) => parseIneligibleReasonToken(reason)?.code ===
|
|
107
|
+
"worker-managed-rerun-unsupported")) {
|
|
108
|
+
return "下一步:请点下方「重新排队 Worker Task」,从头完整执行。";
|
|
109
|
+
}
|
|
110
|
+
return "下一步:请改用「完整重跑任务」,从头完整执行。";
|
|
111
|
+
}
|
|
112
|
+
export function formatIneligibleRerunMessage(input) {
|
|
113
|
+
const fallback = input.fallbackMessage?.trim();
|
|
114
|
+
if (fallback?.startsWith("无法从该节点续跑"))
|
|
115
|
+
return fallback;
|
|
116
|
+
const parsed = collectParsedReasons(input);
|
|
117
|
+
const lines = uniqueReasonLines(parsed);
|
|
118
|
+
const reasonTokens = parsed.map((item) => item.code);
|
|
119
|
+
const next = nextActionForIneligibleRerun(reasonTokens.length > 0 ? reasonTokens : [fallback ?? ""]);
|
|
120
|
+
if (lines.length === 0) {
|
|
121
|
+
return [
|
|
122
|
+
"无法从该节点续跑。这次续跑不够安全(工作区、控制器或下游步骤不允许只重跑一段)。",
|
|
123
|
+
next,
|
|
124
|
+
].join("\n");
|
|
125
|
+
}
|
|
126
|
+
return [
|
|
127
|
+
"无法从该节点续跑,因为:",
|
|
128
|
+
...lines.map((line) => `• ${line}`),
|
|
129
|
+
"",
|
|
130
|
+
next,
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
/** Returns rewritten copy when `raw` is a known recovery failure; otherwise null. */
|
|
134
|
+
export function rewriteRecoveryOperatorError(raw) {
|
|
135
|
+
const text = raw.trim();
|
|
136
|
+
if (!text)
|
|
137
|
+
return null;
|
|
138
|
+
if (text.startsWith("无法从该节点续跑"))
|
|
139
|
+
return text;
|
|
140
|
+
if (/从节点重跑不具备安全资格|worker-managed-rerun-unsupported|restart-subgraph-contains-|workspace-drift|controller-drift|binding-drift/.test(text)) {
|
|
141
|
+
return formatIneligibleRerunMessage({ fallbackMessage: text });
|
|
142
|
+
}
|
|
143
|
+
if (/action must be a mutation-gate target/i.test(text)) {
|
|
144
|
+
return "这个操作不能再走确认门。Worker 启动的任务请直接点「重新排队 Worker Task」;独立运行请点「完整重跑任务」。";
|
|
145
|
+
}
|
|
146
|
+
if (/cannot read dagReport run facts/i.test(text)) {
|
|
147
|
+
return "读不到这次运行的失败报告,不能完整重跑。请先点「查看报告」或「Doctor 诊断」。";
|
|
148
|
+
}
|
|
149
|
+
if (/dagRerunPlan run facts unavailable/i.test(text)) {
|
|
150
|
+
return "读不到从节点续跑的资格结果,不能改走完整重跑。请先点「从节点重跑」查看资格,或先「查看报告」。";
|
|
151
|
+
}
|
|
152
|
+
if (/node rerun plan is eligible/i.test(text)) {
|
|
153
|
+
return "从节点续跑是安全的,请改用「从节点重跑」,不必完整重跑整单。";
|
|
154
|
+
}
|
|
155
|
+
if (/does not support autonomous standaloneTaskRerun/i.test(text)) {
|
|
156
|
+
return "当前失败不适合自动完整重跑。请先点「查看报告」,按建议操作。";
|
|
157
|
+
}
|
|
158
|
+
if (/primaryRecovery requires a human decision/i.test(text)) {
|
|
159
|
+
return "这次失败需要人工决策,不能自动重跑。请先点「查看报告」。";
|
|
160
|
+
}
|
|
161
|
+
if (/is not bound to run/i.test(text)) {
|
|
162
|
+
return "填写的任务 ID 与这次运行不匹配。请使用该运行绑定的原任务 ID。";
|
|
163
|
+
}
|
|
164
|
+
if (/cannot read pool doctor/i.test(text)) {
|
|
165
|
+
return "读不到任务队列状态,暂时不能重新排队。请稍后重试,或先跑 Doctor 诊断。";
|
|
166
|
+
}
|
|
167
|
+
if (/not found in the pool doctor inventory/i.test(text)) {
|
|
168
|
+
return "任务队列里找不到这个任务,不能重新排队。请确认 Task ID 来自 Worker 队列,而不是只填了 DAG 运行 ID。";
|
|
169
|
+
}
|
|
170
|
+
if (/only Failed tasks are retry-eligible/i.test(text)) {
|
|
171
|
+
const status = text.match(/is ([A-Za-z_]+); only Failed/)?.[1];
|
|
172
|
+
return status
|
|
173
|
+
? `该任务当前是「${status}」,只有失败(Failed)的任务才能重新排队。`
|
|
174
|
+
: "只有失败(Failed)的任务才能重新排队。请先确认任务已失败。";
|
|
175
|
+
}
|
|
176
|
+
if (/featureId .+ does not match the pool task/i.test(text)) {
|
|
177
|
+
return "Feature ID 与队列中的任务不一致。请改用队列里记录的 Feature ID,或留空后重试。";
|
|
178
|
+
}
|
|
179
|
+
if (/RUN_NOT_CONSOLE_OWNED|不由当前 Console 持有/.test(text)) {
|
|
180
|
+
return "此运行不由当前 Console 持有,暂不支持协作中止。请用 Doctor / 报告查看。";
|
|
181
|
+
}
|
|
182
|
+
if (/RUNNER_IDENTITY_MISMATCH|运行身份已变化/.test(text)) {
|
|
183
|
+
return "运行身份已变化,不能中止这份过期请求。请刷新后再试。";
|
|
184
|
+
}
|
|
185
|
+
if (/INTERRUPT_ALREADY_PENDING|已有一条中止请求/.test(text)) {
|
|
186
|
+
return "已有一条中止请求等待当前节点安全收敛。请稍候刷新,不要重复提交。";
|
|
187
|
+
}
|
|
188
|
+
if (/RUN_ALREADY_TERMINAL|运行已经结束/.test(text)) {
|
|
189
|
+
return "运行已经结束,没有可中止的活 runner。请改用报告或对账。";
|
|
190
|
+
}
|
|
191
|
+
if (/INTERRUPT_NEEDS_RECONCILE|无法确认 runner 已处理/.test(text)) {
|
|
192
|
+
return "中止请求已登记,但无法确认 runner 已处理。请用 Doctor 对账,不要当成已经中止。";
|
|
193
|
+
}
|
|
194
|
+
if (/RUN_NOT_ACTIVE/.test(text)) {
|
|
195
|
+
return "这次运行不在本机活动目录,或已经不是 running,不能协作中止。";
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|