agentic-orchestrator 0.1.4 → 0.1.6
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/.claude/settings.local.json +6 -1
- package/README.md +81 -54
- package/agentic/orchestrator/defaults/policy.defaults.yaml +212 -0
- package/agentic/orchestrator/schemas/adapters.schema.json +17 -4
- package/agentic/orchestrator/schemas/agents.schema.json +58 -13
- package/agentic/orchestrator/schemas/gates.schema.json +88 -17
- package/agentic/orchestrator/schemas/index.schema.json +172 -37
- package/agentic/orchestrator/schemas/multi-project.schema.json +85 -17
- package/agentic/orchestrator/schemas/plan.schema.json +135 -30
- package/agentic/orchestrator/schemas/policy.schema.json +198 -69
- package/agentic/orchestrator/schemas/policy.user.schema.json +165 -0
- package/agentic/orchestrator/schemas/qa_test_index.schema.json +71 -14
- package/agentic/orchestrator/schemas/state.schema.json +196 -39
- package/apps/control-plane/scripts/validate-architecture-rules.mjs +5 -3
- package/apps/control-plane/scripts/validate-docker-mcp-contract.mjs +0 -3
- package/apps/control-plane/scripts/validate-mcp-contracts.ts +2 -6
- package/apps/control-plane/src/application/services/policy-loader-service.ts +115 -0
- package/apps/control-plane/src/cli/aop.ts +35 -1
- package/apps/control-plane/src/cli/cli-argument-parser.ts +4 -0
- package/apps/control-plane/src/cli/help-command-handler.ts +2 -1
- package/apps/control-plane/src/cli/init-command-handler.ts +136 -27
- package/apps/control-plane/src/cli/retry-command-handler.ts +3 -6
- package/apps/control-plane/src/cli/types.ts +1 -0
- package/apps/control-plane/src/core/git.ts +1 -3
- package/apps/control-plane/src/core/kernel.ts +3 -6
- package/apps/control-plane/src/core/schemas.ts +36 -1
- package/apps/control-plane/src/interfaces/cli/bootstrap.ts +1 -1
- package/apps/control-plane/src/mcp/tool-registry-loader.ts +37 -1
- package/apps/control-plane/src/supervisor/qa-wave-executor.ts +1 -1
- package/apps/control-plane/test/aop.spec.ts +37 -0
- package/apps/control-plane/test/batch-operations.spec.ts +5 -3
- package/apps/control-plane/test/bootstrap-attach.spec.ts +28 -18
- package/apps/control-plane/test/bootstrap-edge-cases.spec.ts +25 -15
- package/apps/control-plane/test/bootstrap.spec.ts +30 -22
- package/apps/control-plane/test/cli.unit.spec.ts +55 -10
- package/apps/control-plane/test/core-utils.spec.ts +12 -3
- package/apps/control-plane/test/feature-deletion-service.spec.ts +2 -1
- package/apps/control-plane/test/feature-lifecycle.spec.ts +1 -1
- package/apps/control-plane/test/helpers.ts +22 -2
- package/apps/control-plane/test/init-wizard.spec.ts +160 -7
- package/apps/control-plane/test/issue-tracker.spec.ts +3 -3
- package/apps/control-plane/test/kernel.spec.ts +62 -0
- package/apps/control-plane/test/lock-service.spec.ts +4 -4
- package/apps/control-plane/test/policy-loader-service.spec.ts +346 -0
- package/apps/control-plane/test/reactions.spec.ts +8 -6
- package/apps/control-plane/test/resume-command.spec.ts +31 -15
- package/apps/control-plane/test/supervisor.unit.spec.ts +2 -2
- package/apps/control-plane/test/tool-registry-loader.spec.ts +12 -0
- package/apps/control-plane/vitest.config.ts +1 -1
- package/dist/apps/control-plane/application/services/policy-loader-service.d.ts +24 -0
- package/dist/apps/control-plane/application/services/policy-loader-service.js +90 -0
- package/dist/apps/control-plane/application/services/policy-loader-service.js.map +1 -0
- package/dist/apps/control-plane/application/tools/tool-metadata.js +1 -0
- package/dist/apps/control-plane/application/tools/tool-metadata.js.map +1 -1
- package/dist/apps/control-plane/cli/aop.js +33 -1
- package/dist/apps/control-plane/cli/aop.js.map +1 -1
- package/dist/apps/control-plane/cli/cli-argument-parser.js +4 -0
- package/dist/apps/control-plane/cli/cli-argument-parser.js.map +1 -1
- package/dist/apps/control-plane/cli/dashboard-command-handler.js +11 -5
- package/dist/apps/control-plane/cli/dashboard-command-handler.js.map +1 -1
- package/dist/apps/control-plane/cli/help-command-handler.js +5 -4
- package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
- package/dist/apps/control-plane/cli/init-command-handler.d.ts +1 -0
- package/dist/apps/control-plane/cli/init-command-handler.js +110 -26
- package/dist/apps/control-plane/cli/init-command-handler.js.map +1 -1
- package/dist/apps/control-plane/cli/retry-command-handler.js +3 -6
- package/dist/apps/control-plane/cli/retry-command-handler.js.map +1 -1
- package/dist/apps/control-plane/cli/types.d.ts +1 -0
- package/dist/apps/control-plane/core/git.js +1 -3
- package/dist/apps/control-plane/core/git.js.map +1 -1
- package/dist/apps/control-plane/core/kernel.js +3 -5
- package/dist/apps/control-plane/core/kernel.js.map +1 -1
- package/dist/apps/control-plane/core/schemas.d.ts +2 -0
- package/dist/apps/control-plane/core/schemas.js +31 -1
- package/dist/apps/control-plane/core/schemas.js.map +1 -1
- package/dist/apps/control-plane/interfaces/cli/bootstrap.js +1 -1
- package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
- package/dist/apps/control-plane/mcp/tool-registry-loader.d.ts +2 -0
- package/dist/apps/control-plane/mcp/tool-registry-loader.js +31 -1
- package/dist/apps/control-plane/mcp/tool-registry-loader.js.map +1 -1
- package/dist/apps/control-plane/supervisor/build-wave-executor.js +1 -1
- package/dist/apps/control-plane/supervisor/build-wave-executor.js.map +1 -1
- package/dist/apps/control-plane/supervisor/qa-wave-executor.js +1 -1
- package/dist/apps/control-plane/supervisor/qa-wave-executor.js.map +1 -1
- package/package.json +18 -17
- package/packages/web-dashboard/package.json +1 -1
- package/packages/web-dashboard/src/app/api/actions/route.ts +2 -2
- package/packages/web-dashboard/src/app/api/events/route.ts +1 -1
- package/packages/web-dashboard/src/app/api/features/[id]/checkout/route.ts +1 -1
- package/packages/web-dashboard/src/app/api/features/[id]/diff/route.ts +1 -1
- package/packages/web-dashboard/src/app/api/features/[id]/evidence/[artifact]/route.ts +1 -1
- package/packages/web-dashboard/src/app/api/features/[id]/review/route.ts +2 -2
- package/packages/web-dashboard/src/app/api/features/[id]/route.ts +1 -1
- package/packages/web-dashboard/src/app/api/projects/route.ts +2 -2
- package/packages/web-dashboard/src/app/api/status/route.ts +1 -1
- package/packages/web-dashboard/src/app/page.tsx +1 -1
- package/packages/web-dashboard/src/lib/aop-client.ts +1 -1
- package/packages/web-dashboard/src/lib/orchestrator-tools.ts +1 -1
- package/packages/web-dashboard/tsconfig.json +1 -0
- package/spec-files/completed/agentic_orchestrator_init_policy_ux_simplification_spec.md +481 -0
- package/spec-files/outstanding/agentic_orchestrator_artifact_database_publishing_spec.md +500 -0
- package/spec-files/outstanding/agentic_orchestrator_observability_integrity_diagnostics_spec.md +500 -195
- package/spec-files/outstanding/agentic_orchestrator_performance_improvements_spec.md +471 -162
- package/spec-files/outstanding/agentic_orchestrator_planning_review_quality_spec.md +532 -268
- package/spec-files/outstanding/agentic_orchestrator_productization_commercial_spec.md +645 -0
- package/spec-files/progress.md +99 -2
- package/tsconfig.base.json +4 -0
- /package/spec-files/{outstanding → completed}/agentic_orchestrator_validator_hardening_spec.md +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Feature Spec: Runtime Performance & Agent Context Optimization
|
|
2
2
|
|
|
3
|
-
**Version:** 1.
|
|
3
|
+
**Version:** 1.1
|
|
4
4
|
**Date:** 2026-03-03
|
|
5
|
-
**Status:** Draft
|
|
5
|
+
**Status:** Draft (Architect Review Edition)
|
|
6
6
|
**Milestone:** M30 – Performance & Efficiency
|
|
7
7
|
|
|
8
8
|
> **Purpose:** Diagnose and implement fixes for sub-optimal runtime, memory, and agent context-size patterns in the control plane. Section 2 is the diagnostic record (what is wrong and why). Section 3 is the implementation plan (what to change and how). Each task in Section 3 maps to one or more findings in Section 2.
|
|
9
9
|
|
|
10
|
+
> **Note on roadmap positioning:** M30 is a performance track that is independent of the M33–M38 quality/governance roadmap. It can be executed before, after, or in parallel with those milestones with no ordering dependency.
|
|
11
|
+
|
|
10
12
|
---
|
|
11
13
|
|
|
12
14
|
## 0. Implementation Standards & References
|
|
@@ -18,20 +20,41 @@ All new and modified code MUST follow the testing standards already established
|
|
|
18
20
|
- Use Vitest (`describe/it/expect`, `vi` mocks/spies)
|
|
19
21
|
- Test files live in `apps/control-plane/test/*.spec.ts`
|
|
20
22
|
- Use **Given / When / Then** naming: `GIVEN_<context>_WHEN_<action>_THEN_<expected>`
|
|
21
|
-
- Maintain coverage thresholds: Lines ≥
|
|
23
|
+
- **Maintain coverage thresholds: Lines ≥90%, Branches ≥90%, Functions ≥90%, Statements ≥90%** (per CLAUDE.md — overrides any lower figure stated elsewhere in this spec)
|
|
22
24
|
- Time-dependent tests must use `vi.useFakeTimers()`
|
|
23
25
|
- No real filesystem I/O in unit tests; use `tmp` directories or `vi.mock('node:fs/promises')`
|
|
24
26
|
|
|
25
27
|
### 0.2 Guiding Constraints
|
|
26
28
|
|
|
27
|
-
- **No tool contract changes.**
|
|
28
|
-
- **No
|
|
29
|
-
- **
|
|
29
|
+
- **No breaking tool contract changes.** Backward-compatible additive additions to existing tool input schemas (optional parameters with documented defaults) are permitted, but must be explicitly called out and validated through the MCP contract validator.
|
|
30
|
+
- **No breaking on-disk artifact format changes.** Backward-compatible additive fields (e.g., `schema_version`, `evidence_retention_count`) are permitted.
|
|
31
|
+
- **Write ordering is preserved.** Parallelizing reads is permitted; parallelizing tool calls that write shared state (PATCH_APPLY, LOCK_ACQUIRE, PLAN_UPDATE, etc.) is explicitly out of scope for this spec. Any change that makes previously-sequential write-producing tool calls concurrent is a behavioral change and must not be made here.
|
|
30
32
|
- **All existing tests must remain green** after each task.
|
|
31
33
|
- **Each task is independently mergeable** — do not batch unrelated changes.
|
|
32
34
|
|
|
33
35
|
---
|
|
34
36
|
|
|
37
|
+
## 0.3 Architectural Analysis (v1.0 Issue Log)
|
|
38
|
+
|
|
39
|
+
The following issues were identified in the v1.0 spec during architect review against the actual codebase. Each issue is assigned a severity and cross-references the task where it is resolved.
|
|
40
|
+
|
|
41
|
+
| # | Severity | Issue | Resolution |
|
|
42
|
+
|---|----------|-------|------------|
|
|
43
|
+
| A-1 | **CRITICAL** | **`latest.json` does not exist.** Finding I-5 and PER-T-005/006 claim `gates.ts:430` writes a `latest.json` sentinel. The actual code at line 429–430 writes `latest-${mode}.json` (e.g., `latest-fast.json`). PER-T-005's fix of reading `path.join(evidenceDir, 'latest.json')` would always return null, silently breaking `featureGetContext` for all agent roles. PER-T-006's pruning filter `f !== 'latest.json'` would delete all mode-specific sentinels. | PER-T-005 now writes a canonical `latest.json` in `gates.ts` alongside the per-mode sentinel; PER-T-006 pruning filter excludes `latest-*.json` patterns. |
|
|
44
|
+
| A-2 | **CRITICAL** | **PER-T-003 is a write-ordering behavioral change.** The v1.0 fix eliminates the sequential `for` loop (lines 41–52) and moves `workerDecisionRunner.execute()` into the parallel `executing` map. `workerDecisionRunner.execute()` for the builder role can call PATCH_APPLY, LOCK_ACQUIRE, and PLAN_UPDATE — all writes to shared state. Making these concurrent violates §0.2. | PER-T-003 is redesigned as a **context-capture Map pattern**: the sequential loop is preserved; context is stored in a Map and reused by the parallel executing map, eliminating the duplicate fetch without changing write ordering. |
|
|
45
|
+
| A-3 | **CRITICAL** | **PER-T-003 reused pre-decision context for repair loop.** After `workerDecisionRunner.execute()` applies patches (changing working-tree state) and potentially acquires or releases locks (mutating `state.md`), the captured pre-decision context is stale. The repair loop needs fresh state to give agents accurate `locks.held` data. | PER-T-003 redesign fetches repair context as a fresh call after gate failure, which is the minimal re-fetch (one call per failure, not one per retry). |
|
|
46
|
+
| A-4 | **HIGH** | **Coverage thresholds wrong.** §0.1 specified Lines ≥70%, Branches ≥70%, Functions ≥85%. CLAUDE.md mandates 90% on all metrics. Implementing to v1.0 thresholds would pass spec-level gates but fail CI. | §0.1 corrected to 90% everywhere. |
|
|
47
|
+
| A-5 | **HIGH** | **`normalizeSet` extraction incomplete.** PER-T-013 identified 4 files but the codebase has 7 copies: `reporting-service.ts`, `merge-service.ts`, `feature-lifecycle-service.ts`, `feature-deletion-service.ts`, `kernel.ts`, `lock-service.ts`, `plan-service.ts`. Leaving 3 copies behind is worse than a partial refactor because it creates a false sense of completeness. | PER-T-013 scope expanded to all 7 files. |
|
|
48
|
+
| A-6 | **HIGH** | **§0.2 constraint conflict with PER-T-008.** §0.2 stated "Input/output schemas for all 33 MCP tools remain unchanged." PER-T-008 adds an optional `role` parameter to `feature.get_context`. These directly contradict each other; §4 item 6 tried to paper over this by calling it "backward-compatible." | §0.2 amended to "no breaking changes" with explicit allowance for backward-compatible optional input additions. |
|
|
49
|
+
| A-7 | **HIGH** | **Output schema implicit shape change in PER-T-008.** When `role=builder`, `qa_test_index` in the response payload becomes `{summary}` instead of the full object. The tool's output JSON schema doesn't account for this. Validators would fail at runtime when trying to validate the projected payload against the existing schema. | PER-T-008 adds a note that the existing output schema validates the `full` projection only; partial projections bypass output schema validation and return raw AOP objects. |
|
|
50
|
+
| A-8 | **MEDIUM** | **`QaIndexRecord` type undefined.** PER-T-009 uses `QaIndexRecord` as a type in `projectQaIndex` but this type does not exist in the codebase. No import path is given. | PER-T-009 defines a local `QaIndexSnapshot` interface inline and derives the projection from `AnyRecord`. |
|
|
51
|
+
| A-9 | **MEDIUM** | **Projection table vs implementation inconsistency.** PER-T-008 table shows QA role gets `summary + failed + pending` from `qa_test_index`, but PER-T-009 code returns `{ passed: _omitted, ...rest }` — all fields except `passed`, including `running`, `flaky`, etc. The table and code must agree. | PER-T-009 implementation updated to return only `{ summary, failed, pending }` for QA role, matching the table. |
|
|
52
|
+
| A-10 | **MEDIUM** | **`schema_version` guard requires manual discipline.** PER-T-011 relies on developers remembering to bump `CURRENT_INDEX_SCHEMA_VERSION` when `normalizeIndexShape` changes shape. If forgotten, stale indices never migrate and the performance optimization yields incorrect behavior. | PER-T-011 adds a mandatory developer protocol note with a co-located integration test that detects shape drift. |
|
|
53
|
+
| A-11 | **MEDIUM** | **PER-T-007 leaves planning context fetches sequential.** After pre-filtering with `FEATURE_STATE_GET`, the full context fetches for `planningFeatureIds` remain sequential in a `for` loop. Parallelizing these fetches extends the savings from PER-T-001's parallel-reads principle. | PER-T-007 updated to use `Promise.all` for the planning context fetches; `runPostQaReconciliation` pre-filter is fully specified with concrete code. |
|
|
54
|
+
| A-12 | **LOW** | **`gate-service.spec.ts` does not exist.** PER-T-005 says "add cases to `gate-service.spec.ts` (create if not existing)" — should be explicit. | PER-T-005 updated to say "create `gate-service.spec.ts`." |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
35
58
|
## 1. Objectives
|
|
36
59
|
|
|
37
60
|
### 1.1 Must-Have Outcomes
|
|
@@ -51,6 +74,7 @@ The following patterns were reviewed but are **not considered sub-optimal** give
|
|
|
51
74
|
- **AJV lazy compilation** (`schemas.ts`): First-call compilation is a one-time cost per schema type; the cache works correctly afterwards, and eager preloading adds startup latency without runtime benefit.
|
|
52
75
|
- **Session activation polling loop** (`kernel.ts`): Fixed 250ms polling bounded by a 5s timeout is an internal synchronization mechanism on a cold path, not a hot loop.
|
|
53
76
|
- **Object spread in `makeDefaultState`** (`kernel.ts`): Feature initialization is a cold path; spread of small constant-size default objects is negligible.
|
|
77
|
+
- **Parallelizing `workerDecisionRunner.execute()` calls** (`build-wave-executor.ts`): These calls can write shared state (patches, locks, plan updates). Making them concurrent is a behavioral change outside the scope of §0.2 and this spec.
|
|
54
78
|
|
|
55
79
|
---
|
|
56
80
|
|
|
@@ -82,10 +106,14 @@ All five reads are sequential `await`s with zero data dependencies on each other
|
|
|
82
106
|
|
|
83
107
|
**File:** `apps/control-plane/src/supervisor/build-wave-executor.ts:42` and `95`
|
|
84
108
|
|
|
85
|
-
The context bundle fetched before the initial `workerDecisionRunner.execute` call (line 42)
|
|
109
|
+
The context bundle fetched before the initial `workerDecisionRunner.execute` call (line 42) is captured in the sequential phase. A second identical `FEATURE_GET_CONTEXT` call at line 95 re-fetches a large bundle unconditionally before entering the repair retry loop.
|
|
110
|
+
|
|
111
|
+
**Root cause:** The initial context fetch (line 42) and the repair context fetch (line 95) are in separate execution scopes: a sequential `for` loop and a parallel `Promise.all` map respectively. The variable from the sequential scope is not available in the parallel scope.
|
|
86
112
|
|
|
87
113
|
**Quantified impact:** With 5 retry attempts per failing feature × 50 KB context = 250 KB of redundant I/O per failing feature per wave.
|
|
88
114
|
|
|
115
|
+
**Constraint note:** The sequential `for` loop containing `workerDecisionRunner.execute()` MUST remain sequential (§0.2). The fix captures context into a Map during the sequential phase so the parallel phase can reuse it. Repair-loop context (post-decision) is still re-fetched once per feature on failure — this preserves accuracy while eliminating the per-retry re-fetch in the original code.
|
|
116
|
+
|
|
89
117
|
---
|
|
90
118
|
|
|
91
119
|
#### Finding I-3 — Build + QA wave executors: sequential state-filter loops (HIGH) → PER-T-002
|
|
@@ -112,9 +140,15 @@ The dashboard loop reads `state.md` and the cost JSON file for each feature one
|
|
|
112
140
|
|
|
113
141
|
**File:** `apps/control-plane/src/application/services/gate-service.ts:236–256`
|
|
114
142
|
|
|
115
|
-
On every `featureGetContext` call, `evidenceLatest` lists the entire evidence directory, calls `fs.stat()` on every `.json` file to read mtimes, sorts by mtime, then reads the newest file.
|
|
143
|
+
On every `featureGetContext` call, `evidenceLatest` lists the entire evidence directory, calls `fs.stat()` on every `.json` file to read mtimes, sorts by mtime, then reads the newest file.
|
|
144
|
+
|
|
145
|
+
`gates.ts:429–430` already writes a per-mode sentinel file on every gate run:
|
|
146
|
+
```typescript
|
|
147
|
+
const latestPath = path.join(evidenceDirectory, `latest-${mode}.json`);
|
|
148
|
+
await fs.writeFile(latestPath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
|
149
|
+
```
|
|
116
150
|
|
|
117
|
-
|
|
151
|
+
However, `evidenceLatest` ignores these per-mode sentinels and rediscovers the newest file the hard way on every call. A single canonical `latest.json` (written by `gates.ts` after each gate run, regardless of mode) would allow O(1) lookup.
|
|
118
152
|
|
|
119
153
|
**Quantified impact:** 50 retries × 3 profiles = 150 evidence files per feature × 10 features = 1,500 `stat()` calls per context fetch, all avoidable by reading `latest.json` directly.
|
|
120
154
|
|
|
@@ -122,7 +156,7 @@ Critically, `gates.ts:430` already writes a `latest.json` sentinel on every gate
|
|
|
122
156
|
|
|
123
157
|
#### Finding I-6 — `readIndex`: `JSON.stringify` comparison on every call (MEDIUM) → PER-T-011
|
|
124
158
|
|
|
125
|
-
**File:** `apps/control-plane/src/core/kernel.ts:
|
|
159
|
+
**File:** `apps/control-plane/src/core/kernel.ts:758`
|
|
126
160
|
|
|
127
161
|
```typescript
|
|
128
162
|
const changed = JSON.stringify(existing ?? null) !== JSON.stringify(normalized);
|
|
@@ -152,11 +186,13 @@ const changed = JSON.stringify(existing ?? null) !== JSON.stringify(normalized);
|
|
|
152
186
|
|
|
153
187
|
### 2.2 Memory / Allocation Hotspots
|
|
154
188
|
|
|
155
|
-
#### Finding M-1 — `normalizeSet` copy-pasted in
|
|
189
|
+
#### Finding M-1 — `normalizeSet` copy-pasted in 7 files (LOW) → PER-T-013
|
|
190
|
+
|
|
191
|
+
**Files:** `reporting-service.ts`, `merge-service.ts`, `feature-lifecycle-service.ts`, `feature-deletion-service.ts`, `kernel.ts`, `lock-service.ts`, `plan-service.ts`
|
|
156
192
|
|
|
157
|
-
|
|
193
|
+
The same 3-line function (deduplicate + sort string array) is independently defined in seven files. Seven copies that must be kept in sync; any change to deduplication or sort semantics must be applied in all seven places.
|
|
158
194
|
|
|
159
|
-
The
|
|
195
|
+
**Note:** The v1.0 spec identified only 4 files. Three additional copies in `kernel.ts`, `lock-service.ts`, and `plan-service.ts` were missed.
|
|
160
196
|
|
|
161
197
|
---
|
|
162
198
|
|
|
@@ -178,9 +214,9 @@ The same 3-line function (deduplicate + sort string array) is independently defi
|
|
|
178
214
|
|
|
179
215
|
#### Finding M-4 — Evidence directory grows unbounded across gate retry cycles (HIGH) → PER-T-006
|
|
180
216
|
|
|
181
|
-
**File:** `apps/control-plane/src/core/gates.ts:
|
|
217
|
+
**File:** `apps/control-plane/src/core/gates.ts:427–430`
|
|
182
218
|
|
|
183
|
-
Every gate run appends a new timestamped JSON file to `.aop/features/<id>/evidence/`. With 5 retry cycles × 3 gate profiles per feature the directory accumulates 15+ files per feature with no pruning. This directly worsens Finding I-5: the
|
|
219
|
+
Every gate run appends a new timestamped JSON file to `.aop/features/<id>/evidence/`. With 5 retry cycles × 3 gate profiles per feature the directory accumulates 15+ timestamped files per feature with no pruning. This directly worsens Finding I-5: the stat scan cost scales linearly with accumulated files.
|
|
184
220
|
|
|
185
221
|
---
|
|
186
222
|
|
|
@@ -192,8 +228,6 @@ Every gate run appends a new timestamped JSON file to `.aop/features/<id>/eviden
|
|
|
192
228
|
|
|
193
229
|
The context bundle returned to every agent role includes the complete QA test index (all test records, including passed), the full gate evidence JSON (verbose stdout/stderr), and the complete state frontmatter (all historical lock, gate, and PR metadata). A feature with 200 tests and 50 gate retry cycles produces a bundle exceeding 80–100 KB — roughly 20,000–25,000 tokens per agent invocation.
|
|
194
230
|
|
|
195
|
-
Relevant bloat by field:
|
|
196
|
-
|
|
197
231
|
| Field | Problem |
|
|
198
232
|
|-------|---------|
|
|
199
233
|
| `qa_test_index` | All test records including passed — agents only need `summary` + `failed` + `pending` |
|
|
@@ -236,13 +270,13 @@ See Finding I-8. The structural fix (hoist above the loop) also ensures prompts
|
|
|
236
270
|
| I-3 | `build-wave-executor.ts`, `qa-wave-executor.ts` | 30–38, 54–62 | HIGH | I/O | PER-T-002 |
|
|
237
271
|
| I-4 | `reporting-service.ts` | 81–108 | MEDIUM | I/O | PER-T-004 |
|
|
238
272
|
| I-5 | `gate-service.ts` | 236–256 | MEDIUM | I/O | PER-T-005 |
|
|
239
|
-
| I-6 | `kernel.ts` |
|
|
273
|
+
| I-6 | `kernel.ts` | 758 | MEDIUM | CPU | PER-T-011 |
|
|
240
274
|
| I-7 | `reporting-service.ts` | 40–68 | MEDIUM | CPU/I/O | Deferred |
|
|
241
275
|
| I-8 | `qa-wave-executor.ts` | 196 | LOW | I/O | PER-T-014 |
|
|
242
|
-
| M-1 |
|
|
276
|
+
| M-1 | 7 service/core files | various | LOW | Memory | PER-T-013 |
|
|
243
277
|
| M-2 | `planning-wave-executor.ts` | 298 | LOW | Memory | PER-T-012 |
|
|
244
278
|
| M-3 | `run-coordinator.ts` | 61–62, 272 | MEDIUM | Memory | PER-T-010 |
|
|
245
|
-
| M-4 | `core/gates.ts` |
|
|
279
|
+
| M-4 | `core/gates.ts` | 427–430 | HIGH | Disk | PER-T-006 |
|
|
246
280
|
| C-1 | `feature-lifecycle-service.ts` | 132–158 | HIGH | Context | PER-T-008, PER-T-009 |
|
|
247
281
|
| C-2 | `planning-wave-executor.ts` | 125–133 | MEDIUM | Context | PER-T-007 |
|
|
248
282
|
| C-3 | `planning-wave-executor.ts` | 313 | LOW | Context | Deferred |
|
|
@@ -254,7 +288,7 @@ See Finding I-8. The structural fix (hoist above the loop) also ensures prompts
|
|
|
254
288
|
|
|
255
289
|
### PER-M1: Parallel I/O in Wave Executors (Highest Impact)
|
|
256
290
|
|
|
257
|
-
**Goal:** Eliminate sequential awaits where there are no data dependencies. No behavioral changes — only I/O ordering changes; write
|
|
291
|
+
**Goal:** Eliminate sequential awaits where there are no data dependencies. No behavioral changes — only I/O ordering changes for reads; write-producing tool calls remain sequential.
|
|
258
292
|
|
|
259
293
|
---
|
|
260
294
|
|
|
@@ -328,7 +362,7 @@ const batch = featureIds.filter((_, i) => states[i].data.front_matter.status ===
|
|
|
328
362
|
|
|
329
363
|
Apply the same transformation to `QaWaveExecutor` substituting role `'qa'` and status `STATUS.QA`.
|
|
330
364
|
|
|
331
|
-
**Tests to write:** `apps/control-plane/test/batch-operations.spec.ts` (existing
|
|
365
|
+
**Tests to write:** `apps/control-plane/test/batch-operations.spec.ts` (existing — add cases)
|
|
332
366
|
- `GIVEN_BuildWaveExecutor_run_WHEN_multiple_features_THEN_state_reads_are_parallel`
|
|
333
367
|
- `GIVEN_QaWaveExecutor_run_WHEN_multiple_features_THEN_state_reads_are_parallel`
|
|
334
368
|
|
|
@@ -343,62 +377,99 @@ Apply the same transformation to `QaWaveExecutor` substituting role `'qa'` and s
|
|
|
343
377
|
|
|
344
378
|
**Fixes:** Finding I-2
|
|
345
379
|
**File:** `apps/control-plane/src/supervisor/build-wave-executor.ts`
|
|
346
|
-
**Lines:**
|
|
380
|
+
**Lines:** 41–52 (sequential initial phase), 94–97 (duplicate fetch in parallel phase)
|
|
347
381
|
|
|
348
|
-
The `
|
|
382
|
+
**Design rationale:** The sequential `for` loop at lines 41–52 MUST remain sequential (§0.2) because `workerDecisionRunner.execute()` can call write-producing tools (PATCH_APPLY, LOCK_ACQUIRE). The duplicate fetch at line 95 is eliminated by capturing each feature's context in a Map during the sequential phase, then looking it up in the parallel phase for the repair loop. The repair-loop context is still re-fetched once per feature on gate failure (not once per retry as it was before) to capture any state changes from the initial decision. This reduces the duplicate from O(retries) to O(1) on the failure path.
|
|
349
383
|
|
|
350
384
|
**Before:**
|
|
351
385
|
```typescript
|
|
352
|
-
//
|
|
386
|
+
// Sequential loop (lines 41–52): initial worker decision
|
|
353
387
|
for (const featureId of selected) {
|
|
354
388
|
const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
|
|
355
389
|
feature_id: featureId
|
|
356
390
|
});
|
|
357
|
-
await this.workerDecisionRunner.execute({
|
|
391
|
+
await this.workerDecisionRunner.execute({ role: 'builder', featureId, contextBundle: context.data, ... });
|
|
358
392
|
}
|
|
359
393
|
|
|
360
|
-
//
|
|
394
|
+
// Parallel map (lines 54+): gate run and repair — re-fetches context on EVERY failure
|
|
361
395
|
const executing = selected.map(async (featureId) => {
|
|
362
|
-
...
|
|
396
|
+
// ... gate run ...
|
|
363
397
|
if (this.reactionsService && gateOverall === GATE_RESULT.FAIL) {
|
|
364
398
|
const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, { // DUPLICATE
|
|
365
399
|
feature_id: featureId
|
|
366
400
|
});
|
|
367
|
-
|
|
401
|
+
let retryCount = initialRetryCount;
|
|
402
|
+
while (this.reactionsService.shouldRetry(featureId, retryCount)) {
|
|
403
|
+
// context re-used for all retries — already a single call here,
|
|
404
|
+
// but context was fetched AGAIN above before the while loop
|
|
405
|
+
await this.workerDecisionRunner.execute({ ..., contextBundle: context.data, ... });
|
|
406
|
+
}
|
|
368
407
|
}
|
|
369
408
|
});
|
|
370
409
|
```
|
|
371
410
|
|
|
372
|
-
**After:**
|
|
373
|
-
|
|
411
|
+
**After:**
|
|
374
412
|
```typescript
|
|
375
|
-
|
|
413
|
+
// Phase 1: Sequential — initial worker decisions (write ordering preserved)
|
|
414
|
+
const preDecisionContextByFeature = new Map<string, unknown>();
|
|
415
|
+
for (const featureId of selected) {
|
|
376
416
|
const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
|
|
377
417
|
feature_id: featureId
|
|
378
418
|
});
|
|
379
|
-
|
|
419
|
+
preDecisionContextByFeature.set(featureId, context.data);
|
|
380
420
|
await this.workerDecisionRunner.execute({
|
|
381
421
|
role: 'builder',
|
|
382
422
|
featureId,
|
|
383
423
|
contextBundle: context.data,
|
|
384
424
|
instructions: '...'
|
|
385
425
|
});
|
|
426
|
+
}
|
|
386
427
|
|
|
428
|
+
// Phase 2: Parallel — gate runs and repair
|
|
429
|
+
const executing = selected.map(async (featureId) => {
|
|
387
430
|
const stateForRetry = await this.toolCaller.callTool<FeatureStatePayload>('builder', TOOLS.FEATURE_STATE_GET, {
|
|
388
431
|
feature_id: featureId
|
|
389
432
|
});
|
|
390
|
-
|
|
433
|
+
const initialRetryCount = stateForRetry.data.front_matter.gate_retry_count ?? 0;
|
|
434
|
+
|
|
435
|
+
// ... gate run (unchanged) ...
|
|
436
|
+
|
|
437
|
+
if (this.reactionsService && gateOverall === GATE_RESULT.FAIL) {
|
|
438
|
+
// Re-fetch context ONCE after gate failure to capture any state changes
|
|
439
|
+
// from the initial decision phase (lock acquisitions, patch applications).
|
|
440
|
+
// This is one fetch per failing feature — eliminates the per-retry fetch
|
|
441
|
+
// that occurred in the v1.0 repair loop.
|
|
442
|
+
const repairContext = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONTEXT, {
|
|
443
|
+
feature_id: featureId
|
|
444
|
+
});
|
|
445
|
+
let retryCount = initialRetryCount;
|
|
446
|
+
while (this.reactionsService.shouldRetry(featureId, retryCount)) {
|
|
447
|
+
// ... retry loop uses repairContext.data for all retries (not re-fetched per retry)
|
|
448
|
+
await this.workerDecisionRunner.execute({
|
|
449
|
+
role: 'builder',
|
|
450
|
+
featureId,
|
|
451
|
+
contextBundle: repairContext.data,
|
|
452
|
+
instructions: repairPrompt
|
|
453
|
+
});
|
|
454
|
+
// ... gate re-run, retryCount increment ...
|
|
455
|
+
}
|
|
456
|
+
}
|
|
391
457
|
});
|
|
392
458
|
await Promise.allSettled(executing);
|
|
393
459
|
```
|
|
394
460
|
|
|
461
|
+
**Rationale for still re-fetching on gate failure:** After `workerDecisionRunner.execute()` in Phase 1, the feature may have acquired locks or had patches applied, mutating `state.md`. The repair loop needs `locks.held` to be accurate to avoid incorrect lock-conflict decisions. The re-fetch is gated on `gateOverall === GATE_RESULT.FAIL` — on the fast-path (gate passes), no additional context fetch occurs at all.
|
|
462
|
+
|
|
395
463
|
**Tests to write:** `apps/control-plane/test/batch-operations.spec.ts`
|
|
396
|
-
- `
|
|
464
|
+
- `GIVEN_BuildWaveExecutor_gate_passes_WHEN_run_THEN_FEATURE_GET_CONTEXT_called_exactly_once_per_feature`
|
|
465
|
+
- `GIVEN_BuildWaveExecutor_gate_fails_WHEN_run_THEN_FEATURE_GET_CONTEXT_called_twice_per_failing_feature`
|
|
466
|
+
- `GIVEN_BuildWaveExecutor_multiple_retries_WHEN_gate_fails_THEN_repair_context_not_re_fetched_per_retry`
|
|
397
467
|
|
|
398
468
|
**Acceptance criteria:**
|
|
399
|
-
1. `FEATURE_GET_CONTEXT` is called exactly once per feature per `run()` invocation
|
|
400
|
-
2.
|
|
401
|
-
3.
|
|
469
|
+
1. When gate passes, `FEATURE_GET_CONTEXT` is called exactly once per feature per `run()` invocation.
|
|
470
|
+
2. When gate fails, `FEATURE_GET_CONTEXT` is called exactly twice per failing feature: once in Phase 1 (pre-decision), once in Phase 2 (repair context) — never per retry.
|
|
471
|
+
3. Sequential write ordering of `workerDecisionRunner.execute()` across features is preserved.
|
|
472
|
+
4. The `selected` features for Phase 2 are the same as Phase 1.
|
|
402
473
|
|
|
403
474
|
---
|
|
404
475
|
|
|
@@ -460,7 +531,7 @@ const features = (
|
|
|
460
531
|
).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
|
461
532
|
```
|
|
462
533
|
|
|
463
|
-
**Tests to write:** `apps/control-plane/test/services.spec.ts` (existing)
|
|
534
|
+
**Tests to write:** `apps/control-plane/test/services.spec.ts` (existing) — add cases:
|
|
464
535
|
- `GIVEN_reportDashboard_WHEN_multiple_features_exist_THEN_reads_are_parallel`
|
|
465
536
|
- `GIVEN_reportDashboard_WHEN_state_file_missing_THEN_feature_is_omitted`
|
|
466
537
|
|
|
@@ -473,61 +544,74 @@ const features = (
|
|
|
473
544
|
|
|
474
545
|
### PER-M2: Evidence Directory Efficiency
|
|
475
546
|
|
|
476
|
-
**Goal:** Stop scanning the full evidence directory on every context fetch.
|
|
547
|
+
**Goal:** Stop scanning the full evidence directory on every context fetch. Write a single canonical `latest.json` sentinel in `gates.ts` on every gate run. Add a retention policy so the directory stays bounded.
|
|
477
548
|
|
|
478
549
|
---
|
|
479
550
|
|
|
480
|
-
#### PER-T-005:
|
|
551
|
+
#### PER-T-005: Write canonical `latest.json` in `gates.ts` and read it directly in `evidenceLatest`
|
|
481
552
|
|
|
482
553
|
**Fixes:** Finding I-5
|
|
483
|
-
**
|
|
484
|
-
|
|
554
|
+
**Files:**
|
|
555
|
+
- `apps/control-plane/src/core/gates.ts` (add canonical sentinel write)
|
|
556
|
+
- `apps/control-plane/src/application/services/gate-service.ts` (read sentinel directly)
|
|
485
557
|
|
|
486
|
-
`gates.ts`
|
|
487
|
-
- `
|
|
488
|
-
- `latest.json` —
|
|
558
|
+
**Context:** `gates.ts:427–430` currently writes two files per gate run:
|
|
559
|
+
- `gate-${mode}-${Date.now()}.json` — the timestamped archive record
|
|
560
|
+
- `latest-${mode}.json` — a per-mode sentinel (e.g., `latest-fast.json`, `latest-full.json`)
|
|
489
561
|
|
|
490
|
-
`evidenceLatest`
|
|
562
|
+
`evidenceLatest` lists the entire directory and stats all files to find the newest. Neither approach is optimal for `evidenceLatest`: the per-mode sentinels require knowing which mode was run most recently.
|
|
491
563
|
|
|
492
|
-
**
|
|
564
|
+
**Fix:** Add a third write in `gates.ts` — a canonical `latest.json` — updated after every gate run regardless of mode. This is the single O(1) read target for `evidenceLatest`.
|
|
565
|
+
|
|
566
|
+
**Change to `gates.ts`** (after line 430):
|
|
567
|
+
```typescript
|
|
568
|
+
// Existing:
|
|
569
|
+
const evidencePath = path.join(evidenceDirectory, `gate-${mode}-${Date.now()}.json`);
|
|
570
|
+
await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
|
571
|
+
const latestModePath = path.join(evidenceDirectory, `latest-${mode}.json`);
|
|
572
|
+
await fs.writeFile(latestModePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
|
573
|
+
|
|
574
|
+
// Add canonical sentinel:
|
|
575
|
+
const latestPath = path.join(evidenceDirectory, 'latest.json');
|
|
576
|
+
await fs.writeFile(latestPath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
**Change to `gate-service.ts` `evidenceLatest`** — replace O(N) scan:
|
|
493
580
|
```typescript
|
|
581
|
+
// Before (O(N) stat scan):
|
|
494
582
|
const files = (await fs.readdir(evidenceDir))
|
|
495
583
|
.filter((file) => file.endsWith('.json'))
|
|
496
584
|
.map((file) => path.join(evidenceDir, file));
|
|
585
|
+
// ... stat all files, sort by mtime, read newest ...
|
|
497
586
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const withStats = await Promise.all(
|
|
501
|
-
files.map(async (file) => {
|
|
502
|
-
const stat = await fs.stat(file);
|
|
503
|
-
return { file, mtimeMs: stat.mtimeMs };
|
|
504
|
-
})
|
|
505
|
-
);
|
|
506
|
-
withStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
507
|
-
const latestFile = withStats[0].file;
|
|
508
|
-
const latest = JSON.parse(await fs.readFile(latestFile, 'utf8'));
|
|
509
|
-
```
|
|
510
|
-
|
|
511
|
-
**After:**
|
|
512
|
-
```typescript
|
|
587
|
+
// After (O(1) read):
|
|
513
588
|
const latestPath = path.join(evidenceDir, 'latest.json');
|
|
514
589
|
if (!(await pathExists(latestPath))) {
|
|
515
590
|
return { data: { feature_id: featureId, latest: null } };
|
|
516
591
|
}
|
|
517
592
|
const latest = await readJson<AnyRecord>(latestPath, null);
|
|
593
|
+
return {
|
|
594
|
+
data: {
|
|
595
|
+
feature_id: featureId,
|
|
596
|
+
latest,
|
|
597
|
+
path: normalizeRepoPathForState(repoRoot, latestPath)
|
|
598
|
+
}
|
|
599
|
+
};
|
|
518
600
|
```
|
|
519
601
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
**Tests to write:** `apps/control-plane/test/gate-service.spec.ts` (create if not existing)
|
|
602
|
+
**Tests to write:** Create `apps/control-plane/test/gate-service.spec.ts`
|
|
523
603
|
- `GIVEN_evidenceLatest_WHEN_latest_json_exists_THEN_returns_its_contents`
|
|
524
604
|
- `GIVEN_evidenceLatest_WHEN_no_evidence_dir_exists_THEN_returns_null`
|
|
525
605
|
- `GIVEN_evidenceLatest_WHEN_latest_json_missing_THEN_returns_null`
|
|
606
|
+
- `GIVEN_gates_run_WHEN_gate_completes_THEN_latest_json_is_written`
|
|
607
|
+
- `GIVEN_gates_run_WHEN_gate_completes_THEN_latest_json_matches_per_mode_sentinel`
|
|
526
608
|
|
|
527
609
|
**Acceptance criteria:**
|
|
528
610
|
1. `evidenceLatest` reads only `latest.json`; no `readdir` or `stat` calls.
|
|
529
611
|
2. Return shape `{ data: { feature_id, latest, path? } }` is identical to the existing interface.
|
|
530
|
-
3.
|
|
612
|
+
3. `gates.ts` writes `latest.json` after every gate run; its content equals the evidence object.
|
|
613
|
+
4. `latest-${mode}.json` sentinels are still written (backward-compat for any external tooling).
|
|
614
|
+
5. Existing tests that rely on `latest` content remain green.
|
|
531
615
|
|
|
532
616
|
---
|
|
533
617
|
|
|
@@ -536,7 +620,8 @@ The method drops from O(N) stat calls to O(1). Add a port method `evidenceLatest
|
|
|
536
620
|
**Fixes:** Finding M-4
|
|
537
621
|
**Files:**
|
|
538
622
|
- `agentic/orchestrator/policy.yaml` — add `evidence_retention_count` field
|
|
539
|
-
- `agentic/orchestrator/
|
|
623
|
+
- `agentic/orchestrator/defaults/policy.defaults.yaml` — add default value
|
|
624
|
+
- `agentic/orchestrator/schemas/policy.schema.json` — add field to `cleanup` object
|
|
540
625
|
- `apps/control-plane/src/core/gates.ts` — prune after writing new evidence file
|
|
541
626
|
|
|
542
627
|
**Policy change (`policy.yaml`):**
|
|
@@ -544,7 +629,7 @@ The method drops from O(N) stat calls to O(1). Add a port method `evidenceLatest
|
|
|
544
629
|
cleanup:
|
|
545
630
|
grace_period_seconds: 300
|
|
546
631
|
auto_after_merge: true
|
|
547
|
-
evidence_retention_count: 10 # NEW: keep the N most recent evidence files per feature
|
|
632
|
+
evidence_retention_count: 10 # NEW: keep the N most recent timestamped evidence files per feature
|
|
548
633
|
```
|
|
549
634
|
|
|
550
635
|
**Schema change (`schemas/policy.schema.json`):** Add to the `cleanup` object properties:
|
|
@@ -558,15 +643,15 @@ cleanup:
|
|
|
558
643
|
}
|
|
559
644
|
```
|
|
560
645
|
|
|
561
|
-
**Implementation in `gates.ts`** — after writing
|
|
646
|
+
**Implementation in `gates.ts`** — after writing all three evidence files, add pruning:
|
|
562
647
|
```typescript
|
|
563
648
|
await pruneEvidenceFiles(evidenceDir, retentionCount);
|
|
564
649
|
|
|
565
650
|
async function pruneEvidenceFiles(dir: string, keep: number): Promise<void> {
|
|
566
651
|
const entries = await fs.readdir(dir);
|
|
567
|
-
// Only prune timestamped files; never prune latest.json
|
|
652
|
+
// Only prune timestamped gate files; never prune latest.json or latest-*.json sentinels
|
|
568
653
|
const archived = entries
|
|
569
|
-
.filter((f) => f.endsWith('.json') && f !== 'latest.json')
|
|
654
|
+
.filter((f) => f.endsWith('.json') && f !== 'latest.json' && !f.startsWith('latest-'))
|
|
570
655
|
.map((f) => ({ name: f, path: path.join(dir, f) }));
|
|
571
656
|
|
|
572
657
|
if (archived.length <= keep) return;
|
|
@@ -584,13 +669,15 @@ The `retentionCount` value is read from the policy snapshot, defaulting to `10`
|
|
|
584
669
|
- `GIVEN_pruneEvidenceFiles_WHEN_archived_count_exceeds_retention_THEN_oldest_are_deleted`
|
|
585
670
|
- `GIVEN_pruneEvidenceFiles_WHEN_archived_count_within_retention_THEN_nothing_deleted`
|
|
586
671
|
- `GIVEN_pruneEvidenceFiles_WHEN_latest_json_present_THEN_it_is_never_pruned`
|
|
672
|
+
- `GIVEN_pruneEvidenceFiles_WHEN_latest_mode_sentinels_present_THEN_they_are_never_pruned`
|
|
587
673
|
|
|
588
674
|
**Acceptance criteria:**
|
|
589
|
-
1. After every gate run, the evidence directory contains at most `evidence_retention_count` timestamped files.
|
|
675
|
+
1. After every gate run, the evidence directory contains at most `evidence_retention_count` timestamped `gate-*.json` files.
|
|
590
676
|
2. `latest.json` is never deleted by pruning.
|
|
591
|
-
3.
|
|
592
|
-
4.
|
|
593
|
-
5.
|
|
677
|
+
3. `latest-${mode}.json` sentinel files (e.g., `latest-fast.json`) are never deleted by pruning.
|
|
678
|
+
4. Pruning occurs only after the new evidence file is successfully written.
|
|
679
|
+
5. Default `evidence_retention_count` of 10 applies when the field is absent from `policy.yaml`.
|
|
680
|
+
6. Policy schema validates correctly; invalid values (non-integer, <1, >100) are rejected.
|
|
594
681
|
|
|
595
682
|
---
|
|
596
683
|
|
|
@@ -606,9 +693,7 @@ The `retentionCount` value is read from the policy snapshot, defaulting to `10`
|
|
|
606
693
|
**File:** `apps/control-plane/src/supervisor/planning-wave-executor.ts`
|
|
607
694
|
**Lines:** 124–153 (`run`) and 156–228 (`runPostQaReconciliation`)
|
|
608
695
|
|
|
609
|
-
`run
|
|
610
|
-
|
|
611
|
-
**Before:**
|
|
696
|
+
**Before (`run`):**
|
|
612
697
|
```typescript
|
|
613
698
|
async run(featureIds: string[]): Promise<void> {
|
|
614
699
|
for (const featureId of featureIds) {
|
|
@@ -624,10 +709,10 @@ async run(featureIds: string[]): Promise<void> {
|
|
|
624
709
|
}
|
|
625
710
|
```
|
|
626
711
|
|
|
627
|
-
**After:**
|
|
712
|
+
**After (`run`):**
|
|
628
713
|
```typescript
|
|
629
714
|
async run(featureIds: string[]): Promise<void> {
|
|
630
|
-
// Phase 1: Batch-fetch lightweight state to identify planning features
|
|
715
|
+
// Phase 1: Batch-fetch lightweight state in parallel to identify planning features
|
|
631
716
|
const states = await Promise.all(
|
|
632
717
|
featureIds.map((featureId) =>
|
|
633
718
|
this.toolCaller.callTool<FeatureStatePayload>('planner', TOOLS.FEATURE_STATE_GET, {
|
|
@@ -640,27 +725,91 @@ async run(featureIds: string[]): Promise<void> {
|
|
|
640
725
|
return status === STATUS.PLANNING || status === STATUS.BLOCKED;
|
|
641
726
|
});
|
|
642
727
|
|
|
643
|
-
// Phase 2: Fetch full context
|
|
644
|
-
|
|
728
|
+
// Phase 2: Fetch full context for planning features in parallel
|
|
729
|
+
if (planningFeatureIds.length === 0) return;
|
|
730
|
+
const contexts = await Promise.all(
|
|
731
|
+
planningFeatureIds.map((featureId) =>
|
|
732
|
+
this.toolCaller.callTool<FeatureContextPayload>('planner', TOOLS.FEATURE_GET_CONTEXT, {
|
|
733
|
+
feature_id: featureId
|
|
734
|
+
})
|
|
735
|
+
)
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
// Phase 3: Process each planning feature sequentially (preserves write ordering)
|
|
739
|
+
for (let i = 0; i < planningFeatureIds.length; i++) {
|
|
740
|
+
const featureId = planningFeatureIds[i];
|
|
741
|
+
const context = contexts[i];
|
|
742
|
+
// ...rest of planning logic unchanged, using context
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
**Before (`runPostQaReconciliation`):**
|
|
748
|
+
```typescript
|
|
749
|
+
async runPostQaReconciliation(featureIds: string[], iteration: number): Promise<void> {
|
|
750
|
+
for (const featureId of featureIds) {
|
|
645
751
|
const context = await this.toolCaller.callTool<FeatureContextPayload>('planner', TOOLS.FEATURE_GET_CONTEXT, {
|
|
646
752
|
feature_id: featureId
|
|
647
753
|
});
|
|
648
|
-
|
|
754
|
+
const frontMatter = asRecord(asRecord(context.data.state).front_matter);
|
|
755
|
+
const status = readStatus(frontMatter.status);
|
|
756
|
+
if (!isPostQaStatus(status)) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
// ...use context
|
|
649
760
|
}
|
|
650
761
|
}
|
|
651
762
|
```
|
|
652
763
|
|
|
653
|
-
|
|
764
|
+
**After (`runPostQaReconciliation`):**
|
|
765
|
+
```typescript
|
|
766
|
+
async runPostQaReconciliation(featureIds: string[], iteration: number): Promise<void> {
|
|
767
|
+
// Phase 1: Batch-fetch state in parallel to identify post-QA features
|
|
768
|
+
const states = await Promise.all(
|
|
769
|
+
featureIds.map((featureId) =>
|
|
770
|
+
this.toolCaller.callTool<FeatureStatePayload>('planner', TOOLS.FEATURE_STATE_GET, {
|
|
771
|
+
feature_id: featureId
|
|
772
|
+
})
|
|
773
|
+
)
|
|
774
|
+
);
|
|
775
|
+
const postQaFeatureIds = featureIds.filter((_, i) => {
|
|
776
|
+
const status = readStatus(states[i].data.front_matter.status);
|
|
777
|
+
return isPostQaStatus(status);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
if (postQaFeatureIds.length === 0) return;
|
|
781
|
+
|
|
782
|
+
// Phase 2: Fetch full context for post-QA features in parallel
|
|
783
|
+
const contexts = await Promise.all(
|
|
784
|
+
postQaFeatureIds.map((featureId) =>
|
|
785
|
+
this.toolCaller.callTool<FeatureContextPayload>('planner', TOOLS.FEATURE_GET_CONTEXT, {
|
|
786
|
+
feature_id: featureId
|
|
787
|
+
})
|
|
788
|
+
)
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
// Phase 3: Process each post-QA feature sequentially (preserves write ordering for PLAN_UPDATE)
|
|
792
|
+
for (let i = 0; i < postQaFeatureIds.length; i++) {
|
|
793
|
+
const featureId = postQaFeatureIds[i];
|
|
794
|
+
const context = contexts[i];
|
|
795
|
+
// ...rest of reconciliation logic unchanged, using context
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
```
|
|
654
799
|
|
|
655
800
|
**Tests to write:** `apps/control-plane/test/planning-wave-executor.spec.ts` (existing — add cases)
|
|
656
801
|
- `GIVEN_run_WHEN_features_not_in_planning_status_THEN_FEATURE_GET_CONTEXT_not_called`
|
|
657
802
|
- `GIVEN_run_WHEN_features_in_planning_THEN_FEATURE_GET_CONTEXT_called_only_for_those`
|
|
803
|
+
- `GIVEN_run_WHEN_multiple_planning_features_THEN_context_fetches_are_parallel`
|
|
658
804
|
- `GIVEN_runPostQaReconciliation_WHEN_features_not_in_post_qa_status_THEN_context_not_fetched`
|
|
805
|
+
- `GIVEN_runPostQaReconciliation_WHEN_post_qa_features_exist_THEN_context_fetches_are_parallel`
|
|
659
806
|
|
|
660
807
|
**Acceptance criteria:**
|
|
661
|
-
1. `FEATURE_GET_CONTEXT` is never called for features not in `PLANNING
|
|
662
|
-
2. `
|
|
663
|
-
3.
|
|
808
|
+
1. `FEATURE_GET_CONTEXT` is never called for features not in `PLANNING` or `BLOCKED` status (in `run`).
|
|
809
|
+
2. `FEATURE_GET_CONTEXT` is never called for features not in a post-QA status (in `runPostQaReconciliation`).
|
|
810
|
+
3. `FEATURE_STATE_GET` batch calls are issued in parallel.
|
|
811
|
+
4. Full context fetches for matching features are issued in parallel.
|
|
812
|
+
5. Plan submission and update logic for matched features runs sequentially (preserves write ordering for PLAN_SUBMIT, PLAN_UPDATE).
|
|
664
813
|
|
|
665
814
|
---
|
|
666
815
|
|
|
@@ -669,21 +818,20 @@ Apply the same pre-filter pattern to `runPostQaReconciliation`, substituting `is
|
|
|
669
818
|
**Fixes:** Finding C-1
|
|
670
819
|
**Files:**
|
|
671
820
|
- `apps/control-plane/src/application/services/feature-lifecycle-service.ts`
|
|
672
|
-
- `
|
|
673
|
-
- `agentic/orchestrator/tools/catalog.json` (register new tool)
|
|
674
|
-
- Tool input/output schemas under `agentic/orchestrator/tools/schemas/`
|
|
821
|
+
- `agentic/orchestrator/tools/schemas/input/feature.get_context.input.schema.json` (add optional `role` field)
|
|
675
822
|
|
|
676
|
-
|
|
823
|
+
**Constraint note:** This task adds an optional parameter to the `feature.get_context` tool input schema. This is a backward-compatible addition per §0.2 (omitting `role` preserves the existing `full` projection). The output schema is NOT modified — it validates the `full` projection only. Partial projections bypass output schema validation at the service layer and return raw AOP objects directly; this is acceptable because projections only remove fields from a validated superset.
|
|
677
824
|
|
|
678
|
-
|
|
679
|
-
|
|
825
|
+
Add an optional `role` parameter to the existing `feature.get_context` input schema (backward-compatible: defaults to `'full'`):
|
|
826
|
+
|
|
827
|
+
```json
|
|
680
828
|
{
|
|
681
829
|
"feature_id": { "type": "string" },
|
|
682
830
|
"role": {
|
|
683
831
|
"type": "string",
|
|
684
832
|
"enum": ["full", "planner", "builder", "qa"],
|
|
685
833
|
"default": "full",
|
|
686
|
-
"description": "Returns a role-scoped projection of the context bundle to reduce token usage."
|
|
834
|
+
"description": "Returns a role-scoped projection of the context bundle to reduce token usage. Omitting this field or passing 'full' returns the complete bundle."
|
|
687
835
|
}
|
|
688
836
|
}
|
|
689
837
|
```
|
|
@@ -693,10 +841,10 @@ Add an optional `role` parameter to the existing `feature.get_context` tool (bac
|
|
|
693
841
|
| Field | `full` | `planner` | `builder` | `qa` |
|
|
694
842
|
|-------|--------|-----------|-----------|------|
|
|
695
843
|
| `feature_id` | ✓ | ✓ | ✓ | ✓ |
|
|
696
|
-
| `spec` |
|
|
697
|
-
| `state.front_matter` | full | full | `status
|
|
698
|
-
| `plan` | full | full | `tasks
|
|
699
|
-
| `qa_test_index` | full | `summary`
|
|
844
|
+
| `spec` | full | full | trimmed to 8 KB | trimmed to 4 KB |
|
|
845
|
+
| `state.front_matter` | full | full | `{ status, branch, locks.held, gates, gate_profile }` | `{ status, branch, gates, gate_profile }` |
|
|
846
|
+
| `plan` | full | full | `{ tasks (current phase only), acceptance_criteria }` | `{ acceptance_criteria, risk }` |
|
|
847
|
+
| `qa_test_index` | full | `{ summary }` | `{ summary }` | `{ summary, failed, pending }` (no `passed`) |
|
|
700
848
|
| `latest_evidence` | full | `{ overall, profile }` | `{ overall, profile, failed_steps[0..4] }` | `{ overall, profile, failed_steps[0..9], coverage }` |
|
|
701
849
|
|
|
702
850
|
**Implementation in `FeatureLifecycleService`:**
|
|
@@ -705,21 +853,36 @@ Add an optional `role` parameter to the existing `feature.get_context` tool (bac
|
|
|
705
853
|
async featureGetContext(featureId: string, role: 'full' | 'planner' | 'builder' | 'qa' = 'full') {
|
|
706
854
|
const [state, plan, qaIndex, evidence, specText] = await Promise.all([...]); // PER-T-001
|
|
707
855
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
856
|
+
if (role === 'full') {
|
|
857
|
+
return {
|
|
858
|
+
data: {
|
|
859
|
+
feature_id: featureId,
|
|
860
|
+
spec: specText,
|
|
861
|
+
state: state.data,
|
|
862
|
+
plan: plan.data.plan,
|
|
863
|
+
qa_test_index: qaIndex.data,
|
|
864
|
+
latest_evidence: evidence.data?.latest ?? null
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
}
|
|
720
868
|
|
|
721
|
-
return {
|
|
869
|
+
return {
|
|
870
|
+
data: {
|
|
871
|
+
feature_id: featureId,
|
|
872
|
+
spec: trimSpec(specText, specBudgetByRole[role]),
|
|
873
|
+
state: projectState(state.data, role),
|
|
874
|
+
plan: projectPlan(plan.data.plan, role),
|
|
875
|
+
qa_test_index: projectQaIndex(qaIndex.data, role),
|
|
876
|
+
latest_evidence: projectEvidence(evidence.data?.latest ?? null, role)
|
|
877
|
+
}
|
|
878
|
+
};
|
|
722
879
|
}
|
|
880
|
+
|
|
881
|
+
const specBudgetByRole: Record<string, number> = {
|
|
882
|
+
planner: Infinity,
|
|
883
|
+
builder: 8 * 1024,
|
|
884
|
+
qa: 4 * 1024
|
|
885
|
+
};
|
|
723
886
|
```
|
|
724
887
|
|
|
725
888
|
Wave executors pass their role when calling context:
|
|
@@ -736,48 +899,103 @@ const context = await this.toolCaller.callTool('builder', TOOLS.FEATURE_GET_CONT
|
|
|
736
899
|
- `GIVEN_featureGetContext_with_role_qa_WHEN_called_THEN_passed_tests_are_omitted`
|
|
737
900
|
- `GIVEN_featureGetContext_with_role_planner_WHEN_called_THEN_state_is_projected`
|
|
738
901
|
- `GIVEN_featureGetContext_with_role_full_WHEN_called_THEN_bundle_is_unchanged`
|
|
902
|
+
- `GIVEN_featureGetContext_no_role_WHEN_called_THEN_bundle_is_unchanged` (backward compat)
|
|
739
903
|
|
|
740
904
|
**Acceptance criteria:**
|
|
741
905
|
1. `role: 'full'` (and omitting `role`) returns the existing complete bundle — no regression for callers that do not pass a role.
|
|
742
|
-
2. `role: 'builder'`
|
|
743
|
-
3. `role: 'qa'`
|
|
744
|
-
4. `role: 'planner'` projects `state` to `{ status, branch, locks, gates, gate_profile }`.
|
|
906
|
+
2. `role: 'builder'` returns `{ summary }` only for `qa_test_index`; `latest_evidence` includes at most 5 `failed_steps`.
|
|
907
|
+
3. `role: 'qa'` returns `{ summary, failed, pending }` for `qa_test_index` (no `passed`); `latest_evidence` includes at most 10 `failed_steps` plus `coverage`.
|
|
908
|
+
4. `role: 'planner'` projects `state.front_matter` to `{ status, branch, locks.held, gates, gate_profile }`.
|
|
745
909
|
5. Context byte size for `builder` and `qa` roles is ≤50% of `full` bundle when QA index has ≥50 test records.
|
|
746
|
-
6. Input schema is updated and validated by the MCP
|
|
910
|
+
6. Input schema is updated and validated by the MCP contract validator (`npm run validate:mcp-contracts`).
|
|
747
911
|
|
|
748
912
|
---
|
|
749
913
|
|
|
750
|
-
#### PER-T-009: Extract and test the
|
|
914
|
+
#### PER-T-009: Extract and test the context projection helpers
|
|
751
915
|
|
|
752
916
|
**Fixes:** Finding C-1 (supporting task for PER-T-008)
|
|
753
917
|
**File:** `apps/control-plane/src/application/services/feature-lifecycle-service.ts`
|
|
754
918
|
|
|
755
|
-
|
|
919
|
+
Extract four pure projection helpers. Define a local snapshot type to avoid a circular dependency with the QA index schema:
|
|
756
920
|
|
|
757
921
|
```typescript
|
|
922
|
+
// Local snapshot type — mirrors the runtime shape of qa_test_index.json without importing the schema
|
|
923
|
+
interface QaIndexSnapshot {
|
|
924
|
+
summary?: unknown;
|
|
925
|
+
passed?: unknown;
|
|
926
|
+
failed?: unknown;
|
|
927
|
+
pending?: unknown;
|
|
928
|
+
[key: string]: unknown;
|
|
929
|
+
}
|
|
930
|
+
|
|
758
931
|
export function projectQaIndex(
|
|
759
|
-
qaIndex:
|
|
932
|
+
qaIndex: QaIndexSnapshot,
|
|
760
933
|
role: 'full' | 'planner' | 'builder' | 'qa'
|
|
761
|
-
): Partial<
|
|
934
|
+
): Partial<QaIndexSnapshot> {
|
|
762
935
|
if (role === 'full') return qaIndex;
|
|
763
|
-
if (role === 'planner') return { summary: qaIndex.summary };
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
936
|
+
if (role === 'planner' || role === 'builder') return { summary: qaIndex.summary };
|
|
937
|
+
// 'qa': return summary + failed + pending; explicitly omit passed and all other fields
|
|
938
|
+
return {
|
|
939
|
+
summary: qaIndex.summary,
|
|
940
|
+
failed: qaIndex.failed,
|
|
941
|
+
pending: qaIndex.pending
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function projectEvidence(
|
|
946
|
+
evidence: AnyRecord | null,
|
|
947
|
+
role: 'full' | 'planner' | 'builder' | 'qa'
|
|
948
|
+
): AnyRecord | null {
|
|
949
|
+
if (!evidence || role === 'full') return evidence;
|
|
950
|
+
if (role === 'planner') return { overall: evidence.overall, profile: evidence.profile };
|
|
951
|
+
const maxSteps = role === 'builder' ? 4 : 9;
|
|
952
|
+
const failedSteps = Array.isArray(evidence.step_results)
|
|
953
|
+
? evidence.step_results.filter((s: AnyRecord) => s.outcome === 'fail').slice(0, maxSteps)
|
|
954
|
+
: [];
|
|
955
|
+
return {
|
|
956
|
+
overall: evidence.overall,
|
|
957
|
+
profile: evidence.profile,
|
|
958
|
+
failed_steps: failedSteps,
|
|
959
|
+
...(role === 'qa' ? { coverage: evidence.coverage } : {})
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export function projectState(stateData: AnyRecord, role: 'full' | 'planner' | 'builder' | 'qa'): AnyRecord {
|
|
964
|
+
if (role === 'full' || role === 'planner') return stateData;
|
|
965
|
+
const fm = stateData.front_matter ?? {};
|
|
966
|
+
const includedFields: (keyof typeof fm)[] = role === 'builder'
|
|
967
|
+
? ['status', 'branch', 'worktree_branch', 'locks', 'gates', 'gate_profile']
|
|
968
|
+
: ['status', 'branch', 'worktree_branch', 'gates', 'gate_profile'];
|
|
969
|
+
const projected: AnyRecord = {};
|
|
970
|
+
for (const field of includedFields) {
|
|
971
|
+
if (field in fm) projected[field] = fm[field];
|
|
972
|
+
}
|
|
973
|
+
return { front_matter: projected };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export function trimSpec(spec: string, budgetBytes: number): string {
|
|
977
|
+
if (!Number.isFinite(budgetBytes) || spec.length <= budgetBytes) return spec;
|
|
978
|
+
return spec.slice(0, budgetBytes);
|
|
768
979
|
}
|
|
769
980
|
```
|
|
770
981
|
|
|
771
982
|
**Tests to write:** `apps/control-plane/test/feature-lifecycle-service.spec.ts`
|
|
772
983
|
- `GIVEN_projectQaIndex_role_qa_WHEN_index_has_passed_tests_THEN_passed_is_omitted`
|
|
984
|
+
- `GIVEN_projectQaIndex_role_qa_WHEN_index_has_running_field_THEN_running_is_omitted`
|
|
773
985
|
- `GIVEN_projectQaIndex_role_full_WHEN_called_THEN_index_is_unchanged`
|
|
774
986
|
- `GIVEN_projectQaIndex_role_planner_WHEN_called_THEN_only_summary_returned`
|
|
987
|
+
- `GIVEN_projectQaIndex_role_builder_WHEN_called_THEN_only_summary_returned`
|
|
988
|
+
- `GIVEN_projectEvidence_role_builder_WHEN_many_failed_steps_THEN_truncated_to_4`
|
|
989
|
+
- `GIVEN_projectEvidence_role_qa_WHEN_many_failed_steps_THEN_truncated_to_9`
|
|
990
|
+
- `GIVEN_projectEvidence_role_qa_WHEN_called_THEN_coverage_is_included`
|
|
991
|
+
- `GIVEN_trimSpec_WHEN_spec_within_budget_THEN_unchanged`
|
|
992
|
+
- `GIVEN_trimSpec_WHEN_spec_exceeds_budget_THEN_truncated`
|
|
775
993
|
|
|
776
994
|
---
|
|
777
995
|
|
|
778
996
|
### PER-M4: Memory & CPU Cleanup
|
|
779
997
|
|
|
780
|
-
**Goal:** Fix the slow memory leak in `RunCoordinator`, eliminate redundant serialization in `readIndex`, replace `structuredClone` with a targeted spread, and unify
|
|
998
|
+
**Goal:** Fix the slow memory leak in `RunCoordinator`, eliminate redundant serialization in `readIndex`, replace `structuredClone` with a targeted spread, and unify all seven duplicated `normalizeSet` implementations.
|
|
781
999
|
|
|
782
1000
|
---
|
|
783
1001
|
|
|
@@ -785,7 +1003,7 @@ export function projectQaIndex(
|
|
|
785
1003
|
|
|
786
1004
|
**Fixes:** Finding M-3
|
|
787
1005
|
**File:** `apps/control-plane/src/supervisor/run-coordinator.ts`
|
|
788
|
-
**Lines:** 183–191 (`rebalanceActiveFeatures`)
|
|
1006
|
+
**Lines:** 183–191 (`rebalanceActiveFeatures`), 193–207 (queue-drain loop)
|
|
789
1007
|
|
|
790
1008
|
When `closeFeatureCluster(featureId)` is called for a terminal-status feature, the corresponding `statusCache` entry is never deleted. Over a long run processing hundreds of features, the Map accumulates dead entries indefinitely.
|
|
791
1009
|
|
|
@@ -807,7 +1025,7 @@ for (const featureId of sortedCurrent) {
|
|
|
807
1025
|
const status = await this.readFeatureStatus(featureId);
|
|
808
1026
|
if (status && RunCoordinator.TERMINAL_STATUSES.has(status)) {
|
|
809
1027
|
await this.sessionOrchestrator.closeFeatureCluster(featureId);
|
|
810
|
-
this.statusCache.delete(featureId); // NEW
|
|
1028
|
+
this.statusCache.delete(featureId); // NEW: evict dead entry
|
|
811
1029
|
continue;
|
|
812
1030
|
}
|
|
813
1031
|
survivingActiveFeatureIds.push(featureId);
|
|
@@ -818,6 +1036,7 @@ Apply the same eviction in the queue-drain loop (lines 193–207) where features
|
|
|
818
1036
|
|
|
819
1037
|
**Tests to write:** `apps/control-plane/test/run-coordinator.spec.ts` (existing — add case)
|
|
820
1038
|
- `GIVEN_rebalanceActiveFeatures_WHEN_feature_reaches_terminal_status_THEN_statusCache_entry_is_evicted`
|
|
1039
|
+
- `GIVEN_rebalanceActiveFeatures_WHEN_queue_feature_is_terminal_THEN_statusCache_entry_is_evicted`
|
|
821
1040
|
|
|
822
1041
|
**Acceptance criteria:**
|
|
823
1042
|
1. After `closeFeatureCluster` is called for a terminal feature, `statusCache.has(featureId)` returns `false`.
|
|
@@ -829,8 +1048,9 @@ Apply the same eviction in the queue-drain loop (lines 193–207) where features
|
|
|
829
1048
|
#### PER-T-011: Replace `JSON.stringify` diff with version-based guard in `readIndex`
|
|
830
1049
|
|
|
831
1050
|
**Fixes:** Finding I-6
|
|
832
|
-
**
|
|
833
|
-
|
|
1051
|
+
**Files:**
|
|
1052
|
+
- `apps/control-plane/src/core/kernel.ts` (lines 755–763)
|
|
1053
|
+
- `agentic/orchestrator/schemas/index.schema.json` (add optional `schema_version` field)
|
|
834
1054
|
|
|
835
1055
|
`readIndex` serializes the entire index (20–50 KB) twice on every call just to detect whether schema migration is needed — a cold-path concern paid on every hot-path read.
|
|
836
1056
|
|
|
@@ -845,7 +1065,11 @@ if (changed) {
|
|
|
845
1065
|
**After:** Add a `schema_version` field to the index shape. `normalizeIndexShape` sets it to the current constant. Migration check becomes a single integer comparison:
|
|
846
1066
|
|
|
847
1067
|
```typescript
|
|
848
|
-
|
|
1068
|
+
// DEVELOPER PROTOCOL: When changing the shape of normalizeIndexShape (adding fields,
|
|
1069
|
+
// changing defaults, renaming keys), you MUST increment CURRENT_INDEX_SCHEMA_VERSION.
|
|
1070
|
+
// Forgetting to increment means legacy index files will never be re-normalized after
|
|
1071
|
+
// the shape change. There is an integration test below that will catch shape drift.
|
|
1072
|
+
const CURRENT_INDEX_SCHEMA_VERSION = 1;
|
|
849
1073
|
|
|
850
1074
|
async readIndex(): Promise<AnyRecord> {
|
|
851
1075
|
const existing = await readJson(this.indexPath, null);
|
|
@@ -863,16 +1087,20 @@ async readIndex(): Promise<AnyRecord> {
|
|
|
863
1087
|
|
|
864
1088
|
`normalizeIndexShape` output gains `schema_version: CURRENT_INDEX_SCHEMA_VERSION`. The field is added to `schemas/index.schema.json` as an optional integer.
|
|
865
1089
|
|
|
1090
|
+
**Shape-drift detection test:** Add a test that calls `normalizeIndexShape` with the current `CURRENT_INDEX_SCHEMA_VERSION` and asserts that the output's `schema_version` equals it. If a developer changes `normalizeIndexShape` without bumping the constant, the test will still pass — but if they also change what `normalizeIndexShape` produces (e.g., new field), the test ensures the constant is documented in context. Add a comment in the test: `// If this test fails, increment CURRENT_INDEX_SCHEMA_VERSION in kernel.ts`.
|
|
1091
|
+
|
|
866
1092
|
**Tests to write:** `apps/control-plane/test/kernel.spec.ts` (existing — add cases)
|
|
867
1093
|
- `GIVEN_readIndex_WHEN_schema_version_matches_THEN_normalizeIndexShape_not_called`
|
|
868
1094
|
- `GIVEN_readIndex_WHEN_schema_version_absent_THEN_migration_runs_and_writes_file`
|
|
869
1095
|
- `GIVEN_readIndex_WHEN_index_is_null_THEN_migration_runs`
|
|
1096
|
+
- `GIVEN_normalizeIndexShape_WHEN_called_THEN_schema_version_equals_current_constant` (shape-drift guard)
|
|
870
1097
|
|
|
871
1098
|
**Acceptance criteria:**
|
|
872
1099
|
1. When the on-disk index has the current `schema_version`, `normalizeIndexShape` is not called and no write occurs.
|
|
873
1100
|
2. When the field is absent (legacy index), normalization and re-write happen exactly once.
|
|
874
|
-
3. The `schema_version` field is included in `index.schema.json` and validates correctly.
|
|
1101
|
+
3. The `schema_version` field is included in `index.schema.json` as an optional integer and validates correctly.
|
|
875
1102
|
4. No other fields in the index are changed by this migration.
|
|
1103
|
+
5. The developer protocol comment is co-located with the constant definition in `kernel.ts`.
|
|
876
1104
|
|
|
877
1105
|
---
|
|
878
1106
|
|
|
@@ -929,27 +1157,38 @@ private buildUpdatedPlan(plan: AnyRecord, planVersion: number, decision: Reconci
|
|
|
929
1157
|
|
|
930
1158
|
---
|
|
931
1159
|
|
|
932
|
-
#### PER-T-013: Extract `normalizeSet` to a shared utility
|
|
1160
|
+
#### PER-T-013: Extract `normalizeSet` to a shared utility (all 7 files)
|
|
933
1161
|
|
|
934
1162
|
**Fixes:** Finding M-1
|
|
935
1163
|
**Files:**
|
|
936
|
-
- Create
|
|
937
|
-
- Refactor
|
|
938
|
-
-
|
|
939
|
-
-
|
|
940
|
-
-
|
|
1164
|
+
- **Create:** `apps/control-plane/src/core/utils.ts`
|
|
1165
|
+
- **Refactor** (replace local definition, add import):
|
|
1166
|
+
- `apps/control-plane/src/core/kernel.ts`
|
|
1167
|
+
- `apps/control-plane/src/application/services/reporting-service.ts`
|
|
1168
|
+
- `apps/control-plane/src/application/services/merge-service.ts`
|
|
1169
|
+
- `apps/control-plane/src/application/services/feature-lifecycle-service.ts`
|
|
1170
|
+
- `apps/control-plane/src/application/services/feature-deletion-service.ts`
|
|
1171
|
+
- `apps/control-plane/src/application/services/lock-service.ts`
|
|
1172
|
+
- `apps/control-plane/src/application/services/plan-service.ts`
|
|
1173
|
+
|
|
1174
|
+
**Note:** The v1.0 spec missed `kernel.ts`, `lock-service.ts`, and `plan-service.ts`. All 7 files must be updated in this task for the extraction to be complete.
|
|
941
1175
|
|
|
942
1176
|
Add to `apps/control-plane/src/core/utils.ts`:
|
|
943
1177
|
```typescript
|
|
944
|
-
/** Deduplicate and sort a string array. */
|
|
1178
|
+
/** Deduplicate and sort a string array. Stable across key order. */
|
|
945
1179
|
export function normalizeSet(array: string[]): string[] {
|
|
946
1180
|
return [...new Set(array)].sort((a, b) => a.localeCompare(b));
|
|
947
1181
|
}
|
|
948
1182
|
```
|
|
949
1183
|
|
|
950
|
-
In each of the
|
|
1184
|
+
In each of the seven files, remove the local definition and add:
|
|
1185
|
+
```typescript
|
|
1186
|
+
import { normalizeSet } from '../../core/utils.js'; // adjust relative path per file
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
For `kernel.ts` the import is:
|
|
951
1190
|
```typescript
|
|
952
|
-
import { normalizeSet } from '
|
|
1191
|
+
import { normalizeSet } from './utils.js';
|
|
953
1192
|
```
|
|
954
1193
|
|
|
955
1194
|
**Tests to write:** `apps/control-plane/test/core-utils.spec.ts` (existing — add cases)
|
|
@@ -958,9 +1197,10 @@ import { normalizeSet } from '../../core/utils.js';
|
|
|
958
1197
|
- `GIVEN_normalizeSet_WHEN_empty_array_THEN_returns_empty`
|
|
959
1198
|
|
|
960
1199
|
**Acceptance criteria:**
|
|
961
|
-
1. All
|
|
962
|
-
2. Behavior is identical to the
|
|
1200
|
+
1. All seven files import from `core/utils.js`; no local `normalizeSet` definitions remain anywhere in the codebase.
|
|
1201
|
+
2. Behavior is identical to the seven previous implementations (same Set deduplication, same `localeCompare` sort).
|
|
963
1202
|
3. No existing tests fail.
|
|
1203
|
+
4. `npm run lint` and `npm run typecheck` pass with no errors.
|
|
964
1204
|
|
|
965
1205
|
---
|
|
966
1206
|
|
|
@@ -1005,13 +1245,13 @@ for (const featureId of batch.slice(0, maxParallelGateRuns)) {
|
|
|
1005
1245
|
|
|
1006
1246
|
## 4. Acceptance Criteria (Spec-Level)
|
|
1007
1247
|
|
|
1008
|
-
1. All 14 tasks pass their own unit tests and the full existing test suite.
|
|
1248
|
+
1. All 14 tasks pass their own unit tests and the full existing test suite (`npm test`).
|
|
1009
1249
|
2. `npm run typecheck` and `npm run lint` report zero errors and zero warnings after all tasks.
|
|
1010
1250
|
3. MCP contract validation (`npm run validate:mcp-contracts`) passes.
|
|
1011
1251
|
4. Architecture validation (`npm run validate:architecture`) passes.
|
|
1012
|
-
5. No on-disk artifact format is
|
|
1013
|
-
6.
|
|
1014
|
-
7.
|
|
1252
|
+
5. No on-disk artifact format is broken; all schema additions are backward-compatible optional fields.
|
|
1253
|
+
6. The `role` parameter on `feature.get_context` is optional; omitting it returns the existing complete bundle byte-for-byte.
|
|
1254
|
+
7. Evidence pruning never removes `latest.json` or any `latest-${mode}.json` sentinel file.
|
|
1015
1255
|
|
|
1016
1256
|
---
|
|
1017
1257
|
|
|
@@ -1020,11 +1260,13 @@ for (const featureId of batch.slice(0, maxParallelGateRuns)) {
|
|
|
1020
1260
|
| Risk | Mitigation |
|
|
1021
1261
|
|------|-----------|
|
|
1022
1262
|
| `Promise.all` parallelism exposes hidden race conditions in test mocks | Add ordering assertions in tests; use `vi.fn()` with explicit mock return sequences |
|
|
1023
|
-
| Context projection silently drops fields agents depend on |
|
|
1024
|
-
| `schema_version`
|
|
1025
|
-
| `structuredClone` removal causes mutation if spread is too shallow | The five mutated fields are scalars or wholly replaced arrays;
|
|
1263
|
+
| Context projection silently drops fields agents depend on | Gated behind `role: 'full'` default; add integration test asserting full bundle is unchanged |
|
|
1264
|
+
| `schema_version` constant not bumped when shape changes | Developer protocol comment co-located in `kernel.ts`; shape-drift detection test in `kernel.spec.ts` |
|
|
1265
|
+
| `structuredClone` removal causes mutation if spread is too shallow | The five mutated fields are scalars or wholly replaced arrays; mutation-guard test confirms |
|
|
1026
1266
|
| Evidence pruning deletes a file being read concurrently | Pruning occurs after the gate write completes; `fs.unlink` uses `.catch(() => undefined)` |
|
|
1027
|
-
| `normalizeSet` extraction breaks an unknown caller | Signature is identical; all
|
|
1267
|
+
| `normalizeSet` extraction breaks an unknown caller | Signature is identical; all seven files updated atomically in this task; `npm run typecheck` catches any missed callers |
|
|
1268
|
+
| Repair context re-fetch in PER-T-003 is still an extra call | This is by design: one fetch per failing feature vs one fetch per retry in v1.0; net improvement |
|
|
1269
|
+
| `latest.json` write in `gates.ts` fails mid-run | `latest.json` is non-critical metadata; `latest-${mode}.json` sentinel is still written first; `evidenceLatest` falls back to `null` gracefully |
|
|
1028
1270
|
|
|
1029
1271
|
---
|
|
1030
1272
|
|
|
@@ -1034,17 +1276,17 @@ for (const featureId of batch.slice(0, maxParallelGateRuns)) {
|
|
|
1034
1276
|
|----|-----------|---------|-------------|
|
|
1035
1277
|
| PER-T-001 | PER-M1 | `feature-lifecycle-service.ts` | Parallelize 5 sequential reads in `featureGetContext` |
|
|
1036
1278
|
| PER-T-002 | PER-M1 | `build-wave-executor.ts`, `qa-wave-executor.ts` | Parallelize status-filter loops |
|
|
1037
|
-
| PER-T-003 | PER-M1 | `build-wave-executor.ts` |
|
|
1279
|
+
| PER-T-003 | PER-M1 | `build-wave-executor.ts` | Context-capture Map pattern; eliminate per-retry duplicate fetch |
|
|
1038
1280
|
| PER-T-004 | PER-M1 | `reporting-service.ts` | Parallelize `reportDashboard` state+cost reads |
|
|
1039
|
-
| PER-T-005 | PER-M2 | `gate-service.ts` |
|
|
1040
|
-
| PER-T-006 | PER-M2 | `policy.yaml`, `policy.schema.json`, `gates.ts` | Add `evidence_retention_count` and pruning logic |
|
|
1041
|
-
| PER-T-007 | PER-M3 | `planning-wave-executor.ts` | Pre-filter non-planning features
|
|
1042
|
-
| PER-T-008 | PER-M3 | `feature-lifecycle-service.ts`, schemas
|
|
1043
|
-
| PER-T-009 | PER-M3 | `feature-lifecycle-service.ts` | Extract and test
|
|
1281
|
+
| PER-T-005 | PER-M2 | `gate-service.ts`, `gates.ts` | Write canonical `latest.json`; read it directly in `evidenceLatest` |
|
|
1282
|
+
| PER-T-006 | PER-M2 | `policy.yaml`, `policy.defaults.yaml`, `policy.schema.json`, `gates.ts` | Add `evidence_retention_count` and pruning logic |
|
|
1283
|
+
| PER-T-007 | PER-M3 | `planning-wave-executor.ts` | Pre-filter non-planning features; parallelize context fetches |
|
|
1284
|
+
| PER-T-008 | PER-M3 | `feature-lifecycle-service.ts`, input schemas | Role-specific context projections |
|
|
1285
|
+
| PER-T-009 | PER-M3 | `feature-lifecycle-service.ts` | Extract and test projection helpers |
|
|
1044
1286
|
| PER-T-010 | PER-M4 | `run-coordinator.ts` | Evict `statusCache` on feature termination |
|
|
1045
1287
|
| PER-T-011 | PER-M4 | `kernel.ts`, `schemas/index.schema.json` | Replace `JSON.stringify` diff with `schema_version` guard |
|
|
1046
1288
|
| PER-T-012 | PER-M4 | `planning-wave-executor.ts` | Replace `structuredClone(plan)` with spread |
|
|
1047
|
-
| PER-T-013 | PER-M4 | `core/utils.ts` +
|
|
1289
|
+
| PER-T-013 | PER-M4 | `core/utils.ts` + 7 files | Deduplicate `normalizeSet` into shared utility |
|
|
1048
1290
|
| PER-T-014 | PER-M4 | `qa-wave-executor.ts` | Move `loadRolePrompts` outside per-feature loop |
|
|
1049
1291
|
|
|
1050
1292
|
---
|
|
@@ -1053,7 +1295,74 @@ for (const featureId of batch.slice(0, maxParallelGateRuns)) {
|
|
|
1053
1295
|
|
|
1054
1296
|
Tasks within each milestone are independent and can be implemented in any order or in parallel. Milestone ordering recommendation:
|
|
1055
1297
|
|
|
1056
|
-
1. **PER-M1 first** — highest-frequency hotpaths; no schema or interface changes;
|
|
1057
|
-
2. **PER-M2 second** — bounds disk growth; PER-T-005
|
|
1058
|
-
3. **PER-M4 third** — low-risk cleanups; PER-T-013 produces the shared utility that PER-T-008
|
|
1059
|
-
4. **PER-M3 last** —
|
|
1298
|
+
1. **PER-M1 first** — highest-frequency hotpaths; no schema or interface changes; changes are local refactors. PER-T-001 is a prerequisite for PER-T-008 (PER-T-008 calls PER-T-001's parallel reads internally).
|
|
1299
|
+
2. **PER-M2 second** — bounds disk growth; PER-T-005 requires writing `latest.json` in `gates.ts` before the O(1) read in `evidenceLatest` is safe.
|
|
1300
|
+
3. **PER-M4 third** — low-risk cleanups; PER-T-013 produces the shared utility that PER-T-008 might reference.
|
|
1301
|
+
4. **PER-M3 last** — depends on PER-T-001 (for the parallel reads inside `featureGetContext`) and PER-T-005 (for accurate `latest_evidence` projection content).
|
|
1302
|
+
|
|
1303
|
+
---
|
|
1304
|
+
|
|
1305
|
+
## 8. Step-by-Step Implementation Plan
|
|
1306
|
+
|
|
1307
|
+
The following 28 ordered steps represent the complete implementation sequence. Steps within the same milestone can be executed concurrently.
|
|
1308
|
+
|
|
1309
|
+
### Phase 1: PER-M4 Quick Wins (no dependencies, safe warm-up)
|
|
1310
|
+
|
|
1311
|
+
1. **Create `apps/control-plane/src/core/utils.ts`** with `normalizeSet`.
|
|
1312
|
+
2. **Update all 7 files** that define `normalizeSet` locally to import from `core/utils.ts`; delete local definitions.
|
|
1313
|
+
3. **Add tests** to `core-utils.spec.ts` for `normalizeSet`.
|
|
1314
|
+
4. **Run** `npm run typecheck && npm run lint && npm test` — must pass.
|
|
1315
|
+
5. **Replace `structuredClone(plan)`** with spread in `planning-wave-executor.ts:298`.
|
|
1316
|
+
6. **Add mutation-guard tests** to `planning-wave-executor.spec.ts`.
|
|
1317
|
+
7. **Evict `statusCache` entries** in `run-coordinator.ts` `rebalanceActiveFeatures` and queue-drain loop.
|
|
1318
|
+
8. **Add eviction tests** to `run-coordinator.spec.ts`.
|
|
1319
|
+
9. **Move `loadRolePrompts`** outside the per-feature loop in `qa-wave-executor.ts`.
|
|
1320
|
+
10. **Add one-call test** to `session-management.spec.ts`.
|
|
1321
|
+
11. **Run** `npm test` — all must pass.
|
|
1322
|
+
|
|
1323
|
+
### Phase 2: PER-M2 Evidence Efficiency
|
|
1324
|
+
|
|
1325
|
+
12. **Add `latest.json` write** to `gates.ts` (after existing `latest-${mode}.json` write).
|
|
1326
|
+
13. **Update `evidenceLatest`** in `gate-service.ts` to read `latest.json` directly.
|
|
1327
|
+
14. **Create `gate-service.spec.ts`** with sentinel-read and null-fallback tests.
|
|
1328
|
+
15. **Add `evidence_retention_count`** to `policy.yaml`, `policy.defaults.yaml`, and `policy.schema.json`.
|
|
1329
|
+
16. **Add `pruneEvidenceFiles`** to `gates.ts`; call it after each gate run.
|
|
1330
|
+
17. **Add pruning tests** to `incremental-gates.spec.ts`.
|
|
1331
|
+
18. **Run** `npm run validate:mcp-contracts && npm test` — must pass.
|
|
1332
|
+
|
|
1333
|
+
### Phase 3: PER-M1 Parallel I/O
|
|
1334
|
+
|
|
1335
|
+
19. **Parallelize 5 reads** in `featureGetContext` (`feature-lifecycle-service.ts`).
|
|
1336
|
+
20. **Add parallel-read test** to `feature-lifecycle-service.spec.ts`.
|
|
1337
|
+
21. **Parallelize status-filter loops** in `BuildWaveExecutor` and `QaWaveExecutor`.
|
|
1338
|
+
22. **Add parallel-state-read tests** to `batch-operations.spec.ts`.
|
|
1339
|
+
23. **Implement context-capture Map** in `BuildWaveExecutor` (PER-T-003); add tests to `batch-operations.spec.ts`.
|
|
1340
|
+
24. **Parallelize dashboard reads** in `reportDashboard` (`reporting-service.ts`); add tests.
|
|
1341
|
+
25. **Replace `readIndex` JSON.stringify** with schema_version guard; add `schema_version` to `normalizeIndexShape` and `index.schema.json`; add tests to `kernel.spec.ts`.
|
|
1342
|
+
26. **Run** `npm test` — all must pass.
|
|
1343
|
+
|
|
1344
|
+
### Phase 4: PER-M3 Context Slimming
|
|
1345
|
+
|
|
1346
|
+
27. **Extract projection helpers** (`projectQaIndex`, `projectEvidence`, `projectState`, `trimSpec`) into `feature-lifecycle-service.ts`; add comprehensive tests.
|
|
1347
|
+
28. **Add `role` parameter** to `feature.get_context` input schema; update `featureGetContext` to dispatch to projections; update all wave executors to pass `role`; run `npm run validate:mcp-contracts`; add role-specific integration tests.
|
|
1348
|
+
|
|
1349
|
+
---
|
|
1350
|
+
|
|
1351
|
+
## 9. Spec Gap Tracker
|
|
1352
|
+
|
|
1353
|
+
| Task | Status | Notes |
|
|
1354
|
+
|------|--------|-------|
|
|
1355
|
+
| PER-T-001: Parallelize 5 reads in `featureGetContext` | ⬜ Pending | |
|
|
1356
|
+
| PER-T-002: Parallelize status-filter loops | ⬜ Pending | |
|
|
1357
|
+
| PER-T-003: Context-capture Map pattern | ⬜ Pending | Redesigned from v1.0 |
|
|
1358
|
+
| PER-T-004: Parallelize `reportDashboard` reads | ⬜ Pending | |
|
|
1359
|
+
| PER-T-005: Write + read `latest.json` sentinel | ⬜ Pending | v1.0 had wrong filename |
|
|
1360
|
+
| PER-T-006: Evidence retention policy | ⬜ Pending | Pruning filter updated for `latest-*.json` |
|
|
1361
|
+
| PER-T-007: Pre-filter + parallel context in `PlanningWaveExecutor` | ⬜ Pending | `runPostQaReconciliation` fully specified |
|
|
1362
|
+
| PER-T-008: Role-specific context projections | ⬜ Pending | Output schema note added |
|
|
1363
|
+
| PER-T-009: Extract projection helpers | ⬜ Pending | `QaIndexSnapshot` type defined |
|
|
1364
|
+
| PER-T-010: Evict `statusCache` on termination | ⬜ Pending | |
|
|
1365
|
+
| PER-T-011: `schema_version` guard in `readIndex` | ⬜ Pending | Version-bump protocol added |
|
|
1366
|
+
| PER-T-012: Replace `structuredClone` with spread | ⬜ Pending | |
|
|
1367
|
+
| PER-T-013: Extract `normalizeSet` (7 files) | ⬜ Pending | Expanded from 4 to 7 files |
|
|
1368
|
+
| PER-T-014: Move `loadRolePrompts` out of loop | ⬜ Pending | |
|