@tea-agent/loop-agent 0.20.1-beta.0 → 0.21.0

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 (71) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/application/dag/args.js +29 -0
  3. package/dist/application/dag/run-dag.js +3 -1
  4. package/dist/cli/command-definitions.js +15 -1
  5. package/dist/cli/program.js +11 -1
  6. package/dist/commands/dag-rerun-task.js +19 -0
  7. package/dist/commands/dag-rerun.js +111 -0
  8. package/dist/commands/init.js +7 -0
  9. package/dist/executors/dag-pi-executor.js +24 -0
  10. package/dist/executors/pi-executor.js +111 -36
  11. package/dist/executors/pi-sdk-executor.js +105 -29
  12. package/dist/executors/shell-executor.js +54 -11
  13. package/dist/shared/operator/capabilities.js +54 -0
  14. package/dist/worker/console/index.js +1 -1
  15. package/dist/worker/console/inspect-split.js +82 -0
  16. package/dist/worker/console/operation-runner.js +3 -1
  17. package/dist/worker/console/operation-store.js +1 -0
  18. package/dist/worker/console/operator-actions.js +153 -2
  19. package/dist/worker/console/operator-user-error.js +10 -0
  20. package/dist/worker/console/pi-readiness.js +4 -0
  21. package/dist/worker/console/recovery-cta.js +116 -5
  22. package/dist/worker/console/recovery-selection.js +107 -0
  23. package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
  24. package/dist/worker/console/routes.js +20 -0
  25. package/dist/worker/console/sibling-controller.js +12 -7
  26. package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
  27. package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
  28. package/dist/worker/console/static/index.html +2 -2
  29. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  30. package/dist/worker/observability/read-model.js +67 -1
  31. package/dist/worker/observe/static/constants.js +5 -0
  32. package/dist/worker/observe/static/format-pool.js +22 -3
  33. package/dist/worker/observe/static/index.html +1 -1
  34. package/dist/worker/observe/static/styles.css +32 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  36. package/dist/worker/run-task/run-task.js +23 -6
  37. package/dist/workflows/dag/backend-test-markdown-workflow.js +291 -97
  38. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  39. package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
  40. package/dist/workflows/dag/init-hybrid.js +117 -64
  41. package/dist/workflows/dag/lifecycle.js +60 -4
  42. package/dist/workflows/dag/liveness-policy.js +250 -0
  43. package/dist/workflows/dag/node-execution.js +89 -6
  44. package/dist/workflows/dag/output-protocol.js +76 -0
  45. package/dist/workflows/dag/rerun-plan.js +611 -0
  46. package/dist/workflows/dag/rerun-run.js +497 -0
  47. package/dist/workflows/dag/rerun-task.js +284 -0
  48. package/dist/workflows/dag/retry-policy.js +20 -1
  49. package/dist/workflows/dag/runner.js +71 -1
  50. package/dist/workflows/dag/skill-snapshot.js +22 -3
  51. package/dist/workflows/dag/types.js +12 -0
  52. package/dist/workflows/dag/validate.js +11 -0
  53. package/dist/workflows/dag/workspace-checkpoint.js +163 -0
  54. package/docs/README.md +5 -5
  55. package/docs/architecture/dag-execution.md +11 -0
  56. package/docs/architecture/facts-and-state.md +1 -0
  57. package/docs/architecture/worker-and-feature.md +10 -0
  58. package/docs/templates/agent-dag.schema.json +17 -2
  59. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  60. package/docs/templates/backend-test-dag.json +15 -15
  61. package/docs/templates/frontend-test-case-checklist.md +16 -1
  62. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
  63. package/docs/templates/frontend-test-dag.json +65 -6
  64. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
  65. package/harness.json +1 -1
  66. package/package.json +1 -1
  67. package/skills/loop-agent/references/command-reference.md +5 -0
  68. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  69. package/skills/playwright-cli-case-generator/SKILL.md +35 -7
  70. package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
  71. package/dist/worker/console/static/assets/index-i1wV4LrY.css +0 -1
@@ -1,6 +1,6 @@
1
1
  import { appendFile, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
3
+ import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
4
4
  import { serializeSessionEvent } from './pi-event-serializer.js';
5
5
  let sdkSessionFactoryOverride;
6
6
  let sdkImportOverrideForTests;
@@ -169,6 +169,21 @@ export function shouldPersistSessionEvent(event) {
169
169
  }
170
170
  return true;
171
171
  }
172
+ function classifySdkActivityKind(event) {
173
+ if (!event || typeof event !== 'object')
174
+ return 'provider';
175
+ const type = typeof event.type === 'string'
176
+ ? String(event.type)
177
+ : '';
178
+ if (type === 'tool_start' || type === 'tool_end'
179
+ || type === 'tool_execution_start' || type === 'tool_execution_end') {
180
+ return 'tool';
181
+ }
182
+ if (type === 'thinking_delta' || type === 'message_update') {
183
+ return null;
184
+ }
185
+ return 'provider';
186
+ }
172
187
  function createSessionEventAppender(filePath, onSessionEvent) {
173
188
  let chain = Promise.resolve();
174
189
  let dirEnsured = false;
@@ -181,6 +196,9 @@ function createSessionEventAppender(filePath, onSessionEvent) {
181
196
  dirEnsured = true;
182
197
  }
183
198
  await appendFile(filePath, `${line}\n`, 'utf-8');
199
+ // Only after successful persistence: surface the persisted session event.
200
+ // Transport activity is reported synchronously by the subscription so
201
+ // filtered deltas and slow disk writes cannot trip the stall watchdog.
184
202
  onSessionEvent?.(line, event);
185
203
  }
186
204
  catch {
@@ -271,6 +289,8 @@ export async function executeSingleSdkAttempt(options) {
271
289
  ? `${modelConfig.provider}/${modelConfig.model}`
272
290
  : modelConfig.model ?? 'default';
273
291
  const timeoutMs = options.timeoutMs ?? modelConfig.timeoutMs ?? DEFAULT_TIMEOUT_MS;
292
+ const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
293
+ const abortGraceMs = options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
274
294
  const piSdkArgs = [
275
295
  '--provider', modelConfig.provider ?? '(default)',
276
296
  '--model', modelConfig.model ?? '(default)',
@@ -282,15 +302,46 @@ export async function executeSingleSdkAttempt(options) {
282
302
  ];
283
303
  const startedAt = Date.now();
284
304
  let timedOut = false;
305
+ let terminationConfirmed = true;
285
306
  let stderr = '';
286
307
  const stdoutPreview = new BoundedTextPreview('stdout');
287
308
  const stdoutCollector = createPiJsonlStreamCollector();
288
309
  const usageSamples = [];
289
310
  let session;
311
+ let unsubscribe;
290
312
  let timeoutHandle;
313
+ let stallHandle;
314
+ let disposeAttempted = false;
291
315
  const sessionEventAppender = options.sessionEventsPath
292
316
  ? createSessionEventAppender(options.sessionEventsPath, options.onSessionEvent)
293
317
  : undefined;
318
+ const appendStderr = (message) => {
319
+ stderr = stderr ? `${stderr}\n${message}` : message;
320
+ };
321
+ const runSessionActionWithGrace = async (label, action) => {
322
+ let graceHandle;
323
+ const outcome = await Promise.race([
324
+ Promise.resolve()
325
+ .then(action)
326
+ .then(() => ({ ok: true }))
327
+ .catch((error) => ({ ok: false, error })),
328
+ new Promise((resolve) => {
329
+ graceHandle = setTimeout(() => resolve({ ok: false, timedOut: true }), abortGraceMs);
330
+ }),
331
+ ]);
332
+ if (graceHandle)
333
+ clearTimeout(graceHandle);
334
+ if (outcome.ok)
335
+ return true;
336
+ if ('timedOut' in outcome) {
337
+ appendStderr(`pi SDK ${label} was not confirmed within ${abortGraceMs}ms`);
338
+ }
339
+ else {
340
+ const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
341
+ appendStderr(`pi SDK ${label} failed: ${message}`);
342
+ }
343
+ return false;
344
+ };
294
345
  try {
295
346
  const createSession = await resolveSdkSessionFactory(options.reuseScope);
296
347
  session = await createSession({
@@ -301,7 +352,32 @@ export async function executeSingleSdkAttempt(options) {
301
352
  model: modelConfig.model,
302
353
  thinking: modelConfig.thinking,
303
354
  });
304
- const unsubscribe = session.subscribe((event) => {
355
+ let resolveStall;
356
+ const stallPromise = stallTimeoutMs > 0
357
+ ? new Promise((resolve) => {
358
+ resolveStall = resolve;
359
+ })
360
+ : null;
361
+ const armStallWatchdog = () => {
362
+ if (!stallPromise || !resolveStall)
363
+ return;
364
+ if (stallHandle)
365
+ clearTimeout(stallHandle);
366
+ stallHandle = setTimeout(() => resolveStall?.('stall'), stallTimeoutMs);
367
+ };
368
+ unsubscribe = session.subscribe((event) => {
369
+ // Every real SDK event proves transport activity, including noisy deltas
370
+ // intentionally excluded from persisted JSONL and meaningful DAG progress.
371
+ armStallWatchdog();
372
+ const activityKind = classifySdkActivityKind(event);
373
+ if (activityKind) {
374
+ try {
375
+ options.onActivity?.({ kind: activityKind, at: new Date().toISOString() });
376
+ }
377
+ catch {
378
+ // best-effort: activity must never change Pi result
379
+ }
380
+ }
305
381
  const usageSample = extractSdkUsageSample(event);
306
382
  if (usageSample)
307
383
  usageSamples.push(usageSample);
@@ -317,44 +393,44 @@ export async function executeSingleSdkAttempt(options) {
317
393
  ? `${filePrefix}\n${options.userMessage}`
318
394
  : options.userMessage;
319
395
  const promptPromise = session.prompt(promptMessage);
396
+ armStallWatchdog();
320
397
  const timeoutPromise = timeoutMs > 0
321
398
  ? new Promise((resolve) => {
322
399
  timeoutHandle = setTimeout(() => {
323
- timedOut = true;
324
- void session?.abort();
325
- resolve('timeout');
400
+ resolve('absolute-timeout');
326
401
  }, timeoutMs);
327
402
  })
328
403
  : null;
329
- if (timeoutPromise) {
330
- const raced = await Promise.race([
331
- promptPromise.then(() => 'done'),
332
- timeoutPromise,
333
- ]);
334
- if (raced === 'timeout') {
335
- stderr = `pi SDK step timed out after ${timeoutMs}ms`;
336
- }
404
+ const raced = await Promise.race([
405
+ promptPromise.then(() => 'done'),
406
+ ...(timeoutPromise ? [timeoutPromise] : []),
407
+ ...(stallPromise ? [stallPromise] : []),
408
+ ]);
409
+ if (raced !== 'done') {
410
+ timedOut = true;
411
+ appendStderr(raced === 'stall'
412
+ ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
413
+ : `pi SDK step timed out after ${timeoutMs}ms`);
414
+ const abortConfirmed = await runSessionActionWithGrace('abort', () => session.abort());
415
+ disposeAttempted = true;
416
+ const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
417
+ terminationConfirmed = abortConfirmed && disposeConfirmed;
337
418
  }
338
- else {
339
- await promptPromise;
340
- }
341
- unsubscribe();
342
419
  }
343
420
  catch (error) {
344
421
  const message = error instanceof Error ? error.message : String(error);
345
- stderr = stderr ? `${stderr}\n${message}` : message;
422
+ appendStderr(message);
346
423
  }
347
424
  finally {
348
425
  if (timeoutHandle)
349
426
  clearTimeout(timeoutHandle);
350
- if (session) {
351
- try {
352
- await session.dispose();
353
- }
354
- catch (disposeError) {
355
- const message = disposeError instanceof Error ? disposeError.message : String(disposeError);
356
- stderr = stderr ? `${stderr}\n${message}` : message;
357
- }
427
+ if (stallHandle)
428
+ clearTimeout(stallHandle);
429
+ unsubscribe?.();
430
+ if (session && !disposeAttempted) {
431
+ disposeAttempted = true;
432
+ const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
433
+ terminationConfirmed = terminationConfirmed && disposeConfirmed;
358
434
  }
359
435
  if (sessionEventAppender) {
360
436
  try {
@@ -378,14 +454,14 @@ export async function executeSingleSdkAttempt(options) {
378
454
  ? collected.parsedEvents
379
455
  : fallbackParsed?.parsedEvents ?? 0;
380
456
  const tokensUsed = aggregateSdkTokenUsage(usageSamples);
381
- const failureCategory = classifyPiFailure({
457
+ const failureCategory = terminationConfirmed ? classifyPiFailure({
382
458
  assistantText,
383
459
  exitCode: timedOut ? 1 : stderr ? 1 : 0,
384
460
  outputTooLarge: collected.outputTooLarge,
385
461
  stderr,
386
462
  stdout,
387
463
  timedOut,
388
- });
464
+ }) : 'termination-unconfirmed';
389
465
  return {
390
466
  assistantText,
391
467
  backend: 'sdk',
@@ -394,7 +470,7 @@ export async function executeSingleSdkAttempt(options) {
394
470
  exitCode: timedOut || stderr ? 1 : 0,
395
471
  failureCategory,
396
472
  modelDisplay,
397
- ok: !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
473
+ ok: terminationConfirmed && !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
398
474
  parsedEvents,
399
475
  stderr,
400
476
  stdout,
@@ -17,7 +17,7 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
17
17
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
18
18
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
19
19
  import { materializeBackendTestResultFromRunDir, parseJunitXml } from "../workflows/dag/backend-test-result-contract.js";
20
- import { collectBackendTestHumanCaseCatalog, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
20
+ import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
21
21
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
22
22
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
23
23
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
@@ -304,6 +304,14 @@ async function runShellWriteGuard(input) {
304
304
  forbiddenPaths: input.task.forbiddenPaths,
305
305
  });
306
306
  }
307
+ async function readRequiredRunReport(reportsDir, filename) {
308
+ try {
309
+ return await readFile(path.join(reportsDir, filename), "utf8");
310
+ }
311
+ catch {
312
+ throw new Error(`missing required upstream report: reports/${filename}`);
313
+ }
314
+ }
307
315
  async function executeBackendTestPipeline(input, meta) {
308
316
  const pipeline = input.task.shell?.backendTestPipeline;
309
317
  const started = Date.now();
@@ -332,22 +340,54 @@ async function executeBackendTestPipeline(input, meta) {
332
340
  meta.spec.objective ?? "",
333
341
  ...(meta.spec.successCriteria ?? []),
334
342
  ]);
335
- const report = await validateBackendMarkdownCases({
336
- workspaceRoot: input.cwd,
337
- environmentMarkdown,
338
- requiredRequirementIds,
339
- sourceBinding: meta.spec.sourceBinding,
340
- });
343
+ let report;
344
+ try {
345
+ report = await validateBackendMarkdownCases({
346
+ workspaceRoot: input.cwd,
347
+ environmentMarkdown,
348
+ requiredRequirementIds,
349
+ sourceBinding: meta.spec.sourceBinding,
350
+ });
351
+ }
352
+ catch (error) {
353
+ report = `# Backend Markdown Case Validation\n\n## Status\n\nFAIL\n\n## Findings\n\n- Validator could not complete: ${error instanceof Error ? error.message : String(error)}\n`;
354
+ }
341
355
  const reportPath = await writeRunReport(meta.runDir, "backend-md-case-validation.md", report);
342
356
  outputs.push(`caseValidation=${reportPath}`, report);
357
+ if (hasBlockingBackendMarkdownSafetyFindings(report)) {
358
+ return {
359
+ ok: false,
360
+ stdout: outputs.join("\n"),
361
+ stderr: "backend Markdown safety gate blocked: secret-shaped values detected",
362
+ failureCategory: "invalid-output",
363
+ durationMs: Date.now() - started,
364
+ };
365
+ }
343
366
  }
344
367
  else if (pipeline === "markdown-traceability") {
345
- const report = await validateBackendMarkdownTraceability(input.cwd);
368
+ let report;
369
+ try {
370
+ report = await validateBackendMarkdownTraceability(input.cwd);
371
+ }
372
+ catch (error) {
373
+ report = `# Backend Test Traceability\n\n## Status\n\nFAIL\n\n## Findings\n\n- Traceability validation could not complete: ${error instanceof Error ? error.message : String(error)}\n`;
374
+ }
346
375
  const reportPath = await writeRunReport(meta.runDir, "backend-test-traceability.md", report);
347
376
  outputs.push(`traceability=${reportPath}`, report);
348
377
  }
349
378
  else if (pipeline === "markdown-execute-html") {
350
- const results = await executePipelineCommands(input, meta);
379
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
380
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
381
+ const pytestTargets = mappedScripts.map(shellQuote).join(" ");
382
+ const pytestCommand = [
383
+ 'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
384
+ `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest ${pytestTargets} -v -p no:cacheprovider -o junit_logging=all -o junit_log_passing_tests=true --junitxml="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml"`,
385
+ "STATUS=$?",
386
+ 'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
387
+ 'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml" ]; then exit 0; fi',
388
+ 'exit "${STATUS}"',
389
+ ].join("; ");
390
+ const results = await executePipelineCommands(input, meta, [pytestCommand]);
351
391
  if (!results.every((result) => result.ok)) {
352
392
  const failure = results.find((result) => !result.ok);
353
393
  return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
@@ -359,15 +399,18 @@ async function executeBackendTestPipeline(input, meta) {
359
399
  throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
360
400
  const parsed = parseJunitXml(junitContent);
361
401
  const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
402
+ const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
403
+ const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
362
404
  const htmlContent = renderBackendTestHtml({
363
405
  title: meta.spec.title,
364
406
  parsed,
365
407
  cases,
366
408
  environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
367
- traceabilitySummary: await readFile(path.join(reportsDir, "backend-test-traceability.md"), "utf8"),
409
+ caseValidationSummary,
410
+ traceabilitySummary,
368
411
  });
369
412
  const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
370
- const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent });
413
+ const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent, caseValidationSummary, traceabilitySummary });
371
414
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
372
415
  const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
373
416
  outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `facts=${factsPath}`, facts);
@@ -167,6 +167,16 @@ export function buildOperatorCapabilitiesDocument() {
167
167
  requiredErrorCodes: ["INVALID_INPUT", "OPERATION_NEEDS_RECONCILE"],
168
168
  description: "Repo doctor (registry entry; envelope migration follow-up).",
169
169
  },
170
+ {
171
+ action: "dagReport",
172
+ cli: "loop-agent dag report --run-id <runId> --json",
173
+ kind: "read",
174
+ inputSchemaVersion: 1,
175
+ resultSchemaVersion: 1,
176
+ envelopeSchemaVersion: 1,
177
+ requiredErrorCodes: ["NOT_FOUND", "INVALID_INPUT"],
178
+ description: "DAG run handoff report by run id.",
179
+ },
170
180
  {
171
181
  action: "status",
172
182
  cli: "loop-agent status <taskId> --json",
@@ -226,6 +236,50 @@ export function buildOperatorCapabilitiesDocument() {
226
236
  ],
227
237
  description: "Execute reviewed DAG; revalidate binding before writer start.",
228
238
  },
239
+ {
240
+ action: "dagRerunPlan",
241
+ cli: "loop-agent dag rerun --run-id <id> --from-node <node> --plan --json",
242
+ kind: "read",
243
+ inputSchemaVersion: 1,
244
+ resultSchemaVersion: 1,
245
+ envelopeSchemaVersion: 1,
246
+ requiredErrorCodes: ["INVALID_INPUT", "NOT_FOUND"],
247
+ description: "Read-only subgraph rerun plan for confirmation (no mutation).",
248
+ },
249
+ {
250
+ action: "dagRerun",
251
+ cli: "loop-agent dag rerun --run-id <id> --from-node <node> --plan-hash <hash> --request-id <id> --reason <text> --json",
252
+ kind: "long-running",
253
+ inputSchemaVersion: 1,
254
+ resultSchemaVersion: 1,
255
+ envelopeSchemaVersion: 1,
256
+ requiredErrorCodes: [
257
+ ...COMMON_MUTATION_ERRORS,
258
+ "BINDING_DRIFT",
259
+ "INVALID_INPUT",
260
+ ],
261
+ description: "Execute continuation run from effective node with confirmed plan hash.",
262
+ },
263
+ {
264
+ action: "standaloneTaskRerun",
265
+ cli: "loop-agent dag rerun-task --run-id <id> --reason <text> --request-id <id> [--profile auto] [--task-id <id>] --json",
266
+ kind: "long-running",
267
+ inputSchemaVersion: 1,
268
+ resultSchemaVersion: 1,
269
+ envelopeSchemaVersion: 1,
270
+ requiredErrorCodes: [...COMMON_MUTATION_ERRORS, "INVALID_INPUT"],
271
+ description: "Full standalone task regenerate → validate → execute with parent lineage.",
272
+ },
273
+ {
274
+ action: "workerTaskRetry",
275
+ cli: "agent-worker task retry <task-id> --feature-id <feature-id> --repo . --reason <text>",
276
+ kind: "mutation",
277
+ inputSchemaVersion: 1,
278
+ resultSchemaVersion: 1,
279
+ envelopeSchemaVersion: 1,
280
+ requiredErrorCodes: [...COMMON_MUTATION_ERRORS, "INVALID_INPUT"],
281
+ description: "Requeue failed Task Pool task (Failed → Ready) via in-package pool store.",
282
+ },
229
283
  ];
230
284
  return {
231
285
  schemaVersion: 1,
@@ -5,7 +5,7 @@ export { isLoopbackHost } from "./loopback.js";
5
5
  export { repoFingerprintV1, normalizeWorktreeRealpath, } from "./repo-fingerprint.js";
6
6
  export { buildObserveDeepLink } from "./observe-link.js";
7
7
  export { classifyObserveHealth, probeAndClassifyObserve, capabilityForObserveTarget, OBSERVE_START_COMMAND, DEFAULT_OBSERVE_BASE_URL, } from "./observe-health-match.js";
8
- export { allowedRecoveryCtas, classifyRecoveryFact, recoveryMatrixInvariants, } from "./recovery-cta.js";
8
+ export { allowedRecoveryCtas, classifyRecoveryFact, partitionRecoveryCtas, recoveryMatrixInvariants, } from "./recovery-cta.js";
9
9
  export { createClosedResourceLoader, createInterviewResourceLoader, tryActivateTool, assertOfficialToolAllowed, listOfficialTools, OFFICIAL_DENIED_TOOL_IDS, INTERVIEW_ALLOWED_TOOLS, INTERVIEW_DENIED_TOOLS, authorizeInterviewTool, } from "./resource-loader.js";
10
10
  export { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
11
11
  export { openConsoleAppData, defaultConsoleAppDataRoot } from "./app-data.js";
@@ -0,0 +1,82 @@
1
+ function asDisplayText(value) {
2
+ if (value == null)
3
+ return null;
4
+ if (typeof value === "string") {
5
+ const trimmed = value.trim();
6
+ return trimmed || null;
7
+ }
8
+ if (typeof value === "number" || typeof value === "boolean") {
9
+ return String(value);
10
+ }
11
+ if (typeof value === "object") {
12
+ const record = value;
13
+ if (typeof record.text === "string" && record.text.trim()) {
14
+ return record.text.trim();
15
+ }
16
+ if (typeof record.errorMessage === "string" && record.errorMessage.trim()) {
17
+ return record.errorMessage.trim();
18
+ }
19
+ if (Array.isArray(record.content)) {
20
+ const parts = record.content
21
+ .map((part) => {
22
+ if (typeof part === "string")
23
+ return part;
24
+ if (part && typeof part === "object") {
25
+ const item = part;
26
+ if (typeof item.text === "string")
27
+ return item.text;
28
+ }
29
+ return "";
30
+ })
31
+ .filter(Boolean);
32
+ if (parts.length)
33
+ return parts.join("\n").slice(0, 240);
34
+ }
35
+ if (typeof record.role === "string") {
36
+ return `${record.role} 消息`;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ export function formatInspectTimelineEvent(event) {
42
+ const time = asDisplayText(event.at) ??
43
+ asDisplayText(event.timestamp) ??
44
+ asDisplayText(event.recordedAt) ??
45
+ "";
46
+ const text = asDisplayText(event.errorMessage) ??
47
+ asDisplayText(event.label) ??
48
+ asDisplayText(event.type) ??
49
+ asDisplayText(event.message) ??
50
+ "运行事件";
51
+ return time ? `${time} · ${text}` : text;
52
+ }
53
+ export function dagNodeKey(node) {
54
+ return String(node.nodeId ?? node.id ?? "").trim();
55
+ }
56
+ export function dagNodeLabel(node) {
57
+ const key = dagNodeKey(node);
58
+ return String(node.label ?? node.name ?? (key || "未命名节点"));
59
+ }
60
+ export function pickInspectNode(nodes, preferredNodeId) {
61
+ if (nodes.length === 0)
62
+ return undefined;
63
+ const preferred = preferredNodeId?.trim();
64
+ if (preferred) {
65
+ const match = nodes.find((node) => dagNodeKey(node) === preferred);
66
+ if (match)
67
+ return match;
68
+ }
69
+ const failed = nodes.find((node) => {
70
+ const status = String(node.status ?? "").toLowerCase();
71
+ return (status.includes("fail") ||
72
+ status.includes("error") ||
73
+ status === "blocked");
74
+ });
75
+ if (failed)
76
+ return failed;
77
+ const running = nodes.find((node) => {
78
+ const status = String(node.status ?? "").toLowerCase();
79
+ return status === "running" || status === "in_progress" || status === "active";
80
+ });
81
+ return running ?? nodes[0];
82
+ }
@@ -30,7 +30,9 @@ export async function runOperation(operationId, deps) {
30
30
  });
31
31
  stateEvent(deps.events, op, "starting", "spawning sibling loop-agent");
32
32
  const run = deps.runCommand ??
33
- ((cliArgs, options) => deps.client.run(cliArgs, options));
33
+ ((cliArgs, options) => op.externalCommand
34
+ ? deps.client.runExternal(op.externalCommand, cliArgs, options)
35
+ : deps.client.run(cliArgs, options));
34
36
  let finished;
35
37
  try {
36
38
  const result = await run(args, {
@@ -55,6 +55,7 @@ export class OperationStore {
55
55
  createdAt: now,
56
56
  taskId: input.taskId,
57
57
  cliArgs: input.cliArgs,
58
+ ...(input.externalCommand ? { externalCommand: input.externalCommand } : {}),
58
59
  };
59
60
  await writeSecureJson(operationFile(this.appData, operationId), operation);
60
61
  await writeSecureJson(indexPath, { operationId, payloadHash });