@tea-agent/loop-agent 0.27.1 → 0.28.1-beta.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 +24 -0
- package/dist/application/task-lifecycle/observe.js +5 -0
- package/dist/application/task-lifecycle/plan-transitions.js +7 -2
- package/dist/cli/program.js +1 -1
- package/dist/commands/client-recovery.js +439 -20
- package/dist/commands/init.js +42 -6
- package/dist/executors/dag-pi-executor.js +161 -60
- package/dist/executors/pi-playwright-cli-tool.js +955 -0
- package/dist/executors/pi-sdk-executor.js +56 -0
- package/dist/executors/playwright-cli-launcher.js +63 -0
- package/dist/executors/shell-executor.js +128 -0
- package/dist/shared/playwright-cli-command-policy.js +41 -0
- package/dist/task/task-demand-routing.js +1 -15
- package/dist/worker/observability/read-model.js +66 -8
- package/dist/worker/observe/static/dag-model.js +85 -13
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
- package/dist/workflows/dag/frontend-implementation-contract.js +124 -6
- package/dist/workflows/dag/frontend-prewrite-gate.js +22 -8
- package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
- package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
- package/dist/workflows/dag/init-hybrid.js +181 -68
- package/dist/workflows/dag/lifecycle.js +33 -2
- package/dist/workflows/dag/node-execution.js +11 -5
- package/dist/workflows/dag/output-protocol.js +48 -83
- package/dist/workflows/dag/report.js +9 -2
- package/dist/workflows/dag/rerun-run.js +62 -3
- package/dist/workflows/dag/run-store.js +6 -1
- package/dist/workflows/dag/runner.js +15 -3
- package/dist/workflows/dag/types.js +27 -0
- package/dist/workflows/dag/validate.js +121 -1
- package/docs/architecture/runtime-boundaries.md +13 -11
- package/docs/init-surface.manifest.json +6 -2
- package/docs/templates/README.md +9 -1
- package/docs/templates/frontend-implementation-contract.schema.json +2 -2
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
- package/docs/templates/frontend-test-dag.json +55 -15
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +18 -6
- package/skills/playwright-cli/SKILL.md +69 -402
- package/skills/playwright-cli/references/tracing.md +3 -137
- package/skills/playwright-cli/references/video-recording.md +3 -141
- package/skills/playwright-cli-case-generator/SKILL.md +53 -46
|
@@ -62,126 +62,68 @@ export function firstNonEmptyLine(text) {
|
|
|
62
62
|
}
|
|
63
63
|
return undefined;
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Parse the JSON-only review verdict protocol. The complete trimmed output
|
|
67
|
+
* must be exactly one JSON object: fences, prose, partial objects, and
|
|
68
|
+
* concatenated objects are all rejected before schema validation.
|
|
69
|
+
*/
|
|
70
|
+
export function parseJsonReviewVerdict(text) {
|
|
66
71
|
const trimmed = String(text).trim();
|
|
67
72
|
if (!trimmed)
|
|
68
73
|
return { ok: false, reason: "missing JSON output" };
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
continue;
|
|
73
|
-
let depth = 0;
|
|
74
|
-
let inString = false;
|
|
75
|
-
let escaped = false;
|
|
76
|
-
for (let index = start; index < source.length; index += 1) {
|
|
77
|
-
const char = source[index];
|
|
78
|
-
if (inString) {
|
|
79
|
-
if (escaped)
|
|
80
|
-
escaped = false;
|
|
81
|
-
else if (char === "\\")
|
|
82
|
-
escaped = true;
|
|
83
|
-
else if (char === '"')
|
|
84
|
-
inString = false;
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (char === '"')
|
|
88
|
-
inString = true;
|
|
89
|
-
else if (char === "{")
|
|
90
|
-
depth += 1;
|
|
91
|
-
else if (char === "}" && --depth === 0) {
|
|
92
|
-
const candidate = source.slice(start, index + 1);
|
|
93
|
-
try {
|
|
94
|
-
const parsed = JSON.parse(candidate);
|
|
95
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
96
|
-
return candidate;
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// Continue scanning for the next complete object.
|
|
100
|
-
}
|
|
101
|
-
break;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return undefined;
|
|
106
|
-
};
|
|
107
|
-
const direct = extractBalancedObject(trimmed);
|
|
108
|
-
if (direct)
|
|
109
|
-
return { ok: true, jsonText: direct };
|
|
110
|
-
const fencedMatches = Array.from(trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/gi));
|
|
111
|
-
if (fencedMatches.length === 1) {
|
|
112
|
-
const jsonText = fencedMatches[0][1].trim();
|
|
113
|
-
const fencedObject = extractBalancedObject(jsonText);
|
|
114
|
-
if (fencedObject)
|
|
115
|
-
return { ok: true, jsonText: fencedObject };
|
|
116
|
-
return {
|
|
117
|
-
ok: false,
|
|
118
|
-
reason: "single fenced block is not a JSON object",
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
if (fencedMatches.length > 1) {
|
|
122
|
-
return {
|
|
123
|
-
ok: false,
|
|
124
|
-
reason: "multiple fenced JSON candidates found; expected exactly one JSON object",
|
|
125
|
-
};
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(trimmed);
|
|
126
77
|
}
|
|
127
|
-
|
|
128
|
-
ok: false,
|
|
129
|
-
reason: "output is not a single JSON object; expected only JSON with no Markdown or prose",
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
function validateJsonReviewVerdict(text) {
|
|
133
|
-
const extracted = extractSingleJsonObjectText(text);
|
|
134
|
-
if (!extracted.ok) {
|
|
78
|
+
catch (error) {
|
|
135
79
|
return {
|
|
136
80
|
ok: false,
|
|
137
|
-
|
|
138
|
-
reason: extracted.reason,
|
|
139
|
-
firstNonEmptyLine: firstNonEmptyLine(text),
|
|
81
|
+
reason: `output must be exactly one JSON object: ${error instanceof Error ? error.message : String(error)}`,
|
|
140
82
|
};
|
|
141
83
|
}
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
parsed = JSON.parse(extracted.jsonText);
|
|
145
|
-
}
|
|
146
|
-
catch (error) {
|
|
84
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
147
85
|
return {
|
|
148
86
|
ok: false,
|
|
149
|
-
|
|
150
|
-
reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
151
|
-
firstNonEmptyLine: firstNonEmptyLine(text),
|
|
87
|
+
reason: "output must be exactly one JSON object",
|
|
152
88
|
};
|
|
153
89
|
}
|
|
154
90
|
const checked = reviewJsonVerdictSchema.safeParse(parsed);
|
|
155
91
|
if (!checked.success) {
|
|
156
92
|
return {
|
|
157
93
|
ok: false,
|
|
158
|
-
failureCategory: "protocol-invalid",
|
|
159
94
|
reason: `JSON review verdict schema violation: ${checked.error.issues
|
|
160
95
|
.map((issue) => `${issue.path.join(".") || "<root>"} ${issue.message}`)
|
|
161
96
|
.join("; ")}`,
|
|
162
|
-
firstNonEmptyLine: firstNonEmptyLine(text),
|
|
163
97
|
};
|
|
164
98
|
}
|
|
165
99
|
const blockingFindings = checked.data.findings.filter((finding) => finding.severity === "Critical" || finding.severity === "Important");
|
|
166
100
|
if (checked.data.verdict === "pass" && blockingFindings.length > 0) {
|
|
167
101
|
return {
|
|
168
102
|
ok: false,
|
|
169
|
-
failureCategory: "protocol-invalid",
|
|
170
103
|
reason: "JSON review verdict cannot be pass when Critical or Important findings are present",
|
|
171
|
-
firstNonEmptyLine: firstNonEmptyLine(text),
|
|
172
104
|
};
|
|
173
105
|
}
|
|
174
106
|
if (checked.data.verdict === "request-revision" &&
|
|
175
107
|
checked.data.findings.length === 0) {
|
|
176
108
|
return {
|
|
177
109
|
ok: false,
|
|
178
|
-
failureCategory: "protocol-invalid",
|
|
179
110
|
reason: "JSON review verdict request-revision requires at least one finding",
|
|
180
|
-
firstNonEmptyLine: firstNonEmptyLine(text),
|
|
181
111
|
};
|
|
182
112
|
}
|
|
183
113
|
return { ok: true, verdict: checked.data.verdict };
|
|
184
114
|
}
|
|
115
|
+
function validateJsonReviewVerdict(text) {
|
|
116
|
+
const parsed = parseJsonReviewVerdict(text);
|
|
117
|
+
if (!parsed.ok) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
failureCategory: "protocol-invalid",
|
|
121
|
+
reason: parsed.reason,
|
|
122
|
+
firstNonEmptyLine: firstNonEmptyLine(text),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, verdict: parsed.verdict };
|
|
126
|
+
}
|
|
185
127
|
/**
|
|
186
128
|
* Validate node output against an explicit outputProtocol.
|
|
187
129
|
* Pure function — does not mutate run facts.
|
|
@@ -206,6 +148,29 @@ export function validateOutputProtocol(protocol, text) {
|
|
|
206
148
|
reason: `missing first non-empty line; expected one of: ${protocol.validLines.map((l) => JSON.stringify(l)).join(" or ")}`,
|
|
207
149
|
};
|
|
208
150
|
}
|
|
151
|
+
const isReviewVerdict = protocol.validLines.length === 2 && protocol.validLines.includes("VERDICT: pass") && protocol.validLines.includes("VERDICT: request-revision");
|
|
152
|
+
// Models frequently prepend a short explanation despite the protocol
|
|
153
|
+
// instruction. Recover only when there is exactly one unambiguous protocol
|
|
154
|
+
// line; conflicting or repeated verdicts remain fail-closed.
|
|
155
|
+
const candidates = isReviewVerdict ? text
|
|
156
|
+
.split("\n")
|
|
157
|
+
.map((line) => normalizeVerdictCandidateLine(line.trim()))
|
|
158
|
+
.filter((line) => protocol.validLines.includes(line)) : [];
|
|
159
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
160
|
+
if (uniqueCandidates.length > 1) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
failureCategory: "protocol-invalid",
|
|
164
|
+
reason: `conflicting protocol lines found: ${uniqueCandidates.map((line) => JSON.stringify(line)).join(" and ")}`,
|
|
165
|
+
firstNonEmptyLine: first,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (protocol.validLines.includes(first)) {
|
|
169
|
+
return { ok: true, matchedLine: first };
|
|
170
|
+
}
|
|
171
|
+
if (uniqueCandidates.length === 1) {
|
|
172
|
+
return { ok: true, matchedLine: uniqueCandidates[0] };
|
|
173
|
+
}
|
|
209
174
|
if (!protocol.validLines.includes(first)) {
|
|
210
175
|
return {
|
|
211
176
|
ok: false,
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { resolveCliPath } from "../../shared/path-refs.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { repairArtifactSchema } from "./repair-artifact.js";
|
|
7
|
-
import { dagRunDirExists, getDagRunDir, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
|
|
7
|
+
import { dagRunDirExists, getDagRunDir, isDagExecutionLifecycle, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
|
|
8
8
|
export const DAG_CLOSEOUT_DRAFT_DISCLAIMER = "> **Advisory only.** Derived from completed run facts. Canonical source remains `dag report --json` and `.harness/dag-runs/completed/<run-id>/`. Do not treat this draft as authoritative.";
|
|
9
9
|
import { dagNormalizedFailureCategorySchema, normalizeDagFailureCategory, } from "./failure-category.js";
|
|
10
10
|
import { dagProductLineFailureCategoryValues, routeDagFailure, } from "./failure-routing.js";
|
|
@@ -659,7 +659,14 @@ async function locateRunForReport(repoRoot, runId, filter) {
|
|
|
659
659
|
if (!located) {
|
|
660
660
|
throw new Error(`dag run not found: ${runId}`);
|
|
661
661
|
}
|
|
662
|
-
|
|
662
|
+
if (!isDagExecutionLifecycle(located.lifecycle)) {
|
|
663
|
+
throw new Error(`dag run not found: ${runId}`);
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
lifecycle: located.lifecycle,
|
|
667
|
+
runDir: located.runDir,
|
|
668
|
+
runId,
|
|
669
|
+
};
|
|
663
670
|
}
|
|
664
671
|
const runDir = getDagRunDir(repoRoot, filter, runId);
|
|
665
672
|
if (!(await dagRunDirExists(runDir))) {
|
|
@@ -4,9 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
5
5
|
import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
|
|
6
6
|
import { readControllerIdentityArtifact, captureControllerIdentity } from "./controller-identity.js";
|
|
7
|
-
import { DAG_RUNS_DIR, getDagRunDir, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
|
|
7
|
+
import { DAG_RUNS_DIR, getDagRunDir, isDagNonExecutionLifecycle, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
|
|
8
8
|
import { buildDagRunId, runDagContinuation, } from "./runner.js";
|
|
9
|
-
import { evaluateDagRerunPlan, evaluateLiveDagRerunBindings, } from "./rerun-plan.js";
|
|
9
|
+
import { computePlanHash, evaluateDagRerunPlan, evaluateLiveDagRerunBindings, } from "./rerun-plan.js";
|
|
10
10
|
import { prepareActiveRunDir, writeNodeRecord, writeRunSpec, writeRunState, } from "./run-store.js";
|
|
11
11
|
import { cloneParentSkillSnapshotForContinuation, readSkillSnapshot, } from "./skill-snapshot.js";
|
|
12
12
|
import { topoSortToRanks } from "./topo.js";
|
|
@@ -123,6 +123,65 @@ export async function planDagRerun(input) {
|
|
|
123
123
|
const nonCompletedSpec = await readDagRunSpec(located.runDir);
|
|
124
124
|
const nonCompletedState = await readDagRunState(located.runDir);
|
|
125
125
|
const nonCompletedWorkerHints = await detectWorkerManagedRun(located.runDir, nonCompletedState, nonCompletedSpec);
|
|
126
|
+
// evaluateDagRerunPlan only accepts execution lifecycles. Audit parents must
|
|
127
|
+
// not be cast to "active" (reconcile) or "paused" (resume). Also avoid routing
|
|
128
|
+
// audit through evaluate: missing workspace checkpoint would surface as
|
|
129
|
+
// standalone-task-rerun instead of the required fail-closed manual action.
|
|
130
|
+
if (isDagNonExecutionLifecycle(located.lifecycle)) {
|
|
131
|
+
const bindingStatus = await evaluateLiveDagRerunBindings({
|
|
132
|
+
cwd: input.cwd,
|
|
133
|
+
parentSpec: nonCompletedSpec,
|
|
134
|
+
});
|
|
135
|
+
const workerAssociation = nonCompletedWorkerHints.workerManaged
|
|
136
|
+
? {
|
|
137
|
+
kind: "worker-managed",
|
|
138
|
+
...(nonCompletedWorkerHints.featureId
|
|
139
|
+
? { featureId: nonCompletedWorkerHints.featureId }
|
|
140
|
+
: {}),
|
|
141
|
+
...(nonCompletedWorkerHints.taskId
|
|
142
|
+
? { taskId: nonCompletedWorkerHints.taskId }
|
|
143
|
+
: {}),
|
|
144
|
+
...(nonCompletedWorkerHints.workerRunId
|
|
145
|
+
? { workerRunId: nonCompletedWorkerHints.workerRunId }
|
|
146
|
+
: {}),
|
|
147
|
+
}
|
|
148
|
+
: { kind: "standalone" };
|
|
149
|
+
const planWithoutHash = {
|
|
150
|
+
schemaVersion: 1,
|
|
151
|
+
eligible: false,
|
|
152
|
+
parentRunId: input.parentRunId,
|
|
153
|
+
parentLifecycle: located.lifecycle,
|
|
154
|
+
selectedNodeId: input.selectedNodeId,
|
|
155
|
+
rewriteApplied: false,
|
|
156
|
+
resetNodeIds: [],
|
|
157
|
+
importedNodeIds: [],
|
|
158
|
+
blockingNodes: [],
|
|
159
|
+
reasonCodes: ["parent-lifecycle-ineligible"],
|
|
160
|
+
blockedReasons: ["parent-lifecycle-ineligible"],
|
|
161
|
+
risk: "high",
|
|
162
|
+
bindings: {
|
|
163
|
+
sourceBindingMatched: bindingStatus.sourceBindingMatched,
|
|
164
|
+
taskContractBindingMatched: bindingStatus.taskContractBindingMatched,
|
|
165
|
+
controllerIdentityMatched: true,
|
|
166
|
+
skillSnapshotVerified: true,
|
|
167
|
+
workspaceMatched: false,
|
|
168
|
+
},
|
|
169
|
+
workerAssociation,
|
|
170
|
+
estimatedExecution: {
|
|
171
|
+
nodeCount: 0,
|
|
172
|
+
piCallCount: 0,
|
|
173
|
+
shellNodeCount: 0,
|
|
174
|
+
avoidedNodeCount: 0,
|
|
175
|
+
avoidedPiCallCount: 0,
|
|
176
|
+
},
|
|
177
|
+
suggestedAction: "manual",
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
...planWithoutHash,
|
|
181
|
+
planHash: computePlanHash(planWithoutHash),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const parentLifecycle = located.lifecycle === "active" ? "active" : "paused";
|
|
126
185
|
return evaluateDagRerunPlan({
|
|
127
186
|
cwd: input.cwd,
|
|
128
187
|
parentRunId: input.parentRunId,
|
|
@@ -130,7 +189,7 @@ export async function planDagRerun(input) {
|
|
|
130
189
|
parentRunDir: located.runDir,
|
|
131
190
|
parentSpec: nonCompletedSpec,
|
|
132
191
|
parentState: nonCompletedState,
|
|
133
|
-
parentLifecycle
|
|
192
|
+
parentLifecycle,
|
|
134
193
|
workerManaged: nonCompletedWorkerHints.workerManaged,
|
|
135
194
|
workerAssociation: nonCompletedWorkerHints.workerManaged
|
|
136
195
|
? {
|
|
@@ -11,10 +11,15 @@ export async function writeRunState(runDir, state, options) {
|
|
|
11
11
|
export async function readRunSpec(runDir) {
|
|
12
12
|
return readDagRunSpec(runDir);
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
/** Prepare any lifecycle run directory (active / dry-run / init-only / …). */
|
|
15
|
+
export async function prepareRunDir(runDir) {
|
|
15
16
|
await rm(runDir, { recursive: true, force: true });
|
|
16
17
|
await mkdir(runDir, { recursive: true });
|
|
17
18
|
}
|
|
19
|
+
/** @deprecated Prefer prepareRunDir; kept as thin alias for execute callers. */
|
|
20
|
+
export async function prepareActiveRunDir(runDir) {
|
|
21
|
+
await prepareRunDir(runDir);
|
|
22
|
+
}
|
|
18
23
|
export async function writeRunSpec(runDir, spec) {
|
|
19
24
|
await writeJsonAtomic(path.join(runDir, "run.json"), spec);
|
|
20
25
|
}
|
|
@@ -7,7 +7,7 @@ import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-s
|
|
|
7
7
|
import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/runtime.js";
|
|
8
8
|
import { assertFrozenBudget, initRunBudgetLedger, preflightBudgetOrBreach, recordFinishedNodeBudget, writeBudgetLedgerArtifacts, } from "./budget-enforcement.js";
|
|
9
9
|
import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
|
|
10
|
-
import { moveToCompletedRunDir, moveToPausedRunDir,
|
|
10
|
+
import { moveToCompletedRunDir, moveToPausedRunDir, prepareRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
|
|
11
11
|
import { evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
|
|
12
12
|
import { createDagNodeExecutor } from "./executor-registry.js";
|
|
13
13
|
import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
|
|
@@ -126,12 +126,18 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
|
|
|
126
126
|
: {}),
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
|
+
const executionMode = opts.dryRun
|
|
130
|
+
? "dry-run"
|
|
131
|
+
: opts.initOnly
|
|
132
|
+
? "init-only"
|
|
133
|
+
: "execute";
|
|
129
134
|
const state = {
|
|
130
135
|
version: 1,
|
|
131
136
|
title: spec.title,
|
|
132
137
|
runId,
|
|
133
138
|
cwd: opts.cwd,
|
|
134
139
|
startedAt: new Date().toISOString(),
|
|
140
|
+
executionMode,
|
|
135
141
|
runner: {
|
|
136
142
|
pid: process.pid,
|
|
137
143
|
hostname: hostname(),
|
|
@@ -212,8 +218,14 @@ export async function runDag(spec, opts) {
|
|
|
212
218
|
const activeRunDir = getDagRunDir(opts.cwd, "active", state.runId);
|
|
213
219
|
const completedRunDir = getDagRunDir(opts.cwd, "completed", state.runId);
|
|
214
220
|
const pausedRunDir = getDagRunDir(opts.cwd, "paused", state.runId);
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
// R-A2: non-execution modes use dedicated lifecycles; only execute enters active/.
|
|
222
|
+
const lifecycleDir = state.executionMode === "dry-run"
|
|
223
|
+
? "dry-run"
|
|
224
|
+
: state.executionMode === "init-only"
|
|
225
|
+
? "init-only"
|
|
226
|
+
: "active";
|
|
227
|
+
const runDir = getDagRunDir(opts.cwd, lifecycleDir, state.runId);
|
|
228
|
+
await prepareRunDir(runDir);
|
|
217
229
|
await writeRunSpec(runDir, spec);
|
|
218
230
|
// Freeze the running controller identity as a run-owned fact before the
|
|
219
231
|
// first canonical state write, reusing the identity resolved for preflight.
|
|
@@ -10,6 +10,27 @@ export const dagNodeExecutorSchema = z.enum(["pi", "shell", "static"]);
|
|
|
10
10
|
export const CURSOR_DAG_EXECUTOR_REMOVED_ERROR = 'executor "cursor" is no longer supported; regenerate the DAG with Pi-only writers (implement-pi / repair-pi)';
|
|
11
11
|
export const CURSOR_EXECUTOR_MODELS_REMOVED_ERROR = "executorModels.cursor is no longer supported; use executorModels.pi only";
|
|
12
12
|
export const dagToolProfileSchema = z.enum(["read-only", "write"]);
|
|
13
|
+
/** Static command capabilities only; tasks cannot inject executables or shell prefixes. */
|
|
14
|
+
export const dagCommandCapabilitySchema = z.enum(["playwright-cli"]);
|
|
15
|
+
export const dagCommandPolicySchema = z.discriminatedUnion("mode", [
|
|
16
|
+
z.object({ mode: z.literal("deny") }).strict(),
|
|
17
|
+
z
|
|
18
|
+
.object({
|
|
19
|
+
mode: z.literal("capability-allowlist"),
|
|
20
|
+
capabilities: z.array(dagCommandCapabilitySchema).min(1),
|
|
21
|
+
})
|
|
22
|
+
.strict(),
|
|
23
|
+
]);
|
|
24
|
+
/** Undefined / omitted commandPolicy is treated as deny by executors and validators. */
|
|
25
|
+
export function resolveDagCommandPolicy(policy) {
|
|
26
|
+
return policy ?? { mode: "deny" };
|
|
27
|
+
}
|
|
28
|
+
export function dagCommandPolicyAllows(policy, capability) {
|
|
29
|
+
const resolved = resolveDagCommandPolicy(policy);
|
|
30
|
+
if (resolved.mode !== "capability-allowlist")
|
|
31
|
+
return false;
|
|
32
|
+
return resolved.capabilities.includes(capability);
|
|
33
|
+
}
|
|
13
34
|
export const dagOutputModeSchema = z.enum(["default", "structured-required"]);
|
|
14
35
|
export const dagShellPresetSchema = z.enum(["loop-agent-standard-verify"]);
|
|
15
36
|
export const dagVerifyQuotaSchema = z.enum(["1", "3", "full"]);
|
|
@@ -319,6 +340,7 @@ export const dagBackendTestPipelineSchema = z.enum([
|
|
|
319
340
|
"markdown-execute-html",
|
|
320
341
|
"markdown-manifest",
|
|
321
342
|
]);
|
|
343
|
+
export const dagFrontendBrowserToolPreflightSchema = z.object({}).strict();
|
|
322
344
|
export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
|
|
323
345
|
export const dagFrontendTestCaseChecklistSchema = z.object({}).strict();
|
|
324
346
|
export const dagFrontendTestHtmlReportSchema = z.object({}).strict();
|
|
@@ -369,6 +391,7 @@ export const dagShellConfigSchema = z.object({
|
|
|
369
391
|
frontendVerificationBundle: dagFrontendVerificationBundleSchema.optional(),
|
|
370
392
|
frontendReviewContext: dagFrontendReviewContextSchema.optional(),
|
|
371
393
|
frontendTestCaseChecklist: dagFrontendTestCaseChecklistSchema.optional(),
|
|
394
|
+
frontendBrowserToolPreflight: dagFrontendBrowserToolPreflightSchema.optional(),
|
|
372
395
|
frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
|
|
373
396
|
frontendTestL5Report: z.object({}).strict().optional(),
|
|
374
397
|
frontendTestHtmlReport: dagFrontendTestHtmlReportSchema.optional(),
|
|
@@ -416,6 +439,7 @@ export const dagDynamicExpansionChildTaskSchema = z.object({
|
|
|
416
439
|
outputContract: z.string().optional(),
|
|
417
440
|
staticResultTemplate: z.string().optional(),
|
|
418
441
|
toolProfile: dagToolProfileSchema.optional(),
|
|
442
|
+
commandPolicy: dagCommandPolicySchema.optional(),
|
|
419
443
|
writePolicy: dagWritePolicySchema.optional(),
|
|
420
444
|
allowedPaths: z.array(z.string()).optional().default([]),
|
|
421
445
|
forbiddenPaths: z.array(z.string()).optional().default([]),
|
|
@@ -487,6 +511,7 @@ export const dagDynamicLoopBodyTaskSchema = z.object({
|
|
|
487
511
|
outputContract: z.string().optional(),
|
|
488
512
|
staticResultTemplate: z.string().optional(),
|
|
489
513
|
toolProfile: dagToolProfileSchema.optional(),
|
|
514
|
+
commandPolicy: dagCommandPolicySchema.optional(),
|
|
490
515
|
writePolicy: dagWritePolicySchema.optional(),
|
|
491
516
|
allowedPaths: z.array(z.string()).optional().default([]),
|
|
492
517
|
forbiddenPaths: z.array(z.string()).optional().default([]),
|
|
@@ -558,6 +583,8 @@ export const dagTaskSchema = z.object({
|
|
|
558
583
|
role: dagRoleSchema.optional(),
|
|
559
584
|
skills: z.array(z.string()).optional(),
|
|
560
585
|
toolProfile: dagToolProfileSchema.optional(),
|
|
586
|
+
/** Default deny: file write (toolProfile=write) does not grant command execution. */
|
|
587
|
+
commandPolicy: dagCommandPolicySchema.optional(),
|
|
561
588
|
writePolicy: dagWritePolicySchema.optional(),
|
|
562
589
|
writeSet: z.array(z.string()).optional(),
|
|
563
590
|
piStep: z.string().optional(),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_DAG_EXECUTOR_MODELS, ENV_VAR_NAME_PATTERN, } from "./types.js";
|
|
1
|
+
import { DEFAULT_DAG_EXECUTOR_MODELS, ENV_VAR_NAME_PATTERN, dagCommandPolicyAllows, resolveDagCommandPolicy, } from "./types.js";
|
|
2
2
|
import { resolveShellCommands } from "../../executors/shell-executor.js";
|
|
3
3
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
4
4
|
import { resolveRepairTaskForGate } from "./repair-artifact.js";
|
|
@@ -461,6 +461,7 @@ function validateShellTaskConfig(task, spec, issues) {
|
|
|
461
461
|
!shell.jsonArtifactGate &&
|
|
462
462
|
!shell.backendTestPipeline &&
|
|
463
463
|
!shell.frontendPrewriteGate &&
|
|
464
|
+
!shell.frontendBrowserToolPreflight &&
|
|
464
465
|
!shell.frontendVerificationBundle &&
|
|
465
466
|
!shell.frontendReviewContext &&
|
|
466
467
|
!shell.frontendTestCaseChecklist &&
|
|
@@ -623,6 +624,104 @@ function validateWriterOutcomePolicyTaskConfig(task, issues) {
|
|
|
623
624
|
});
|
|
624
625
|
}
|
|
625
626
|
}
|
|
627
|
+
function isFrontendEvidenceWriteSet(writeSet) {
|
|
628
|
+
const entries = writeSet ?? [];
|
|
629
|
+
if (entries.length === 0)
|
|
630
|
+
return false;
|
|
631
|
+
return entries.every((entry) => {
|
|
632
|
+
const normalized = entry.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
633
|
+
return (normalized === "testcase/frontend/evidence/**" ||
|
|
634
|
+
normalized.startsWith("testcase/frontend/evidence/"));
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
function isFrontendBrowserExecutorTask(task) {
|
|
638
|
+
const skills = task.skills ?? [];
|
|
639
|
+
return (task.executor === "pi" &&
|
|
640
|
+
task.toolProfile === "write" &&
|
|
641
|
+
skills.includes("playwright-cli") &&
|
|
642
|
+
isFrontendEvidenceWriteSet(task.writeSet));
|
|
643
|
+
}
|
|
644
|
+
function taskMentionsPlaywrightCliContract(task) {
|
|
645
|
+
const prompt = `${task.subtask_prompt}\n${task.outputContract ?? ""}`;
|
|
646
|
+
return /playwright[_-]?cli/i.test(prompt);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Command capability is orthogonal to file write. Fail closed when:
|
|
650
|
+
* - frontend browser executors lack playwright-cli capability
|
|
651
|
+
* - capability is granted without skill/prompt contract or outside evidence writeSet
|
|
652
|
+
* - non-browser nodes receive command capability
|
|
653
|
+
*/
|
|
654
|
+
function validateCommandPolicyTaskConfig(task, issues) {
|
|
655
|
+
const policy = resolveDagCommandPolicy(task.commandPolicy);
|
|
656
|
+
const allowsPlaywright = dagCommandPolicyAllows(task.commandPolicy, "playwright-cli");
|
|
657
|
+
const isBrowserExecutor = isFrontendBrowserExecutorTask(task);
|
|
658
|
+
if (isBrowserExecutor && !allowsPlaywright) {
|
|
659
|
+
issues.push({
|
|
660
|
+
type: "invalid-command-policy",
|
|
661
|
+
message: `frontend-test browser executor requires playwright-cli command capability (task ${task.id})`,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
if (policy.mode === "deny") {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (task.executor !== "pi") {
|
|
668
|
+
issues.push({
|
|
669
|
+
type: "invalid-command-policy",
|
|
670
|
+
message: `task ${task.id} commandPolicy requires executor=pi`,
|
|
671
|
+
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (allowsPlaywright) {
|
|
675
|
+
const skills = task.skills ?? [];
|
|
676
|
+
if (!skills.includes("playwright-cli")) {
|
|
677
|
+
issues.push({
|
|
678
|
+
type: "invalid-command-policy",
|
|
679
|
+
message: `task ${task.id} playwright-cli command capability requires skills to include playwright-cli`,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (!taskMentionsPlaywrightCliContract(task)) {
|
|
683
|
+
issues.push({
|
|
684
|
+
type: "invalid-command-policy",
|
|
685
|
+
message: `task ${task.id} playwright-cli command capability requires prompt/outputContract to reference playwright_cli`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
if (!isFrontendEvidenceWriteSet(task.writeSet)) {
|
|
689
|
+
issues.push({
|
|
690
|
+
type: "invalid-command-policy",
|
|
691
|
+
message: `task ${task.id} playwright-cli command capability requires frontend evidence writeSet under testcase/frontend/evidence/`,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
if (task.toolProfile !== "write") {
|
|
695
|
+
issues.push({
|
|
696
|
+
type: "invalid-command-policy",
|
|
697
|
+
message: `task ${task.id} playwright-cli command capability requires toolProfile=write for evidence mutation`,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function validateDynamicChildCommandPolicy(task, issues) {
|
|
703
|
+
const expansion = task.dynamicExpansion;
|
|
704
|
+
if (!expansion)
|
|
705
|
+
return;
|
|
706
|
+
const child = expansion.childTask;
|
|
707
|
+
const synthetic = {
|
|
708
|
+
id: `${task.id}-child-template`,
|
|
709
|
+
depends_on: [],
|
|
710
|
+
complexity: child.complexity,
|
|
711
|
+
subtask_prompt: child.subtaskPromptTemplate,
|
|
712
|
+
executor: child.executor,
|
|
713
|
+
role: child.role,
|
|
714
|
+
skills: child.skills,
|
|
715
|
+
toolProfile: child.toolProfile,
|
|
716
|
+
commandPolicy: child.commandPolicy,
|
|
717
|
+
writePolicy: child.writePolicy,
|
|
718
|
+
allowedPaths: child.allowedPaths,
|
|
719
|
+
forbiddenPaths: child.forbiddenPaths,
|
|
720
|
+
writeSet: child.writeSet,
|
|
721
|
+
outputContract: child.outputContract,
|
|
722
|
+
};
|
|
723
|
+
validateCommandPolicyTaskConfig(synthetic, issues);
|
|
724
|
+
}
|
|
626
725
|
function validateDecisionGateTaskConfig(task, issues) {
|
|
627
726
|
if (!task.decisionGate?.enabled) {
|
|
628
727
|
return;
|
|
@@ -727,6 +826,25 @@ export function collectForbiddenExecutorIssues(spec, forbiddenExecutors) {
|
|
|
727
826
|
implicitDefault: rawTasks[index]?.executor === undefined,
|
|
728
827
|
}));
|
|
729
828
|
}
|
|
829
|
+
/** Revalidate a rendered dynamic child before it enters state or executes. */
|
|
830
|
+
export function assertValidMaterializedDagTask(task) {
|
|
831
|
+
const issues = [];
|
|
832
|
+
// A minimal v2 carrier makes write-policy validation strict while avoiding
|
|
833
|
+
// unrelated parent graph/rank checks. Dynamic templates are validated at
|
|
834
|
+
// init; this guards values introduced by {{item}} rendering.
|
|
835
|
+
const spec = { version: 2, tasks: [task] };
|
|
836
|
+
validateTaskWritePolicy(task, spec, issues);
|
|
837
|
+
validateShellTaskConfig(task, spec, issues);
|
|
838
|
+
validateStaticTaskConfig(task, issues);
|
|
839
|
+
validateDecisionGateTaskConfig(task, issues);
|
|
840
|
+
validateRetryPolicyTaskConfig(task, issues);
|
|
841
|
+
validateOutputProtocolTaskConfig(task, issues);
|
|
842
|
+
validateWriterOutcomePolicyTaskConfig(task, issues);
|
|
843
|
+
validateCommandPolicyTaskConfig(task, issues);
|
|
844
|
+
if (issues.length > 0) {
|
|
845
|
+
throw new Error(`invalid materialized dynamic child ${task.id}: ${issues.map((issue) => issue.message).join("; ")}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
730
848
|
export function validateDagSpec(spec) {
|
|
731
849
|
const issues = [];
|
|
732
850
|
if (spec.tasks.length === 0) {
|
|
@@ -778,6 +896,8 @@ export function validateDagSpec(spec) {
|
|
|
778
896
|
validateRetryPolicyTaskConfig(task, issues);
|
|
779
897
|
validateOutputProtocolTaskConfig(task, issues);
|
|
780
898
|
validateWriterOutcomePolicyTaskConfig(task, issues);
|
|
899
|
+
validateCommandPolicyTaskConfig(task, issues);
|
|
900
|
+
validateDynamicChildCommandPolicy(task, issues);
|
|
781
901
|
validateProjectGovernanceTaskConfig(task, spec, issues);
|
|
782
902
|
validateFailureAwareDependsOn(task, spec, issues);
|
|
783
903
|
}
|
|
@@ -141,7 +141,7 @@ Runner / Loop ──(迁移中)──> 逐步改为仅经 Store / Appli
|
|
|
141
141
|
版本化自举包含两个不同的冻结层,不能用其中一个替代另一个:
|
|
142
142
|
|
|
143
143
|
| 层 | 冻结对象 | 生命周期 | 权威证据 |
|
|
144
|
-
|
|
144
|
+
| --- | --- | --- | --- |
|
|
145
145
|
| Controller identity | 实际发布包、entry/realEntry、launch spec、binary hash、portable package fingerprint | Feature/batch/Task/final verification | Worker、Task Pool、batch/Feature、QA/final evidence 中的 `controllerIdentity` |
|
|
146
146
|
| DAG skill snapshot | 某个 run 实际注入 prompt 的 ordered skill profiles、learned flag、budgets 与 dynamic bindings | 单个 DAG run,包括 pause/resume | `state.skillSnapshotRef` + `<runDir>/.runtime/skill-snapshot.json` |
|
|
147
147
|
|
|
@@ -150,7 +150,7 @@ Runner / Loop ──(迁移中)──> 逐步改为仅经 Store / Appli
|
|
|
150
150
|
**规则摘要**
|
|
151
151
|
|
|
152
152
|
| From | May import | Must not import |
|
|
153
|
-
|
|
153
|
+
| ------ | ------------ | ----------------- |
|
|
154
154
|
| `src/commands/**` | application, workflows, infrastructure, task, records, executors, shared | — |
|
|
155
155
|
| `src/workflows/**` | executors, task, records, shared, application(目标) | `src/commands/**` |
|
|
156
156
|
| `src/executors/**` | shared, 外部 SDK | `src/commands/**`, `src/cli/**` |
|
|
@@ -169,7 +169,7 @@ Runner / Loop ──(迁移中)──> 逐步改为仅经 Store / Appli
|
|
|
169
169
|
以下脚本由 `scripts/check-repo.sh` 调用(Phase 0 起):
|
|
170
170
|
|
|
171
171
|
| Script | 检查内容 | 失败条件 |
|
|
172
|
-
|
|
172
|
+
| -------- | ---------- | ---------- |
|
|
173
173
|
| `scripts/check-architecture-boundaries.sh` | workflow/executor forbidden import,及 Worker → CLI/commands/application import | 新的未 allowlist violation |
|
|
174
174
|
| `scripts/check-command-registry-drift.sh` | `command-reference.md` 中的 top-level command vs `src/cli/catalog.ts` | 文档引用未注册 command |
|
|
175
175
|
| `scripts/check-exec-plan-index-sync.sh` | `ai_workspace/loop-agent/exec-plans/{active,completed}` 目录文件 vs 对应 `README.md` 索引 | 任一 plan 文件未在索引登记 |
|
|
@@ -190,23 +190,25 @@ bash scripts/check-skill-entry.sh
|
|
|
190
190
|
|
|
191
191
|
Runtime 变更另需 `npm run typecheck` 及对应 targeted Vitest(见 exec plan 各 Phase 验证关口)。
|
|
192
192
|
|
|
193
|
-
## Client session
|
|
193
|
+
## Client session 恢复边界
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
主会话模型有两类常见缺口:**瞬态 UnknownError**(非标准 502 / `LLMRequestError` / 网络抖动 / 超时经 TypeValidation 落入 UnknownError,内置 APIError 重试不命中)与 **上下文过大**(含内网中文「请求上下文过大」及常见英文 overflow 文案,默认 overflow 识别不全)。loop-agent 通过 **职责分离的项目级产物** 补偿,不修改 OpenCode/Pi 上游,也不引入外部监督器,不改默认模型 `contextWindow`。
|
|
196
196
|
|
|
197
197
|
| 面 | 路径 / 入口 | 边界 |
|
|
198
|
-
|
|
199
|
-
| OpenCode
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| OpenCode transient-retry | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 仅瞬态 UnknownError resume;busy/retry 门控 + backoff;overflow/401/配额/取消/业务标 permanent(`plugin-ignore-permanent-error`),**不** compact |
|
|
200
|
+
| OpenCode overflow-compact | `.opencode/plugins/loop-agent-context-overflow-compact.js`(init surface `generated`) | 仅 context overflow → `session.compact`(无则 `summarize`)+ 有限 `promptAsync` 续接;`MAX_OVERFLOW_RECOVERIES ≤ 2`;session 锁;非 overflow 不动作 |
|
|
201
|
+
| Pi project extension | `.pi/extensions/loop-agent-context-overflow.js`(init surface `generated`) | `message_end` 上将 overflow `errorMessage` 幂等改写为 `context_length_exceeded:` 前缀,触发 Pi 原生 compact/retry;**不**写 `~/.pi` settings / compaction / 模型窗口 |
|
|
200
202
|
| Pi 用户配置 | `~/.pi/agent/settings.json` | **不**进入项目 `.harness/init-surface.json` hash;仅 `--client-recovery=user` 可字段级补缺并原子写;`auto`/`project`/`off` 与 `check-update` 默认零写 home;读取时只有 `ENOENT` 视为缺文件,其他 I/O 错误 fail closed |
|
|
201
|
-
| CLI mode | `--client-recovery=auto\|project\|user\|off`(默认 `auto`) | `auto`/`project`
|
|
202
|
-
| Ownership | recorded sha256 + apply-safe |
|
|
203
|
+
| CLI mode | `--client-recovery=auto\|project\|user\|off`(默认 `auto`) | `auto`/`project` 安装上述三份项目产物;`user` = 三产物 + 显式 Pi retry 合并;`off` 全跳过 |
|
|
204
|
+
| Ownership | recorded sha256 + apply-safe | 三份 generated 缺失可补、与 recorded hash 一致可升级;用户改过 → model merge / human decision,禁止静默覆盖 |
|
|
203
205
|
|
|
204
|
-
实现落点:`src/commands/client-recovery.ts
|
|
206
|
+
实现落点:`src/commands/client-recovery.ts`(共享 `isContextOverflow`、生成器与 install)+ `src/commands/init.ts`(surface 编排与 `written` 投影)。
|
|
205
207
|
|
|
206
208
|
## 演进里程碑
|
|
207
209
|
|
|
208
210
|
| Phase | 边界变化 |
|
|
209
|
-
|
|
211
|
+
| ------- | ---------- |
|
|
210
212
|
| 0(当前) | 文档 + 机器 guard;已知 loop/actions.ts 耦合 advisory |
|
|
211
213
|
| 1 | CLI command definition 单源 |
|
|
212
214
|
| 2 | Skill entry 瘦身 + frontmatter references |
|