@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.36.1] - 2026-08-16
6
+
7
+ ### 新增
8
+
9
+ - cooperative live DAG interrupt and recovery UX
10
+
11
+ ### 修复
12
+
13
+ - move interrupt eligibility to observability to honor kernel import boundary
14
+ - retry semantic intake across Pi tiers with 300s timeout
15
+
5
16
  ## [0.36.0] - 2026-08-15
6
17
 
7
18
  ### 新增
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.36.0",
4
- "gitSha": "4737885305b1a168317cfc97ae06596ae7a39512",
5
- "builtAt": "2026-08-15T20:30:11.790Z"
3
+ "version": "0.36.1",
4
+ "gitSha": "e023c82feae11ad8a6f5f9e50e8553cc38512f5e",
5
+ "builtAt": "2026-08-16T09:24:03.417Z"
6
6
  }
@@ -32,6 +32,7 @@ import { runDagApprove } from "../commands/dag-approve.js";
32
32
  import { runDagReject } from "../commands/dag-reject.js";
33
33
  import { runDagReconcileTasks } from "../commands/dag-reconcile-tasks.js";
34
34
  import { runDagReconcileRun } from "../commands/dag-reconcile-run.js";
35
+ import { runDagRequestInterrupt } from "../commands/dag-request-interrupt.js";
35
36
  import { runDagRerunTask } from "../commands/dag-rerun-task.js";
36
37
  import { runDagRerun } from "../commands/dag-rerun.js";
37
38
  import { runDagResume } from "../commands/dag-resume.js";
@@ -129,6 +130,7 @@ const DAG_SUBCOMMANDS = [
129
130
  "report",
130
131
  "closeout-draft",
131
132
  "reconcile-run",
133
+ "request-interrupt",
132
134
  "rerun",
133
135
  "rerun-task",
134
136
  "reconcile-tasks",
@@ -549,6 +551,11 @@ export const COMMAND_DEFINITIONS = [
549
551
  await runDagReconcileRun(repoRoot, reconcileRunArgs);
550
552
  return;
551
553
  }
554
+ if (subcommand === "request-interrupt") {
555
+ const interruptArgs = rest.filter((arg) => Boolean(arg));
556
+ await runDagRequestInterrupt(repoRoot, interruptArgs);
557
+ return;
558
+ }
552
559
  if (subcommand === "rerun") {
553
560
  const rerunArgs = rest.filter((arg) => Boolean(arg));
554
561
  await runDagRerun(repoRoot, rerunArgs);
@@ -11,6 +11,7 @@ import { runDagFinalVerification } from "../commands/dag-final-verification.js";
11
11
  import { runDagInitHybrid } from "../commands/dag-init-hybrid.js";
12
12
  import { runDagReconcileTasks } from "../commands/dag-reconcile-tasks.js";
13
13
  import { runDagReconcileRun } from "../commands/dag-reconcile-run.js";
14
+ import { runDagRequestInterrupt } from "../commands/dag-request-interrupt.js";
14
15
  import { runDagRerunTask } from "../commands/dag-rerun-task.js";
15
16
  import { runDagRerun } from "../commands/dag-rerun.js";
16
17
  import { runDagReject } from "../commands/dag-reject.js";
@@ -331,6 +332,9 @@ async function runDagAction(repoRoot, subcommand, rest) {
331
332
  case "reconcile-run":
332
333
  await runDagReconcileRun(repoRoot, args);
333
334
  return;
335
+ case "request-interrupt":
336
+ await runDagRequestInterrupt(repoRoot, args);
337
+ return;
334
338
  case "rerun":
335
339
  await runDagRerun(repoRoot, args);
336
340
  return;
@@ -356,7 +360,7 @@ async function runDagAction(repoRoot, subcommand, rest) {
356
360
  throw new Error("usage: dag decision <inspect|validate> --run-id <id> [--node-id <node-id>]");
357
361
  }
358
362
  default:
359
- throw new Error("usage: dag <init-hybrid|execute|validate|workflow-plan|workflow-validate|workflow-compile|approve|reject|resume|status|doctor|report|closeout-draft|reconcile-run|rerun|rerun-task|reconcile-tasks|final-verification|decision> ...");
363
+ throw new Error("usage: dag <init-hybrid|execute|validate|workflow-plan|workflow-validate|workflow-compile|approve|reject|resume|status|doctor|report|closeout-draft|reconcile-run|request-interrupt|rerun|rerun-task|reconcile-tasks|final-verification|decision> ...");
360
364
  }
361
365
  }
362
366
  function compactArgs(args) {
@@ -493,6 +497,7 @@ function addDagCommand(program, defaultRepoRoot) {
493
497
  "report",
494
498
  "closeout-draft",
495
499
  "reconcile-run",
500
+ "request-interrupt",
496
501
  "rerun",
497
502
  "rerun-task",
498
503
  "reconcile-tasks",
@@ -0,0 +1,20 @@
1
+ import { DagInterruptError, executeDagRequestInterrupt, parseDagRequestInterruptArgs, } from "../workflows/dag/interrupt-request.js";
2
+ export { parseDagRequestInterruptArgs, executeDagRequestInterrupt };
3
+ export async function runDagRequestInterrupt(repoRoot, rawArgs) {
4
+ try {
5
+ const result = await executeDagRequestInterrupt(repoRoot, rawArgs);
6
+ console.log(JSON.stringify(result, null, 2));
7
+ }
8
+ catch (error) {
9
+ if (error instanceof DagInterruptError) {
10
+ console.log(JSON.stringify({
11
+ ok: false,
12
+ code: error.code,
13
+ message: error.message,
14
+ }, null, 2));
15
+ process.exitCode = 1;
16
+ return;
17
+ }
18
+ throw error;
19
+ }
20
+ }
@@ -78,6 +78,40 @@ export async function checkPiSdkAvailability(_repoRoot) {
78
78
  return { ok: false, detail: `pi SDK not available: ${message}` };
79
79
  }
80
80
  }
81
+ /**
82
+ * Best-effort Pi SDK model catalog (credentialed providers first).
83
+ * Empty on SDK/auth/runtime failure — callers must fall back to harness tiers.
84
+ */
85
+ export async function listPiSdkAvailableModels() {
86
+ try {
87
+ const imported = sdkImportOverrideForTests
88
+ ? await sdkImportOverrideForTests()
89
+ : await loadPiSdkModule();
90
+ const sdk = imported;
91
+ const getAgentDir = sdk.getAgentDir;
92
+ const ModelRuntime = sdk.ModelRuntime;
93
+ if (typeof getAgentDir !== "function" || typeof ModelRuntime?.create !== "function") {
94
+ return [];
95
+ }
96
+ const agentDir = getAgentDir();
97
+ const runtime = await ModelRuntime.create({
98
+ authPath: path.join(agentDir, "auth.json"),
99
+ modelsPath: path.join(agentDir, "models.json"),
100
+ });
101
+ const snapshot = runtime.snapshot ?? {};
102
+ const configured = snapshot.configuredProviders;
103
+ const isConfigured = (provider) => configured instanceof Set ? configured.has(provider) : false;
104
+ return (snapshot.available ?? []).map((entry) => ({
105
+ provider: entry.provider,
106
+ id: entry.id,
107
+ name: entry.name,
108
+ hasCredentials: isConfigured(entry.provider),
109
+ }));
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ }
81
115
  /** Fail-closed probe for the SDK-only structured custom-tool surface. */
82
116
  export async function checkPiSdkCustomToolCapability(_repoRoot) {
83
117
  if (sdkSessionFactoryOverride) {
@@ -1102,7 +1102,7 @@ export function buildOperatorCapabilitiesDocument() {
1102
1102
  resultSchemaVersion: 1,
1103
1103
  envelopeSchemaVersion: 1,
1104
1104
  requiredErrorCodes: ["INVALID_INPUT", "NOT_FOUND"],
1105
- description: "Read-only R1 subgraph plan (no mutation). Call before dagRerun. Prefer primaryFailure.nodeId from dagReport. If eligible=false (writer/decision/fingerprint, or subgraph contains unsafe shell nodes), do not force execute — fall back to standaloneTaskRerun or same-task advance after fixing the root cause (contract changes must be adopted first). Returns planHash required by execute.",
1105
+ description: "Read-only R1 subgraph plan (no mutation). Call before dagRerun. Prefer primaryFailure.nodeId from dagReport. If eligible=false (writer/decision/fingerprint, unsafe shell, worker-managed, workspace/controller drift), do not force execute — Worker-owned runs use workerTaskRetry; standalone runs use standaloneTaskRerun or same-task advance after fixing the root cause (contract changes must be adopted first). Returns planHash required by execute.",
1106
1106
  inputParams: [
1107
1107
  {
1108
1108
  name: "runId",
@@ -1165,6 +1165,73 @@ export function buildOperatorCapabilitiesDocument() {
1165
1165
  modelCallable: "always",
1166
1166
  humanConfirmation: "none",
1167
1167
  },
1168
+ {
1169
+ action: "dagInterruptEligibility",
1170
+ cli: "console dag-interrupt-eligibility",
1171
+ kind: "read",
1172
+ inputSchemaVersion: 1,
1173
+ resultSchemaVersion: 1,
1174
+ envelopeSchemaVersion: 1,
1175
+ requiredErrorCodes: ["INVALID_INPUT", "NOT_FOUND"],
1176
+ description: "Read-only eligibility for cooperative live-DAG interrupt. v1 only Console-owned active running operations. Worker-managed / unknown owner / orphaned / terminal runs return eligible=false with stable reasonCodes. Does not create a Human Gate receipt or mutate the run.",
1177
+ inputParams: [
1178
+ {
1179
+ name: "runId",
1180
+ type: "string",
1181
+ required: true,
1182
+ description: "active dag run id to inspect for interrupt eligibility",
1183
+ },
1184
+ ],
1185
+ modelCallable: "always",
1186
+ humanConfirmation: "none",
1187
+ },
1188
+ {
1189
+ action: "dagRequestInterrupt",
1190
+ cli: "loop-agent dag request-interrupt --run-id <id> --request-id <id> --target-operation-id <id> --reason-code <code> --reason <detail> --json",
1191
+ kind: "mutation",
1192
+ inputSchemaVersion: 1,
1193
+ resultSchemaVersion: 1,
1194
+ envelopeSchemaVersion: 1,
1195
+ requiredErrorCodes: [
1196
+ ...COMMON_MUTATION_ERRORS,
1197
+ "HUMAN_CONFIRMATION_REQUIRED",
1198
+ "RUN_NOT_ACTIVE",
1199
+ "RUN_NOT_CONSOLE_OWNED",
1200
+ "RUNNER_IDENTITY_MISMATCH",
1201
+ "INTERRUPT_ALREADY_PENDING",
1202
+ "RUN_ALREADY_TERMINAL",
1203
+ "INTERRUPT_NEEDS_RECONCILE",
1204
+ ],
1205
+ description: "Request cooperative abort of a Console-owned running DAG. Success means interrupt.json is requested, not that the run has already stopped. Browser Human Gate only; model must not call this action.",
1206
+ inputParams: [
1207
+ {
1208
+ name: "runId",
1209
+ type: "string",
1210
+ required: true,
1211
+ description: "active dag run id",
1212
+ },
1213
+ {
1214
+ name: "reasonCode",
1215
+ type: "string",
1216
+ required: true,
1217
+ description: "requirements-changed | suspected-runaway | resource-protection | operator-request | other",
1218
+ },
1219
+ {
1220
+ name: "reasonDetail",
1221
+ type: "string",
1222
+ required: true,
1223
+ description: "10–500 character operator reason; do not auto-fill placeholders",
1224
+ },
1225
+ {
1226
+ name: "confirmationId",
1227
+ type: "string",
1228
+ required: true,
1229
+ description: "human confirmation receipt id from prepareMutationGate",
1230
+ },
1231
+ ],
1232
+ modelCallable: "prepare-only",
1233
+ humanConfirmation: "required",
1234
+ },
1168
1235
  {
1169
1236
  action: "prepareMutationGate",
1170
1237
  cli: "console prepare-mutation-gate",
@@ -1173,13 +1240,13 @@ export function buildOperatorCapabilitiesDocument() {
1173
1240
  resultSchemaVersion: 1,
1174
1241
  envelopeSchemaVersion: 1,
1175
1242
  requiredErrorCodes: ["INVALID_INPUT", "NOT_FOUND"],
1176
- description: "Prepare a one-shot Human Gate receipt for Night Scheduler mutations. Model may prepare; only the browser mutation gate may consume.",
1243
+ description: "Prepare a one-shot Human Gate receipt for destructive reconcile, live DAG interrupt, and Night Scheduler mutations. Model may prepare; only the browser mutation gate may consume. Do not use for dagRerun / standaloneTaskRerun / workerTaskRetry.",
1177
1244
  inputParams: [
1178
1245
  {
1179
1246
  name: "action",
1180
1247
  type: "string",
1181
1248
  required: true,
1182
- description: "target action: workerAdmissionPrepare | workerSchedulerAdd | workerSchedulerCancel | workerSchedulerHarvest | workerSchedulerDiscard",
1249
+ description: "target action: dagReconcileRun | dagRequestInterrupt | workerAdmissionPrepare | workerSchedulerAdd | workerSchedulerCancel | workerSchedulerHarvest | workerSchedulerDiscard. Not for standaloneTaskRerun / workerTaskRetry / dagRerun (those are autonomous).",
1183
1250
  },
1184
1251
  {
1185
1252
  name: "actionParams",
@@ -1992,6 +2059,12 @@ export const OPERATOR_COMMAND_COVERAGE = Object.freeze([
1992
2059
  action: "dagReconcileRun",
1993
2060
  source: "loop-agent",
1994
2061
  },
2062
+ {
2063
+ command: "loop-agent dag request-interrupt",
2064
+ coverage: "human-gated-required",
2065
+ action: "dagRequestInterrupt",
2066
+ source: "loop-agent",
2067
+ },
1995
2068
  {
1996
2069
  command: "loop-agent dag rerun",
1997
2070
  coverage: "model-callable",
@@ -13,6 +13,7 @@ import { z } from "zod";
13
13
  import { executePiStep } from "../../executors/pi-executor.js";
14
14
  import { resolveDagPiModelConfig } from "../../executors/dag-pi-executor.js";
15
15
  import { resolveExecutorModelMatrices } from "../../executors/model-routing.js";
16
+ import { listPiSdkAvailableModels } from "../../executors/pi-sdk-executor.js";
16
17
  import { loadHarnessManifest } from "../../governance/harness.js";
17
18
  import { TASK_CONTRACT_DRAFT_SCHEMA_VERSION } from "../contract/constants.js";
18
19
  import { getTaskPaths } from "../runtime.js";
@@ -20,6 +21,14 @@ import { detectProductArtifactMeta } from "./artifact-meta.js";
20
21
  import { filterPlaceholderPaths } from "./placeholder-paths.js";
21
22
  export const SEMANTIC_INTAKE_ARTIFACT_REL = "artifacts/intake/semantic-draft.json";
22
23
  export const SEMANTIC_INTAKE_ATTEMPT_MARKER = "artifacts/intake/semantic-intake-attempted.json";
24
+ /** Per-model wall clock for one semantic-intake Pi attempt. */
25
+ export const SEMANTIC_INTAKE_ATTEMPT_TIMEOUT_MS = 300_000;
26
+ /** Extra Pi-catalog models after MED → HIGH → LOW. */
27
+ export const SEMANTIC_INTAKE_MAX_EXTRA_MODELS = 5;
28
+ /** Parent CLI budget covering harness tiers plus extra catalog models. */
29
+ export const SEMANTIC_INTAKE_CLI_TIMEOUT_MS = SEMANTIC_INTAKE_ATTEMPT_TIMEOUT_MS *
30
+ (3 + SEMANTIC_INTAKE_MAX_EXTRA_MODELS);
31
+ const NON_CHAT_MODEL = /embed|whisper|tts|dall-e|dalle|image|rerank|moderation/i;
23
32
  const productArtifactBlockers = new Set([
24
33
  "PRODUCT_ANALYSIS_NOT_EXECUTABLE",
25
34
  "PRODUCT_CLARIFICATION_NOT_EXECUTABLE",
@@ -126,6 +135,58 @@ export function evaluateSemanticIntakeEligibility(input) {
126
135
  reason: "structural or engineering-boundary gaps with imported sources",
127
136
  };
128
137
  }
138
+ function intakeModelKey(ref) {
139
+ return ref.trim().replace(/\\/g, "/").toLowerCase();
140
+ }
141
+ function intakeModelRef(provider, id) {
142
+ const trimmedId = id.trim();
143
+ if (trimmedId.includes("/"))
144
+ return trimmedId.replace(/\\/g, "/");
145
+ return `${provider.trim()}/${trimmedId}`;
146
+ }
147
+ /**
148
+ * MED → HIGH → LOW from harness, then Pi-supported catalog models
149
+ * (credentialed first). Duplicates and non-chat ids are skipped.
150
+ */
151
+ export function buildSemanticIntakeModelQueue(input) {
152
+ const seen = new Set();
153
+ const queue = [];
154
+ const push = (raw) => {
155
+ const ref = raw?.trim().replace(/\\/g, "/");
156
+ if (!ref)
157
+ return;
158
+ const key = intakeModelKey(ref);
159
+ if (seen.has(key))
160
+ return;
161
+ seen.add(key);
162
+ queue.push(ref);
163
+ };
164
+ push(input.harness.MED);
165
+ push(input.harness.HIGH);
166
+ push(input.harness.LOW);
167
+ const extras = [...(input.available ?? [])]
168
+ .filter((entry) => {
169
+ const hay = `${entry.id} ${entry.name ?? ""} ${entry.provider}`;
170
+ return !NON_CHAT_MODEL.test(hay);
171
+ })
172
+ .sort((left, right) => {
173
+ const leftCred = left.hasCredentials === true ? 0 : 1;
174
+ const rightCred = right.hasCredentials === true ? 0 : 1;
175
+ if (leftCred !== rightCred)
176
+ return leftCred - rightCred;
177
+ return intakeModelRef(left.provider, left.id).localeCompare(intakeModelRef(right.provider, right.id));
178
+ });
179
+ let extraCount = 0;
180
+ for (const entry of extras) {
181
+ if (extraCount >= SEMANTIC_INTAKE_MAX_EXTRA_MODELS)
182
+ break;
183
+ const before = queue.length;
184
+ push(intakeModelRef(entry.provider, entry.id));
185
+ if (queue.length > before)
186
+ extraCount += 1;
187
+ }
188
+ return queue;
189
+ }
129
190
  export function extractJsonObject(text) {
130
191
  const fenced = /```(?:json)?\s*([\s\S]*?)```/iu.exec(text);
131
192
  const candidate = fenced?.[1]?.trim() || text.trim();
@@ -493,6 +554,7 @@ export async function runSemanticIntake(input) {
493
554
  };
494
555
  }
495
556
  let assistantText;
557
+ let attemptedModels = [];
496
558
  if (input.fixtureAssistantText !== undefined) {
497
559
  assistantText = input.fixtureAssistantText;
498
560
  }
@@ -500,7 +562,33 @@ export async function runSemanticIntake(input) {
500
562
  const execute = input.executePi ?? executePiStep;
501
563
  const manifest = await loadHarnessManifest(input.repoRoot);
502
564
  const models = resolveExecutorModelMatrices(manifest);
503
- const model = models.pi.MED;
565
+ let available = [];
566
+ try {
567
+ available = input.listAvailableModels
568
+ ? await input.listAvailableModels()
569
+ : await listPiSdkAvailableModels();
570
+ }
571
+ catch {
572
+ available = [];
573
+ }
574
+ const queue = buildSemanticIntakeModelQueue({
575
+ harness: models.pi,
576
+ available,
577
+ });
578
+ if (queue.length === 0) {
579
+ await writeAttemptMarker(input.repoRoot, input.taskId, {
580
+ ok: false,
581
+ code: "SEMANTIC_INTAKE_PI_FAILED",
582
+ failureCategory: "unavailable",
583
+ at: new Date().toISOString(),
584
+ attemptedModels: [],
585
+ });
586
+ return {
587
+ ok: false,
588
+ code: "SEMANTIC_INTAKE_PI_FAILED",
589
+ message: "semantic intake has no MED/HIGH/LOW or Pi-supported models to try",
590
+ };
591
+ }
504
592
  const attached = usableDocs
505
593
  .map((d) => d.absolutePath)
506
594
  .filter((p) => Boolean(p));
@@ -510,39 +598,71 @@ export async function runSemanticIntake(input) {
510
598
  documents: usableDocs,
511
599
  repoRoot: input.repoRoot,
512
600
  });
513
- const result = await execute({
514
- attachedFiles: attached,
515
- modelConfig: resolveDagPiModelConfig(model),
516
- prompt,
517
- repoRoot: input.repoRoot,
518
- step: "analyze",
519
- toolNames: ["read", "grep", "find", "ls"],
520
- userMessage: "Structure the imported requirement documents into the required JSON only.",
521
- validateOutput: (text) => {
522
- try {
523
- const parsed = parseSemanticRequirementDraft(text);
524
- assertSemanticDraftAcceptable(parsed);
525
- return [];
526
- }
527
- catch (error) {
528
- return [error instanceof Error ? error.message : String(error)];
601
+ const attempted = [];
602
+ let lastFailure = "";
603
+ let chosenText;
604
+ for (const modelRef of queue) {
605
+ try {
606
+ const result = await execute({
607
+ attachedFiles: attached,
608
+ modelConfig: resolveDagPiModelConfig(modelRef),
609
+ prompt,
610
+ repoRoot: input.repoRoot,
611
+ step: "analyze",
612
+ toolNames: ["read", "grep", "find", "ls"],
613
+ timeoutMs: SEMANTIC_INTAKE_ATTEMPT_TIMEOUT_MS,
614
+ stallTimeoutMs: SEMANTIC_INTAKE_ATTEMPT_TIMEOUT_MS,
615
+ userMessage: "Structure the imported requirement documents into the required JSON only.",
616
+ validateOutput: (text) => {
617
+ try {
618
+ const parsed = parseSemanticRequirementDraft(text);
619
+ assertSemanticDraftAcceptable(parsed);
620
+ return [];
621
+ }
622
+ catch (error) {
623
+ return [error instanceof Error ? error.message : String(error)];
624
+ }
625
+ },
626
+ });
627
+ attempted.push({
628
+ model: result.modelDisplay || modelRef,
629
+ ok: result.ok,
630
+ failureCategory: result.failureCategory,
631
+ timedOut: result.timedOut,
632
+ });
633
+ if (result.ok) {
634
+ chosenText = result.assistantText;
635
+ break;
529
636
  }
530
- },
531
- });
532
- if (!result.ok) {
637
+ lastFailure =
638
+ `${result.failureCategory}: ${result.stderr || result.assistantText}`.slice(0, 2000);
639
+ }
640
+ catch (error) {
641
+ const message = error instanceof Error ? error.message : String(error);
642
+ attempted.push({
643
+ model: modelRef,
644
+ ok: false,
645
+ failureCategory: "unknown",
646
+ });
647
+ lastFailure = message.slice(0, 2000);
648
+ }
649
+ }
650
+ attemptedModels = attempted;
651
+ if (chosenText === undefined) {
533
652
  await writeAttemptMarker(input.repoRoot, input.taskId, {
534
653
  ok: false,
535
654
  code: "SEMANTIC_INTAKE_PI_FAILED",
536
- failureCategory: result.failureCategory,
655
+ failureCategory: attempted.at(-1)?.failureCategory,
537
656
  at: new Date().toISOString(),
657
+ attemptedModels: attempted,
538
658
  });
539
659
  return {
540
660
  ok: false,
541
661
  code: "SEMANTIC_INTAKE_PI_FAILED",
542
- message: `semantic intake pi failed: ${result.failureCategory}: ${result.stderr || result.assistantText}`.slice(0, 2000),
662
+ message: `semantic intake pi failed after ${attempted.length} model(s): ${lastFailure}`.slice(0, 2000),
543
663
  };
544
664
  }
545
- assistantText = result.assistantText;
665
+ assistantText = chosenText;
546
666
  }
547
667
  try {
548
668
  const semantic = parseSemanticRequirementDraft(assistantText);
@@ -576,6 +696,7 @@ export async function runSemanticIntake(input) {
576
696
  code: "SEMANTIC_INTAKE_OK",
577
697
  artifactPath: SEMANTIC_INTAKE_ARTIFACT_REL,
578
698
  at: new Date().toISOString(),
699
+ ...(attemptedModels.length > 0 ? { attemptedModels } : {}),
579
700
  });
580
701
  return { ok: true, draft, artifactPath, semantic };
581
702
  }
@@ -39,6 +39,7 @@ const OPERATION_TITLES = {
39
39
  dagDoctor: "执行诊断",
40
40
  dagReport: "执行结果",
41
41
  dagRerunPlan: "恢复评估",
42
+ dagInterruptEligibility: "中止资格",
42
43
  standaloneTaskRerun: "恢复执行",
43
44
  workerTaskRetry: "重试任务",
44
45
  promoteRun: "任务推进",
@@ -90,6 +91,11 @@ const TOOL_SEMANTICS = {
90
91
  success: "恢复条件评估已经完成",
91
92
  error: "暂时无法确定安全的恢复方式",
92
93
  },
94
+ dagInterruptEligibility: {
95
+ running: "正在检查能否协作中止此运行",
96
+ success: "中止资格已经确认",
97
+ error: "当前运行不能协作中止",
98
+ },
93
99
  standaloneTaskRerun: {
94
100
  running: "正在从失败位置恢复执行",
95
101
  success: "已经从失败位置继续执行",
@@ -27,6 +27,7 @@ function readWorkspaceParams(query) {
27
27
  ...(read("dagRunId") ? { dagRunId: read("dagRunId") } : {}),
28
28
  ...(read("fromNodeId") ? { fromNodeId: read("fromNodeId") } : {}),
29
29
  ...(read("featureId") ? { featureId: read("featureId") } : {}),
30
+ ...(read("intent") ? { intent: read("intent") } : {}),
30
31
  };
31
32
  }
32
33
  /** Parse `#/tasks?taskId=…` hash routes (Observe-style virtual paths). */
@@ -241,6 +241,9 @@ async function finalizeFromWorkerResult(operationId, result, deps) {
241
241
  });
242
242
  stateEvent(deps.events, updated, state, updated.errorMessage);
243
243
  appendSafeResultEvent(deps, operationId, state, { ok, exitCode: result.exitCode }, runFacts);
244
+ if (ok && operation?.action === "dagRequestInterrupt") {
245
+ await projectInterruptRequestOntoTarget(operation, result.json, deps);
246
+ }
244
247
  return updated;
245
248
  }
246
249
  /** Result event carries the same safe summary persisted on the record. */
@@ -303,3 +306,48 @@ export function scheduleOperation(operationId, deps) {
303
306
  });
304
307
  });
305
308
  }
309
+ async function projectInterruptRequestOntoTarget(operation, json, deps) {
310
+ const params = operation.actionParams ?? {};
311
+ const targetOperationId = typeof params.targetOperationId === "string"
312
+ ? params.targetOperationId.trim()
313
+ : "";
314
+ const runId = typeof params.runId === "string" ? params.runId.trim() : "";
315
+ if (!targetOperationId)
316
+ return;
317
+ const rec = json && typeof json === "object" ? json : {};
318
+ const request = rec.request && typeof rec.request === "object"
319
+ ? rec.request
320
+ : rec;
321
+ const status = (typeof rec.interruptStatus === "string" && rec.interruptStatus) ||
322
+ (typeof request.status === "string" && request.status) ||
323
+ "requested";
324
+ if (status !== "requested" &&
325
+ status !== "acknowledged" &&
326
+ status !== "settled" &&
327
+ status !== "needs-reconcile") {
328
+ return;
329
+ }
330
+ const target = await deps.store.get(targetOperationId);
331
+ if (!target)
332
+ return;
333
+ await deps.store.update(targetOperationId, {
334
+ ...(runId && !target.dagRunId ? { dagRunId: runId } : {}),
335
+ interrupt: {
336
+ status,
337
+ ...(typeof request.requestId === "string"
338
+ ? { requestId: request.requestId }
339
+ : {}),
340
+ ...(typeof request.reasonCode === "string"
341
+ ? { reasonCode: request.reasonCode }
342
+ : {}),
343
+ ...(typeof request.requestedAt === "string"
344
+ ? { requestedAt: request.requestedAt }
345
+ : {}),
346
+ },
347
+ });
348
+ deps.events.append(targetOperationId, {
349
+ kind: "state",
350
+ message: `interrupt ${status}`,
351
+ data: { interruptStatus: status, ...(runId ? { runId } : {}) },
352
+ });
353
+ }
@@ -158,6 +158,7 @@ export async function waitForOperationChange(input) {
158
158
  return new Promise((resolve, reject) => {
159
159
  let settled = false;
160
160
  let timer;
161
+ let pollTimer;
161
162
  let unsubscribe;
162
163
  /** Highest seq observed via listener/listFrom during this wait. */
163
164
  let observedSeq = afterSeq;
@@ -166,6 +167,10 @@ export async function waitForOperationChange(input) {
166
167
  clearTimeout(timer);
167
168
  timer = undefined;
168
169
  }
170
+ if (pollTimer !== undefined) {
171
+ clearInterval(pollTimer);
172
+ pollTimer = undefined;
173
+ }
169
174
  if (unsubscribe) {
170
175
  unsubscribe();
171
176
  unsubscribe = undefined;
@@ -303,6 +308,42 @@ export async function waitForOperationChange(input) {
303
308
  })();
304
309
  }, maxWaitMs);
305
310
  timer.unref?.();
311
+ if (input.poll) {
312
+ pollTimer = setInterval(() => {
313
+ void (async () => {
314
+ if (settled)
315
+ return;
316
+ try {
317
+ const changed = await input.poll?.();
318
+ if (!changed || settled)
319
+ return;
320
+ const relisted = events.listFrom(operationId, afterSeq);
321
+ if ("error" in relisted)
322
+ return;
323
+ const nextWake = wakeFilter(relisted.events);
324
+ observedSeq = Math.max(observedSeq, relisted.events.length > 0
325
+ ? relisted.events[relisted.events.length - 1].seq
326
+ : afterSeq);
327
+ if (nextWake.length === 0)
328
+ return;
329
+ const latest = await input.operations.get(operationId);
330
+ finish(settledSummary({
331
+ operationId,
332
+ operation: latest ?? operation,
333
+ afterSeq,
334
+ changed: true,
335
+ timedOut: false,
336
+ newEvents: nextWake,
337
+ observedSeq,
338
+ }));
339
+ }
340
+ catch {
341
+ // Poll is best-effort; the wait still times out.
342
+ }
343
+ })();
344
+ }, 1_000);
345
+ pollTimer.unref?.();
346
+ }
306
347
  }
307
348
  catch (error) {
308
349
  finish(error instanceof OperationWaitError