@tea-agent/loop-agent 0.27.1 → 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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  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 +143 -38
  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-test-case-checklist.js +201 -8
  18. package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
  19. package/dist/workflows/dag/init-hybrid.js +116 -30
  20. package/dist/workflows/dag/lifecycle.js +33 -2
  21. package/dist/workflows/dag/node-execution.js +11 -5
  22. package/dist/workflows/dag/output-protocol.js +25 -83
  23. package/dist/workflows/dag/report.js +9 -2
  24. package/dist/workflows/dag/rerun-run.js +62 -3
  25. package/dist/workflows/dag/run-store.js +6 -1
  26. package/dist/workflows/dag/runner.js +15 -3
  27. package/dist/workflows/dag/types.js +27 -0
  28. package/dist/workflows/dag/validate.js +121 -1
  29. package/docs/architecture/runtime-boundaries.md +13 -11
  30. package/docs/init-surface.manifest.json +6 -2
  31. package/docs/templates/README.md +9 -1
  32. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
  33. package/docs/templates/frontend-test-dag.json +55 -15
  34. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
  35. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  36. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  37. package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
  38. package/harness.json +1 -1
  39. package/package.json +1 -1
  40. package/skills/loop-agent/SKILL.md +1 -1
  41. package/skills/loop-agent/references/command-reference.md +18 -6
  42. package/skills/playwright-cli/SKILL.md +69 -402
  43. package/skills/playwright-cli/references/tracing.md +3 -137
  44. package/skills/playwright-cli/references/video-recording.md +3 -141
  45. package/skills/playwright-cli-case-generator/SKILL.md +53 -46
@@ -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: located.lifecycle,
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
- export async function prepareActiveRunDir(runDir) {
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, prepareActiveRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
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
- const runDir = activeRunDir;
216
- await prepareActiveRunDir(runDir);
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
- 主会话模型偶尔会返回非标准 502 / `LLMRequestError` / 网络抖动 / 超时响应;OpenCode 可能先解析为 `TypeValidationError` 再落成 `UnknownError`,导致内置 APIError 重试不命中。loop-agent 通过 **项目级 OpenCode 插件补偿** **Pi 用户级配置显式启用** 处理该缺口,不修改 OpenCode/Pi 上游,也不引入外部监督器。
195
+ 主会话模型有两类常见缺口:**瞬态 UnknownError**(非标准 502 / `LLMRequestError` / 网络抖动 / 超时经 TypeValidation 落入 UnknownError,内置 APIError 重试不命中)与 **上下文过大**(含内网中文「请求上下文过大」及常见英文 overflow 文案,默认 overflow 识别不全)。loop-agent 通过 **职责分离的项目级产物** 补偿,不修改 OpenCode/Pi 上游,也不引入外部监督器,不改默认模型 `contextWindow`。
196
196
 
197
197
  | 面 | 路径 / 入口 | 边界 |
198
- |---|---|---|
199
- | OpenCode 项目插件 | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 直接返回真实 `Hooks.event`,分发 `session.error` / `session.status` / `session.idle` / `message.updated`;每个 session 保存 pending error,busy/retry/unknown/status API 失败只暂停恢复,后续 idle 事件重新驱动单一 worker。`client.session.status({ throwOnError: true })` 在退避前后确认 idle,`client.session.promptAsync(..., throwOnError: true)` 仅在 1.18.9 的 204 响应明确返回对象型 `data` 时计入一次 attempt 并续接同一 session;只有成功完成的 assistant message 清零连续失败计数。认证/权限/配额/上下文溢出/取消/业务错误走 `plugin-ignore-permanent-error` |
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` 只装项目插件;`user` = 项目插件 + 显式 Pi 合并;`off` 全跳过 |
202
- | Ownership | recorded sha256 + apply-safe | 插件缺失可补、与 recorded hash 一致可升级;用户改过 → model merge / human decision,禁止静默覆盖 |
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`(纯逻辑与生成器)+ `src/commands/init.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 |
@@ -166,7 +166,9 @@
166
166
  "docs/templates/backend-test-execution.schema.json",
167
167
  "docs/templates/backend-test-result.schema.json",
168
168
  "docs/templates/backend-test-case-manifest.schema.json",
169
- ".opencode/plugins/loop-agent-transient-retry.js"
169
+ ".opencode/plugins/loop-agent-transient-retry.js",
170
+ ".opencode/plugins/loop-agent-context-overflow-compact.js",
171
+ ".pi/extensions/loop-agent-context-overflow.js"
170
172
  ],
171
173
  "initSurface": {
172
174
  "README.md": "managed-block",
@@ -247,7 +249,9 @@
247
249
  "docs/templates/backend-test-execution.schema.json": "copied",
248
250
  "docs/templates/backend-test-result.schema.json": "copied",
249
251
  "docs/templates/backend-test-case-manifest.schema.json": "copied",
250
- ".opencode/plugins/loop-agent-transient-retry.js": "generated"
252
+ ".opencode/plugins/loop-agent-transient-retry.js": "generated",
253
+ ".opencode/plugins/loop-agent-context-overflow-compact.js": "generated",
254
+ ".pi/extensions/loop-agent-context-overflow.js": "generated"
251
255
  },
252
256
  "packageExcluded": [
253
257
  "docs/progress/20*.md",
@@ -37,7 +37,15 @@
37
37
 
38
38
  - `frontend-task-requirement.md`、`frontend-task-constraints.md`、`frontend-design-contract.md` — 前端任务输入与设计合同。
39
39
  - `frontend-implementation-contract.schema.json` — 前端实现合同 schema。
40
- - `frontend-test-dag.json` — frontend-test DAG 模板。
40
+ - `frontend-test-dag.json` — frontend-test DAG 模板;其 deterministic checklist 会拒绝 alternative executable instructions,仅允许 allowlisted `playwright-cli` command。Pi generator/reviser 的 runtime writeSet 仅授权下列目标项目生成路径(不是本仓库文档索引目标):
41
+
42
+ ~~~text
43
+ testcase/frontend/cases/FE-*.md
44
+ testcase/frontend/cases/index.md
45
+ testcase/frontend/cases/manifest.draft.json
46
+ ~~~
47
+
48
+ Pi writer writeSet 不包含 final manifest(文件名 manifest.json)。依赖 checklist 的 exclusive shell materializer 是 final manifest 的唯一写入者,以 temp+rename 原子生成 final,后续 map 只消费该 materializer 输出。
41
49
  - `frontend-test-dag.retrieve-context.prompt.md`、`frontend-test-dag.generate-cases.prompt.md`、`frontend-test-dag.review-cases.prompt.md`、`frontend-test-dag.review-execution.prompt.md`、`frontend-test-dag.retrospect.prompt.md` — 上下文、用例、审查、执行审查和复盘提示。
42
50
  - `frontend-test-case-checklist.md` — 前端测试用例检查清单。
43
51
 
@@ -14,15 +14,22 @@ Use `playwright-cli-case-generator`. Read only `testcase/frontend/rag/context.md
14
14
  - `casePath` must be `testcase/frontend/cases/<caseId>.md`.
15
15
  - `evidenceDir` must be `testcase/frontend/evidence/<caseId>/`.
16
16
 
17
- Each case is independently executable and includes AC mapping (`acIds`), preconditions, cleanup, UI assertions, and its isolated evidence path. Use dimensions `core`, `boundary`, `flow`, or `backend`. Never guess API fields, constraints, SLA, credentials, or unrecorded data.
17
+ Each case is independently executable and includes AC mapping (`acIds`), preconditions, cleanup, a semantic assertion, and its isolated evidence path. Use dimensions `core`, `boundary`, `flow`, or `backend`. Never guess API fields, constraints, SLA, credentials, or unrecorded data. For passed authority, include a successful `playwright-cli find ...` after `open` and before controller post-execution cleanup; `snapshot`, `goto`, `screenshot`, `request`/`console`, and ordinary interactions are not assertions.
18
18
 
19
- Every case must use the **resolved** absolute base URL from `testcase/frontend/rag/context.md` (field `baseUrl` / base URL line). Do not leave a `<base-url>` placeholder. Resolution policy (already applied by retrieve-context): prefer `config.md` frontend URL; else default `http://localhost:5173`.
19
+ Every case must copy the **controller-frozen** absolute base URL supplied by the DAG. `testcase/frontend/rag/context.md` may reference that value, but model-authored context/case prose cannot establish or override the origin. Do not leave a base-url placeholder.
20
20
 
21
- Every case must use this exact browser-start command prefix with that concrete URL:
21
+ Every case must start with `playwright-cli open --browser=chrome --headed` followed by the concrete controller-frozen URL supplied by the DAG. Do not copy an angle-bracket URL placeholder into a generated executable case line.
22
22
 
23
- ```text
24
- playwright-cli open --browser=chrome --headed <resolved-base-url-from-context.md>
25
- ```
23
+ ### Dynamic element refs (hard)
24
+
25
+ - Executable `playwright-cli` lines must never contain angle-bracket dynamic-ref tokens such as `<fresh-ref>` or descriptive `<...>` placeholders.
26
+ - Use only shell-safe documentation placeholders `eX`, `eY`, ... for dynamic element refs. Each placeholder means the real `eNN` ref parsed from the immediately preceding latest `snapshot` output.
27
+ - Before each element interaction, write a fresh `snapshot` step. A later snapshot invalidates earlier refs: do not reuse a stale ref.
28
+ - `eX`/`eY` are documentation placeholders, not literal structured-tool arguments; the browser child resolves them to the current real `eNN` before its tool call.
29
+
30
+ At execution time the browser child translates each `playwright-cli` line into one structured `playwright_cli` tool call (no Bash). Keep Markdown steps as CLI lines so the executor can map them 1:1.
31
+
32
+ For file evidence, use canonical `--filename` only. For screenshots, use `playwright-cli screenshot --filename final.png`; when a real ref from the latest `snapshot` is needed, use `playwright-cli screenshot e5 --filename final.png`. For PDF use `playwright-cli pdf --filename final.pdf`. A `snapshot` without filename is response-only; for a file use `playwright-cli snapshot --filename snapshot.txt`. Never write `playwright-cli screenshot <path>`, use `--path`, `--output`, or `--file`, or put an output path in a positional target slot.
26
33
 
27
34
  Do not put a session flag before `open`. Every later Playwright CLI command must stay in that same default browser session: do **not** emit `-s=<case-id>`, `-s=...`, or assume an undocumented named-session binding.
28
35
 
@@ -45,4 +52,4 @@ Each case must require the executor to persist, even when blocked:
45
52
  - `testcase/frontend/evidence/<case-id>/execution.md`
46
53
  - `testcase/frontend/evidence/<case-id>/case-result.json`
47
54
 
48
- `case-result.json` must be valid JSON containing the matching `caseId`, `status` (`passed`, `failed`, or `blocked`), and an `evidencePaths` array. A blocked result must include a non-empty `blockedReason`, such as `isolated-test-environment-unavailable` or `token-budget-exhausted`, and must never claim or imply a pass. `execution.md` records attempted or blocked steps, base-URL safety decision, fixture/reset and request-observation availability, timestamps, and the evidence-file list. Screenshots, snapshots, traces, videos, and logs are required only when actually available and must stay under the same case evidence directory.
55
+ `case-result.json` must be valid JSON containing the matching `caseId`, `status` (`passed`, `failed`, or `blocked`), and an `evidencePaths` array. A blocked result must include a non-empty `blockedReason`, such as `isolated-test-environment-unavailable` or `token-budget-exhausted`, and must never claim or imply a pass. `execution.md` records attempted or blocked steps, base-URL safety decision, fixture/reset and request-observation availability, timestamps, and the evidence-file list. Available evidence files are required only when actually produced and must stay under the same case evidence directory.