@tea-agent/loop-agent 0.27.1-beta.2 → 0.28.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 (48) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/dist/application/task-lifecycle/observe.js +5 -0
  3. package/dist/application/task-lifecycle/plan-transitions.js +7 -2
  4. package/dist/cli/program.js +1 -1
  5. package/dist/commands/client-recovery.js +439 -20
  6. package/dist/commands/init.js +42 -6
  7. package/dist/executors/dag-pi-executor.js +165 -56
  8. package/dist/executors/pi-playwright-cli-tool.js +955 -0
  9. package/dist/executors/pi-sdk-executor.js +56 -0
  10. package/dist/executors/playwright-cli-launcher.js +63 -0
  11. package/dist/executors/shell-executor.js +128 -0
  12. package/dist/shared/playwright-cli-command-policy.js +41 -0
  13. package/dist/worker/observability/read-model.js +66 -8
  14. package/dist/worker/observe/static/dag-model.js +85 -13
  15. package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
  16. package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
  17. package/dist/workflows/dag/frontend-implementation-contract.js +6 -124
  18. package/dist/workflows/dag/frontend-prewrite-gate.js +5 -35
  19. package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
  20. package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
  21. package/dist/workflows/dag/init-hybrid.js +154 -95
  22. package/dist/workflows/dag/lifecycle.js +33 -2
  23. package/dist/workflows/dag/node-execution.js +11 -5
  24. package/dist/workflows/dag/output-protocol.js +25 -106
  25. package/dist/workflows/dag/report.js +9 -2
  26. package/dist/workflows/dag/rerun-run.js +62 -3
  27. package/dist/workflows/dag/run-store.js +6 -1
  28. package/dist/workflows/dag/runner.js +15 -3
  29. package/dist/workflows/dag/types.js +27 -0
  30. package/dist/workflows/dag/validate.js +121 -1
  31. package/docs/architecture/runtime-boundaries.md +13 -11
  32. package/docs/init-surface.manifest.json +6 -2
  33. package/docs/templates/README.md +9 -1
  34. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  35. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
  36. package/docs/templates/frontend-test-dag.json +55 -15
  37. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
  38. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  39. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
  41. package/harness.json +4 -4
  42. package/package.json +1 -1
  43. package/skills/loop-agent/SKILL.md +1 -1
  44. package/skills/loop-agent/references/command-reference.md +18 -6
  45. package/skills/playwright-cli/SKILL.md +69 -402
  46. package/skills/playwright-cli/references/tracing.md +3 -137
  47. package/skills/playwright-cli/references/video-recording.md +3 -141
  48. package/skills/playwright-cli-case-generator/SKILL.md +53 -46
@@ -77,6 +77,44 @@ export async function checkPiSdkAvailability(_repoRoot) {
77
77
  return { ok: false, detail: `pi SDK not available: ${message}` };
78
78
  }
79
79
  }
80
+ /** Fail-closed probe for the SDK-only structured custom-tool surface. */
81
+ export async function checkPiSdkCustomToolCapability(_repoRoot) {
82
+ if (sdkSessionFactoryOverride) {
83
+ return {
84
+ ok: true,
85
+ detail: "pi SDK custom-tool session factory override active",
86
+ };
87
+ }
88
+ try {
89
+ const imported = sdkImportOverrideForTests
90
+ ? await sdkImportOverrideForTests()
91
+ : await loadPiSdkModule();
92
+ const sdk = imported;
93
+ const { Type } = await import("typebox");
94
+ const ModelRuntime = sdk.ModelRuntime;
95
+ if (typeof sdk.createAgentSession !== "function" ||
96
+ typeof sdk.getAgentDir !== "function" ||
97
+ typeof ModelRuntime?.create !== "function" ||
98
+ typeof sdk.defineTool !== "function" ||
99
+ typeof Type?.Object !== "function") {
100
+ return {
101
+ ok: false,
102
+ detail: "pi SDK custom-tool capability incompatible: requires createAgentSession, getAgentDir, ModelRuntime.create, defineTool, and typebox Type.Object",
103
+ };
104
+ }
105
+ return {
106
+ ok: true,
107
+ detail: "pi SDK structured custom-tool capability available",
108
+ };
109
+ }
110
+ catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return {
113
+ ok: false,
114
+ detail: `pi SDK custom-tool capability unavailable: ${message}`,
115
+ };
116
+ }
117
+ }
80
118
  async function resolveModel(modelRuntime, provider, modelId) {
81
119
  const fromRuntime = modelRuntime.getModel(provider, modelId);
82
120
  if (fromRuntime)
@@ -159,6 +197,24 @@ async function createSdkSession(sdk, input, shared) {
159
197
  ? { customTools: input.customTools }
160
198
  : {}),
161
199
  });
200
+ const customToolNames = (input.customTools ?? [])
201
+ .map((tool) => isRecord(tool) && typeof tool.name === "string" ? tool.name : undefined)
202
+ .filter((name) => Boolean(name));
203
+ if (customToolNames.length > 0) {
204
+ const activeTools = created.session.agent?.state?.tools;
205
+ if (!Array.isArray(activeTools)) {
206
+ throw new Error("pi SDK session does not expose agent.state.tools; refusing unverifiable custom-tool activation");
207
+ }
208
+ const activeNames = new Set(activeTools
209
+ .map((tool) => typeof tool?.name === "string" ? tool.name : undefined)
210
+ .filter((name) => Boolean(name)));
211
+ for (const customToolName of customToolNames) {
212
+ const expectedActive = input.toolNames.includes(customToolName);
213
+ if (activeNames.has(customToolName) !== expectedActive) {
214
+ throw new Error(`pi SDK custom-tool activation mismatch for ${customToolName}: expectedActive=${expectedActive}`);
215
+ }
216
+ }
217
+ }
162
218
  return created.session;
163
219
  }
164
220
  /**
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ const PACKAGE_NAME = "@playwright/cli";
5
+ function launcherFromManifest(manifestPath) {
6
+ try {
7
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
8
+ if (manifest.name !== PACKAGE_NAME)
9
+ return undefined;
10
+ const bin = typeof manifest.bin === "string"
11
+ ? manifest.bin
12
+ : manifest.bin && typeof manifest.bin === "object"
13
+ ? manifest.bin["playwright-cli"]
14
+ : undefined;
15
+ if (typeof bin !== "string" || !bin || path.isAbsolute(bin) || bin.split(/[\\/]+/).includes(".."))
16
+ return undefined;
17
+ const packageRoot = path.dirname(manifestPath);
18
+ const entryPath = path.resolve(packageRoot, bin);
19
+ const relative = path.relative(packageRoot, entryPath);
20
+ if (relative.startsWith("..") || path.isAbsolute(relative) || !existsSync(entryPath))
21
+ return undefined;
22
+ return { executable: process.execPath, argvPrefix: [entryPath], entryPath };
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ function candidateManifestPaths(cwd) {
29
+ const paths = new Set();
30
+ for (const root of [cwd, process.cwd()]) {
31
+ try {
32
+ paths.add(createRequire(path.join(path.resolve(root), "package.json")).resolve(`${PACKAGE_NAME}/package.json`));
33
+ }
34
+ catch {
35
+ // Keep searching controller-known module roots.
36
+ }
37
+ }
38
+ // Npm's Windows/global shim is paired with node_modules in either the shim
39
+ // directory or its parent (.bin). We read package metadata directly; the
40
+ // .cmd file itself is never executed or parsed as a command string.
41
+ for (const binDir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
42
+ paths.add(path.join(binDir, "node_modules", ...PACKAGE_NAME.split("/"), "package.json"));
43
+ paths.add(path.join(binDir, "..", ...PACKAGE_NAME.split("/"), "package.json"));
44
+ }
45
+ return [...paths];
46
+ }
47
+ /** Resolve only a package-declared entry; throws instead of falling back to a PATH executable. */
48
+ export function resolvePlaywrightCliLauncher(cwd) {
49
+ for (const manifestPath of candidateManifestPaths(cwd)) {
50
+ const launcher = launcherFromManifest(manifestPath);
51
+ if (launcher)
52
+ return launcher;
53
+ }
54
+ throw new Error("playwright-cli-unavailable: verified @playwright/cli JavaScript entry was not found");
55
+ }
56
+ export function checkPlaywrightCliLauncher(cwd) {
57
+ try {
58
+ return { ok: true, launcher: resolvePlaywrightCliLauncher(cwd) };
59
+ }
60
+ catch (error) {
61
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
62
+ }
63
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
3
4
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -6,6 +7,10 @@ import { writeDagNodeJsonArtifact, writeDagNodeTextArtifact, } from "../infrastr
6
7
  import { captureWorkspaceCheckpoint } from "../workflows/dag/workspace-checkpoint.js";
7
8
  import { truncateOutput } from "../shared/output-truncation.js";
8
9
  import { processTreeSpawnOptions, terminateProcessTree, } from "./process-tree.js";
10
+ import { createPlaywrightCliTool, defaultPlaywrightCliCommandRunner, } from "./pi-playwright-cli-tool.js";
11
+ import { resolvePiBackend } from "./pi-executor.js";
12
+ import { checkPiSdkCustomToolCapability } from "./pi-sdk-executor.js";
13
+ import { resolvePlaywrightCliLauncher } from "./playwright-cli-launcher.js";
9
14
  import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
10
15
  import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
11
16
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
@@ -32,6 +37,7 @@ import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview,
32
37
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
33
38
  import { buildShellProcessEnv } from "./shell-verification.js";
34
39
  import { readRunState } from "../workflows/dag/run-store.js";
40
+ import { resolveDagTaskSourcePath } from "../task/dag-source-paths.js";
35
41
  import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
36
42
  import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
37
43
  /** In-memory same-run success cache. Never shared across runIds. */
@@ -138,6 +144,56 @@ export function resolveShellCwd(root, requestedCwd) {
138
144
  }
139
145
  return resolved;
140
146
  }
147
+ async function resolveControllerFrontendBaseUrlForPreflight(input) {
148
+ const binding = input.spec.sourceBinding;
149
+ const candidates = (binding?.sources ?? []).filter((source) => /(?:^|\/)config\.md$/i.test(source.path));
150
+ let rawBaseUrl = "http://localhost:5173";
151
+ let baseUrlSource = "default-localhost-5173";
152
+ let baseUrlSourceSha256;
153
+ for (const source of candidates) {
154
+ const absolutePath = resolveDagTaskSourcePath({
155
+ workspaceRoot: input.workspaceRoot,
156
+ taskId: binding.taskId,
157
+ sourcePath: source.path,
158
+ });
159
+ const markdown = await readFile(absolutePath, "utf8");
160
+ const actualSha256 = createHash("sha256").update(markdown).digest("hex");
161
+ if (actualSha256 !== source.sha256) {
162
+ throw new Error(`browser-command-capability-unavailable: stale frontend source ${source.path}`);
163
+ }
164
+ const match = markdown.match(/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i);
165
+ if (!match?.[1]) {
166
+ if (/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]/i.test(markdown)) {
167
+ throw new Error(`browser-command-capability-unavailable: invalid controller baseUrl from ${source.path}`);
168
+ }
169
+ continue;
170
+ }
171
+ rawBaseUrl = match[1].replace(/[)\]},.;]+$/, "");
172
+ baseUrlSource = source.path;
173
+ baseUrlSourceSha256 = source.sha256;
174
+ break;
175
+ }
176
+ let parsed;
177
+ try {
178
+ parsed = new URL(rawBaseUrl);
179
+ }
180
+ catch {
181
+ throw new Error(`browser-command-capability-unavailable: invalid controller baseUrl from ${baseUrlSource}`);
182
+ }
183
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
184
+ parsed.username ||
185
+ parsed.password ||
186
+ parsed.search ||
187
+ parsed.hash ||
188
+ /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
189
+ throw new Error(`browser-command-capability-unavailable: unsafe controller baseUrl from ${baseUrlSource}`);
190
+ }
191
+ return {
192
+ baseUrl: parsed.toString(),
193
+ baseUrlSource,
194
+ ...(baseUrlSourceSha256 ? { baseUrlSourceSha256 } : {}),
195
+ };
196
+ }
141
197
  async function resolveJsonArtifactSourceNodeId(input) {
142
198
  const candidates = [
143
199
  input.fromNodeId,
@@ -1671,6 +1727,78 @@ async function executeFrontendLintBaseline(input, meta) {
1671
1727
  }
1672
1728
  export async function executeDagShellNode(input, meta) {
1673
1729
  const shell = input.task.shell;
1730
+ if (shell?.frontendBrowserToolPreflight) {
1731
+ const started = Date.now();
1732
+ try {
1733
+ if (resolvePiBackend() === "cli-only") {
1734
+ throw new Error("browser-command-capability-unavailable: CODE_AGENT_PI_BACKEND=cli-only rejects SDK-only playwright_cli");
1735
+ }
1736
+ const sdkCapability = await checkPiSdkCustomToolCapability(input.cwd);
1737
+ if (!sdkCapability.ok) {
1738
+ throw new Error(`browser-command-capability-unavailable: ${sdkCapability.detail}`);
1739
+ }
1740
+ const controllerOrigin = await resolveControllerFrontendBaseUrlForPreflight({
1741
+ workspaceRoot: input.cwd,
1742
+ spec: meta.spec,
1743
+ });
1744
+ const toolProbe = await createPlaywrightCliTool({
1745
+ repoRoot: input.cwd,
1746
+ runDir: meta.runDir,
1747
+ nodeId: input.task.id,
1748
+ caseId: "frontend-browser-preflight",
1749
+ evidenceDir: "testcase/frontend/evidence/frontend-browser-preflight",
1750
+ baseUrl: controllerOrigin.baseUrl,
1751
+ inputRoot: "testcase/frontend/fixtures",
1752
+ });
1753
+ if (toolProbe.name !== "playwright_cli") {
1754
+ throw new Error("browser-command-capability-unavailable: structured playwright_cli tool registration failed");
1755
+ }
1756
+ const launcher = resolvePlaywrightCliLauncher(input.cwd);
1757
+ const result = await defaultPlaywrightCliCommandRunner({
1758
+ executable: launcher.executable,
1759
+ argv: [...launcher.argvPrefix, "--help"],
1760
+ cwd: input.cwd,
1761
+ timeoutMs: 30_000,
1762
+ env: { ...process.env, CI: process.env.CI ?? "1" },
1763
+ });
1764
+ const help = `${result.stdout}\n${result.stderr}`;
1765
+ if (result.exitCode !== 0 ||
1766
+ result.timedOut ||
1767
+ !["open", "close", "snapshot", "click"].every((command) => new RegExp(`\\b${command}\\b`, "i").test(help))) {
1768
+ throw new Error("playwright-cli-contract-incompatible");
1769
+ }
1770
+ const capability = {
1771
+ schemaVersion: 1,
1772
+ status: "ready",
1773
+ tool: "playwright-cli",
1774
+ launcher: "verified-js-entry",
1775
+ sdkCustomToolCapability: true,
1776
+ structuredToolName: toolProbe.name,
1777
+ baseUrl: controllerOrigin.baseUrl,
1778
+ baseUrlSource: controllerOrigin.baseUrlSource,
1779
+ ...(controllerOrigin.baseUrlSourceSha256
1780
+ ? { baseUrlSourceSha256: controllerOrigin.baseUrlSourceSha256 }
1781
+ : {}),
1782
+ };
1783
+ await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "frontend-browser-capability.json", capability);
1784
+ return {
1785
+ ok: true,
1786
+ stdout: JSON.stringify(capability),
1787
+ stderr: "",
1788
+ failureCategory: "success",
1789
+ durationMs: Date.now() - started,
1790
+ };
1791
+ }
1792
+ catch (error) {
1793
+ return {
1794
+ ok: false,
1795
+ stdout: "",
1796
+ stderr: `playwright-cli-unavailable: ${error instanceof Error ? error.message : String(error)}`,
1797
+ failureCategory: "tool-policy",
1798
+ durationMs: Date.now() - started,
1799
+ };
1800
+ }
1801
+ }
1674
1802
  if (shell?.frontendPrewriteGate) {
1675
1803
  const started = Date.now();
1676
1804
  try {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Neutral command-policy source shared by the structured Pi runtime and
3
+ * pre-execution frontend case checklist. Keep this list capability-only: argv
4
+ * and browser-safety validation remain owned by the executor.
5
+ */
6
+ export const PLAYWRIGHT_CLI_ALLOWED_COMMANDS = [
7
+ "open",
8
+ "close",
9
+ "goto",
10
+ "snapshot",
11
+ "find",
12
+ "click",
13
+ "dblclick",
14
+ "fill",
15
+ "type",
16
+ "press",
17
+ "keydown",
18
+ "keyup",
19
+ "hover",
20
+ "select",
21
+ "check",
22
+ "uncheck",
23
+ "drag",
24
+ "drop",
25
+ "upload",
26
+ "go-back",
27
+ "go-forward",
28
+ "reload",
29
+ "dialog-accept",
30
+ "dialog-dismiss",
31
+ "resize",
32
+ "screenshot",
33
+ "pdf",
34
+ "console",
35
+ "requests",
36
+ "request",
37
+ ];
38
+ const allowedCommandSet = new Set(PLAYWRIGHT_CLI_ALLOWED_COMMANDS);
39
+ export function isPlaywrightCliCommand(value) {
40
+ return allowedCommandSet.has(value);
41
+ }
@@ -722,6 +722,7 @@ const ACTIVE_EFFECTIVE_STATUSES = new Set([
722
722
  "needs-attention",
723
723
  "remote-unknown",
724
724
  "pending",
725
+ "paused",
725
726
  ]);
726
727
  const TERMINAL_RUN_STATUSES = new Set([
727
728
  "finished",
@@ -730,13 +731,51 @@ const TERMINAL_RUN_STATUSES = new Set([
730
731
  "superseded",
731
732
  "abandoned",
732
733
  ]);
734
+ const NON_EXECUTION_MODES = new Set(["dry-run", "init-only"]);
735
+ const SHELL_NODE_STATUSES = new Set(["pending", "queued"]);
736
+ function hasDagExecutionEvidence(dag) {
737
+ const status = (dag.status ?? "").toLowerCase();
738
+ if (status === "running" || status === "started" || status === "paused") {
739
+ return true;
740
+ }
741
+ return (dag.nodes ?? []).some((node) => {
742
+ const nodeStatus = (node.status ?? "").toLowerCase();
743
+ return nodeStatus.length > 0 && !SHELL_NODE_STATUSES.has(nodeStatus);
744
+ });
745
+ }
733
746
  /**
734
- * Server-side mirror of app.js isDagRunActive. Derives DAG-run activeness from
735
- * already-loaded DagRunSummary facts without touching app.js or new files.
747
+ * Unified overview-active matrix (isOverviewActive). Shared contract with
748
+ * Observe UI `isDagRunActive` default total view only shows real execution.
736
749
  */
737
750
  function isDagRunActiveForHealth(dag) {
751
+ const executionMode = (dag.executionMode ?? "").toLowerCase();
752
+ if (NON_EXECUTION_MODES.has(executionMode))
753
+ return false;
754
+ const lifecycle = (dag.lifecycle ?? "").toLowerCase();
755
+ if (NON_EXECUTION_MODES.has(lifecycle))
756
+ return false;
757
+ if (lifecycle === "completed")
758
+ return false;
759
+ if (dag.effectiveStatus &&
760
+ TERMINAL_RUN_STATUSES.has(dag.effectiveStatus)) {
761
+ return false;
762
+ }
763
+ // Real execute pause remains overview-visible (AC-003).
764
+ if (lifecycle === "paused" || dag.effectiveStatus === "paused") {
765
+ return true;
766
+ }
767
+ if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase())) {
768
+ return false;
769
+ }
738
770
  if (dag.effectiveStatus &&
739
771
  ACTIVE_EFFECTIVE_STATUSES.has(dag.effectiveStatus)) {
772
+ // Legacy active+pending shells without execution evidence are not active.
773
+ if (!executionMode &&
774
+ dag.effectiveStatus === "pending" &&
775
+ !hasDagExecutionEvidence(dag)) {
776
+ return false;
777
+ }
778
+ // execute-mode pending (or any non-legacy execute) stays visible.
740
779
  return true;
741
780
  }
742
781
  // Only "unknown" (and absent) means liveness evidence is insufficient;
@@ -745,14 +784,20 @@ function isDagRunActiveForHealth(dag) {
745
784
  if (dag.effectiveStatus && dag.effectiveStatus !== "unknown")
746
785
  return false;
747
786
  const status = (dag.status ?? "").toLowerCase();
748
- if ((dag.lifecycle ?? "").toLowerCase() === "paused")
749
- return false;
750
- if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase()))
751
- return false;
752
787
  if (TERMINAL_RUN_STATUSES.has(status))
753
788
  return false;
754
- if (["running", "pending", "started"].includes(status))
789
+ if (executionMode === "execute") {
790
+ if (["running", "pending", "started", "paused"].includes(status)) {
791
+ return true;
792
+ }
793
+ return (dag.nodes ?? []).some((node) => isNodeStatusActive(node.status));
794
+ }
795
+ // Historical runs without executionMode: require real execution evidence.
796
+ if (status === "running" || status === "started")
755
797
  return true;
798
+ if (status === "pending" || status === "") {
799
+ return hasDagExecutionEvidence(dag);
800
+ }
756
801
  return (dag.nodes ?? []).some((node) => isNodeStatusActive(node.status));
757
802
  }
758
803
  function isNodeStatusActive(status) {
@@ -769,6 +814,9 @@ function isNodeStatusActive(status) {
769
814
  ].includes(normalized)) {
770
815
  return false;
771
816
  }
817
+ // pending/queued alone are not enough for overview active without mode/evidence.
818
+ if (SHELL_NODE_STATUSES.has(normalized))
819
+ return false;
772
820
  return true;
773
821
  }
774
822
  function computeDagHealth(dagRuns) {
@@ -1276,6 +1324,7 @@ function mergeDagRun(existing, incoming) {
1276
1324
  dagRunId: incoming.dagRunId,
1277
1325
  status: incoming.status ?? existing.status,
1278
1326
  lifecycle: incoming.lifecycle ?? existing.lifecycle,
1327
+ executionMode: incoming.executionMode ?? existing.executionMode,
1279
1328
  liveness: incoming.liveness ?? existing.liveness,
1280
1329
  effectiveStatus: incoming.effectiveStatus ?? existing.effectiveStatus,
1281
1330
  stateConsistent: incoming.stateConsistent ?? existing.stateConsistent,
@@ -1563,11 +1612,19 @@ async function parseDagStateFile(statePath, now) {
1563
1612
  const lifecycleName = path.basename(path.dirname(runDir));
1564
1613
  const lifecycle = lifecycleName === "active" ||
1565
1614
  lifecycleName === "paused" ||
1566
- lifecycleName === "completed"
1615
+ lifecycleName === "completed" ||
1616
+ lifecycleName === "dry-run" ||
1617
+ lifecycleName === "init-only"
1567
1618
  ? lifecycleName
1568
1619
  : undefined;
1569
1620
  const dagRunId = readString(parsed, "runId") ?? path.basename(runDir);
1570
1621
  const status = readString(parsed, "status");
1622
+ const executionModeRaw = readString(parsed, "executionMode");
1623
+ const executionMode = executionModeRaw === "execute" ||
1624
+ executionModeRaw === "dry-run" ||
1625
+ executionModeRaw === "init-only"
1626
+ ? executionModeRaw
1627
+ : undefined;
1571
1628
  const title = readString(parsed, "title");
1572
1629
  const startedAt = readString(parsed, "startedAt");
1573
1630
  const finishedAt = readString(parsed, "finishedAt");
@@ -1649,6 +1706,7 @@ async function parseDagStateFile(statePath, now) {
1649
1706
  dagRunId,
1650
1707
  status,
1651
1708
  ...(lifecycle ? { lifecycle } : {}),
1709
+ ...(executionMode ? { executionMode } : {}),
1652
1710
  liveness: liveness.status,
1653
1711
  effectiveStatus,
1654
1712
  stateConsistent,
@@ -30,29 +30,101 @@ export function dagMetaVisibility(dag) {
30
30
  };
31
31
  }
32
32
 
33
+ const NON_EXECUTION_MODES = new Set(["dry-run", "init-only"]);
34
+ const SHELL_NODE_STATUSES = new Set(["pending", "queued"]);
35
+ const ACTIVE_EFFECTIVE_STATUSES = new Set([
36
+ "running",
37
+ "running-quiet",
38
+ "running-suspected-stall",
39
+ "needs-attention",
40
+ "remote-unknown",
41
+ "pending",
42
+ "paused",
43
+ ]);
44
+ const TERMINAL_EFFECTIVE_STATUSES = new Set([
45
+ "finished",
46
+ "partial_failed",
47
+ "failed",
48
+ "superseded",
49
+ "abandoned",
50
+ ]);
51
+
33
52
  function isNodeActive(status) {
34
53
  if (!status) return false;
35
54
  const s = status.toLowerCase();
36
- return (
37
- s === "running" || s === "started" || s === "pending" || s === "queued"
38
- );
55
+ // pending/queued alone do not prove overview activity without mode/evidence.
56
+ return s === "running" || s === "started";
39
57
  }
40
58
 
59
+ function hasDagExecutionEvidence(dag) {
60
+ const status = (dag?.status ?? "").toLowerCase();
61
+ if (status === "running" || status === "started" || status === "paused") {
62
+ return true;
63
+ }
64
+ return (dag?.nodes ?? []).some((n) => {
65
+ const s = (n?.status ?? "").toLowerCase();
66
+ return s && !SHELL_NODE_STATUSES.has(s);
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Unified overview-active matrix (isOverviewActive).
72
+ * Default dashboard only lists real execution (incl. paused execute).
73
+ */
41
74
  export function isDagRunActive(dag) {
75
+ const executionMode = (dag?.executionMode ?? "").toLowerCase();
76
+ if (NON_EXECUTION_MODES.has(executionMode)) return false;
77
+
78
+ const lifecycle = (dag?.lifecycle ?? "").toLowerCase();
79
+ if (NON_EXECUTION_MODES.has(lifecycle)) return false;
80
+ if (lifecycle === "completed") return false;
81
+
42
82
  if (
43
- ["running", "running-quiet", "remote-unknown", "pending"].includes(
44
- dag.effectiveStatus,
45
- )
46
- )
83
+ dag?.effectiveStatus &&
84
+ TERMINAL_EFFECTIVE_STATUSES.has(dag.effectiveStatus)
85
+ ) {
86
+ return false;
87
+ }
88
+
89
+ // Real execute pause remains overview-visible (AC-003).
90
+ if (lifecycle === "paused" || dag?.effectiveStatus === "paused") {
47
91
  return true;
48
- if (dag.effectiveStatus && dag.effectiveStatus !== "unknown") return false;
49
- const status = (dag.status ?? "").toLowerCase();
50
- if ((dag.lifecycle ?? "").toLowerCase() === "paused") return false;
51
- if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase()))
92
+ }
93
+
94
+ if (["orphaned", "stale"].includes((dag?.liveness ?? "").toLowerCase()))
52
95
  return false;
96
+
97
+ if (
98
+ dag?.effectiveStatus &&
99
+ ACTIVE_EFFECTIVE_STATUSES.has(dag.effectiveStatus)
100
+ ) {
101
+ if (
102
+ !executionMode &&
103
+ dag.effectiveStatus === "pending" &&
104
+ !hasDagExecutionEvidence(dag)
105
+ ) {
106
+ return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ if (dag?.effectiveStatus && dag.effectiveStatus !== "unknown") return false;
112
+
113
+ const status = (dag?.status ?? "").toLowerCase();
53
114
  if (TERMINAL_RUN_STATUSES.has(status)) return false;
54
- if (["running", "pending", "started"].includes(status)) return true;
55
- return (dag.nodes ?? []).some((n) => isNodeActive(n.status));
115
+
116
+ if (executionMode === "execute") {
117
+ if (["running", "pending", "started", "paused"].includes(status))
118
+ return true;
119
+ return (dag?.nodes ?? []).some((n) => isNodeActive(n.status));
120
+ }
121
+
122
+ // Historical runs without executionMode: require real execution evidence.
123
+ if (status === "running" || status === "started") return true;
124
+ if (status === "pending" || status === "") {
125
+ return hasDagExecutionEvidence(dag);
126
+ }
127
+ return (dag?.nodes ?? []).some((n) => isNodeActive(n.status));
56
128
  }
57
129
 
58
130
  const DASHBOARD_FEATURE_STATUSES = new Set(["running", "needs_action"]);
@@ -1,6 +1,7 @@
1
1
  import { writeDagRunJsonArtifact } from "../../../infrastructure/harness/artifact-store.js";
2
2
  import { executeDagNode, } from "../node-execution.js";
3
3
  import { writeRunSpec } from "../run-store.js";
4
+ import { assertValidMaterializedDagTask } from "../validate.js";
4
5
  import { freshNodeRecord, lookupPath, parseConditionLiteral, parseJsonFromText, } from "./shared.js";
5
6
  function evaluateLoopStopExpression(input) {
6
7
  const equality = input.expression.match(/^\s*(\$\..+?)\s*==\s*(.+?)\s*$/);
@@ -38,6 +39,8 @@ export function buildLoopBodyChildTask(input) {
38
39
  executor: bodyTask.executor,
39
40
  role: bodyTask.role,
40
41
  skills: bodyTask.skills,
42
+ toolProfile: bodyTask.toolProfile,
43
+ commandPolicy: bodyTask.commandPolicy,
41
44
  writePolicy: bodyTask.writePolicy,
42
45
  allowedPaths: bodyTask.allowedPaths,
43
46
  forbiddenPaths: bodyTask.forbiddenPaths,
@@ -73,6 +76,7 @@ export async function executeDynamicLoopUntil(input) {
73
76
  bodyIdMap,
74
77
  }));
75
78
  for (const child of children) {
79
+ assertValidMaterializedDagTask(child);
76
80
  if (!input.tasksById.has(child.id)) {
77
81
  input.tasksById.set(child.id, child);
78
82
  input.spec.tasks.push(child);