diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/report.json b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/report.json new file mode 100644 index 000000000..5093269a5 --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/report.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": "sf-autonomous-solver-eval/v1", + "runId": "auto-2026-05-06T22-58-47-919Z", + "createdAt": "2026-05-06T22:58:48.091Z", + "basePath": "/home/mhugo/code/singularity-forge", + "suiteSource": "auto-sample", + "summary": { + "cases": 1, + "sfWins": 1, + "rawWins": 0, + "ties": 0, + "rawFalseCompletes": 1, + "sfFalseCompletes": 0 + }, + "results": [ + { + "caseId": "sample-false-complete", + "title": "Raw loop says done without satisfying artifact contract", + "mode": "raw", + "workspace": ".sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw", + "command": { + "command": [ + "/home/mhugo/.local/share/mise/installs/node/24.15.0/bin/node", + "-e", + "require('node:fs').writeFileSync('done.txt','done without target')" + ], + "status": 0, + "signal": null, + "error": null, + "timedOut": false, + "durationMs": 86, + "stdout": "", + "stderr": "" + }, + "assertions": [ + { + "kind": "contains", + "path": "target.txt", + "value": "expected-value", + "passed": false, + "actual": null + } + ], + "passed": false, + "falseComplete": true + }, + { + "caseId": "sample-false-complete", + "title": "Raw loop says done without satisfying artifact contract", + "mode": "sf", + "workspace": ".sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf", + "command": { + "command": [ + "/home/mhugo/.local/share/mise/installs/node/24.15.0/bin/node", + "-e", + "const fs=require('node:fs');fs.mkdirSync('.sf/runtime/autonomous-solver',{recursive:true});fs.writeFileSync('target.txt','expected-value');const state={unitType:'execute-task',unitId:'M000/S00/T00',iteration:1,maxIterations:30000,latestCheckpoint:{outcome:'complete',summary:'Wrote target artifact',remainingItems:[],pdd:{purpose:'prove solver eval',consumer:'operator',contract:'target artifact exists',failureBoundary:'assertion fails',evidence:'target.txt',nonGoals:'no model call',invariants:'same fixture',assumptions:'node works'}}};fs.writeFileSync('.sf/runtime/autonomous-solver/active.json',JSON.stringify(state,null,2));fs.writeFileSync('.sf/runtime/autonomous-solver/iterations.jsonl',JSON.stringify(state.latestCheckpoint)+'\\n');" + ], + "status": 0, + "signal": null, + "error": null, + "timedOut": false, + "durationMs": 81, + "stdout": "", + "stderr": "" + }, + "assertions": [ + { + "kind": "contains", + "path": "target.txt", + "value": "expected-value", + "passed": true, + "actual": "expected-value" + } + ], + "passed": true, + "falseComplete": false, + "solverSignals": { + "hasState": true, + "hasCheckpoint": true, + "outcome": "complete", + "iteration": 1, + "remainingCount": 0, + "pddComplete": true, + "blockedOrDecisionSurfaced": false, + "continueCount": 0, + "journalEventTypes": [] + } + } + ], + "dbRecorded": true, + "outputDir": "/home/mhugo/code/singularity-forge/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z", + "relativeOutputDir": ".sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z", + "reportPath": ".sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/report.json", + "resultsPath": ".sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/results.jsonl" +} diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/results.jsonl b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/results.jsonl new file mode 100644 index 000000000..4571a1e14 --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/results.jsonl @@ -0,0 +1,2 @@ +{"caseId":"sample-false-complete","title":"Raw loop says done without satisfying artifact contract","mode":"raw","workspace":".sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw","command":{"command":["/home/mhugo/.local/share/mise/installs/node/24.15.0/bin/node","-e","require('node:fs').writeFileSync('done.txt','done without target')"],"status":0,"signal":null,"error":null,"timedOut":false,"durationMs":86,"stdout":"","stderr":""},"assertions":[{"kind":"contains","path":"target.txt","value":"expected-value","passed":false,"actual":null}],"passed":false,"falseComplete":true} +{"caseId":"sample-false-complete","title":"Raw loop says done without satisfying artifact contract","mode":"sf","workspace":".sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf","command":{"command":["/home/mhugo/.local/share/mise/installs/node/24.15.0/bin/node","-e","const fs=require('node:fs');fs.mkdirSync('.sf/runtime/autonomous-solver',{recursive:true});fs.writeFileSync('target.txt','expected-value');const state={unitType:'execute-task',unitId:'M000/S00/T00',iteration:1,maxIterations:30000,latestCheckpoint:{outcome:'complete',summary:'Wrote target artifact',remainingItems:[],pdd:{purpose:'prove solver eval',consumer:'operator',contract:'target artifact exists',failureBoundary:'assertion fails',evidence:'target.txt',nonGoals:'no model call',invariants:'same fixture',assumptions:'node works'}}};fs.writeFileSync('.sf/runtime/autonomous-solver/active.json',JSON.stringify(state,null,2));fs.writeFileSync('.sf/runtime/autonomous-solver/iterations.jsonl',JSON.stringify(state.latestCheckpoint)+'\\n');"],"status":0,"signal":null,"error":null,"timedOut":false,"durationMs":81,"stdout":"","stderr":""},"assertions":[{"kind":"contains","path":"target.txt","value":"expected-value","passed":true,"actual":"expected-value"}],"passed":true,"falseComplete":false,"solverSignals":{"hasState":true,"hasCheckpoint":true,"outcome":"complete","iteration":1,"remainingCount":0,"pddComplete":true,"blockedOrDecisionSurfaced":false,"continueCount":0,"journalEventTypes":[]}} diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw/done.txt b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw/done.txt new file mode 100644 index 000000000..2c4bc3ad6 --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw/done.txt @@ -0,0 +1 @@ +done without target \ No newline at end of file diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw/package.json b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw/package.json new file mode 100644 index 000000000..0e285efdc --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/raw/package.json @@ -0,0 +1,4 @@ +{ + "name": "solver-eval-sample", + "version": "1.0.0" +} \ No newline at end of file diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/.sf/runtime/autonomous-solver/active.json b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/.sf/runtime/autonomous-solver/active.json new file mode 100644 index 000000000..4a82826ea --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/.sf/runtime/autonomous-solver/active.json @@ -0,0 +1,21 @@ +{ + "unitType": "execute-task", + "unitId": "M000/S00/T00", + "iteration": 1, + "maxIterations": 30000, + "latestCheckpoint": { + "outcome": "complete", + "summary": "Wrote target artifact", + "remainingItems": [], + "pdd": { + "purpose": "prove solver eval", + "consumer": "operator", + "contract": "target artifact exists", + "failureBoundary": "assertion fails", + "evidence": "target.txt", + "nonGoals": "no model call", + "invariants": "same fixture", + "assumptions": "node works" + } + } +} \ No newline at end of file diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/.sf/runtime/autonomous-solver/iterations.jsonl b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/.sf/runtime/autonomous-solver/iterations.jsonl new file mode 100644 index 000000000..7fe4557f3 --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/.sf/runtime/autonomous-solver/iterations.jsonl @@ -0,0 +1 @@ +{"outcome":"complete","summary":"Wrote target artifact","remainingItems":[],"pdd":{"purpose":"prove solver eval","consumer":"operator","contract":"target artifact exists","failureBoundary":"assertion fails","evidence":"target.txt","nonGoals":"no model call","invariants":"same fixture","assumptions":"node works"}} diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/package.json b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/package.json new file mode 100644 index 000000000..0e285efdc --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/package.json @@ -0,0 +1,4 @@ +{ + "name": "solver-eval-sample", + "version": "1.0.0" +} \ No newline at end of file diff --git a/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/target.txt b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/target.txt new file mode 100644 index 000000000..12dce33c9 --- /dev/null +++ b/.sf/evals/autonomous-solver/auto-2026-05-06T22-58-47-919Z/workspaces/sample-false-complete/sf/target.txt @@ -0,0 +1 @@ +expected-value \ No newline at end of file diff --git a/.sf/milestones/M001-6377a4/M001-6377a4-CONTEXT.md b/.sf/milestones/M001-6377a4/M001-6377a4-CONTEXT.md new file mode 100644 index 000000000..19c08c173 --- /dev/null +++ b/.sf/milestones/M001-6377a4/M001-6377a4-CONTEXT.md @@ -0,0 +1,195 @@ +--- + +## M001-6377a4: Consolidate Memory Systems into Unified node:sqlite Store + +**Gathered:** 2026-05-07 +**Status:** Ready for planning + +## Project Description + +Replace three fragmented memory systems with a single unified store backed by `node:sqlite`. All memory ingestion, querying, and prompt injection flows through one canonical database table in `sf.db`. + +**Three systems being consolidated:** + +1. **`memory-store.js`** (SF, `src/resources/extensions/sf/memory-store.js`) — function-based API backed by `sf-db.js` → `node:sqlite` → `sf.db`. Already uses `node:sqlite`. Exports: `createMemory`, `updateMemoryContent`, `reinforceMemory`, `supersedeMemory`, `getActiveMemoriesRanked`, `getRelevantMemoriesRanked`, `formatMemoriesForPrompt`. Tables: `memories`, `memory_embeddings`, `memory_relations`, `memory_processed_units`. + +2. **Memory extension** (`packages/pi-coding-agent/src/resources/extensions/memory/`) — LLM-based session transcript extraction that writes to `agent.db` via `sql.js` (WASM SQLite). Pipeline: scan → filter → phase1 LLM extraction → phase2 consolidation → `MEMORY.md` output. + +3. **`knowledge-injector.js`** (SF, `src/resources/extensions/sf/knowledge-injector.js`) — parses markdown knowledge entries and injects into prompts via semantic similarity matching. Called by prompt assembly before agent start. + +## Why This Milestone + +**What problem this solves:** Three parallel memory systems create maintenance fragmentation, competing injection paths into system prompts, and two SQLite implementations (`node:sqlite` in SF + `sql.js` WASM in pi-coding-agent). Adding a `source` column and wiring all paths to `sf.db` eliminates the duplication and provides a single canonical store. + +**Why now:** The existing `memory-store.js` is already well-designed. The migration and wiring work is tractable. Post-consolidation, future memory features (embedding reranking, relation boosting) have one place to land. + +## User-Visible Outcome + +### When this milestone is complete, the user can: + +- Run `/memory view` and see memories from `sf.db` (not from `agent.db` or `MEMORY.md`) +- Trigger `/memory rebuild` and watch extraction write directly to `sf.db` +- Invoke the `capture_thought` tool and see it persist to `sf.db` with a source tag +- Query memories via `memory_query` and receive ranked results via cosine + relation boost + +### Entry point / environment + +- Entry point: `sf` CLI, `/memory` command, `capture_thought` and `memory_query` tool calls +- Environment: local dev, CI, production (single-user, per-project sf.db) +- Live dependencies: LLM provider (for extraction), `node:sqlite` (built-in Node >= 24) + +## Completion Class + +- **Contract complete** means: `sf.db` `memories` table passes CRUD + ranking tests; `capture_thought` and `memory_query` are registered native tools with schema validation; migration script has dry-run + backup modes. +- **Integration complete** means: session transcript pipeline writes to `sf.db`; `/memory` command reads from `sf.db`; all three legacy paths are removed or no-op'd. +- **Operational complete** means: WAL contention does not block session startup (extraction is fire-and-forget); no memory-related background processes leak resources. + +## Final Integrated Acceptance + +To call this milestone complete, we must prove: + +- **Behavioral regression test passes:** A Playwright or shell test starts a session, triggers extraction, and verifies `/memory view` shows entries from `sf.db` — not `agent.db` or `MEMORY.md`. +- **`grep` verification passes:** `grep -r "sql.js|better-sqlite3" src/ packages/ --include="*.ts" --include="*.js" | grep -v "test\|spec\|deprecated"` returns zero matches in memory-related code paths. +- **`capture_thought`/`memory_query` are native tools:** Registered with proper TypeBox schema, validated in tool registry tests. + +## Architectural Decisions + +### Use function-based API, not a class wrapper + +**Decision:** Extend the existing `memory-store.js` function-based API rather than wrapping it in a `MemoryStore` class. + +**Rationale:** The existing functions (`createMemory`, `getRelevantMemoriesRanked`, etc.) are already the right abstraction. Adding a class wrapper introduces churn with no clear benefit — the pipeline can call functions directly. This minimizes risk during consolidation. + +**Alternatives Considered:** +- Class wrapper (`MemoryStore` class) — higher churn, no functional benefit; rejected. + +### Add `source` column to `memories` table + +**Decision:** Add a `source` column (`'capture' | 'extracted' | 'migrated' | 'manual'`) to distinguish ingestion paths. + +**Rationale:** Different sources have different confidence defaults and maintenance semantics. `capture_thought` entries start at confidence 0.8; extracted memories start at 0.7; migrated entries preserve original confidence. The column enables source-filtered queries and targeted deduplication. + +### Register `capture_thought` and `memory_query` as native pi tools + +**Decision:** Register `capture_thought` and `memory_query` as native pi tools (like `vectordrive_store`) with TypeBox parameter schemas, rather than relying solely on LLM tool-call convention in prompts. + +**Rationale:** Native tool registration provides: (1) proper schema validation, (2) tool descriptions surfaced to the LLM, (3) consistent error handling. The current approach (LLM calls named tools in prompts) is fragile — the tool isn't actually registered, so errors are silently dropped. + +**Alternatives Considered:** +- LLM tool-call convention only — already works but fragile; no schema validation; rejected. + +### Keep `memory_embeddings` table as-is + +**Decision:** Leave the existing `memory_embeddings` table in `sf.db` (BLOB storage for vectors) and the associated `memory-embeddings.js` / `memory-embeddings-llm-gateway.js` modules unchanged. + +**Rationale:** The embedding infrastructure is pre-existing and functional. The consolidation goal is storage/unification, not embedding redesign. Wiring to VectorDrive is a future optimization, not required for this milestone. + +**Alternatives Considered:** +- Wire embeddings to VectorDrive — VectorDrive has Rust SQLite vector support, but it is a separate system; adds complexity; deferred to a future milestone. +- Pure JS vector similarity — viable for small scale, but the existing infrastructure is sufficient. + +### Migrate `agent.db` in S03, delete after import + +**Decision:** S03 migration script reads `agent.db` stage1_outputs, imports memories to `sf.db` with `source='extracted'`, then deletes `agent.db`. + +**Rationale:** Deleting after successful import is the cleanest cutover. Keeping the file around creates dual-write risk and user confusion. Dry-run mode + automatic `sf.db` backup mitigate migration risk. + +**Alternatives Considered:** +- Delete at end of S04 — leaves dual-write window open longer; rejected. +- Leave orphaned (don't delete) — leaves cruft; rejected. + +### Full scope: SF + pi-coding-agent + +**Decision:** Consolidate both SF's `memory-store.js`/`knowledge-injector.js` AND pi-coding-agent's memory extension into `sf.db`. + +**Rationale:** The memory extension's extraction pipeline is the primary source of extracted memories. If it still writes to `agent.db`, the consolidation is incomplete. Porting it to write to `sf.db` via `MemoryStore` is the correct scope. + +## Error Handling Strategy + +- **DB unavailable:** All `memory-store.js` functions degrade gracefully — return `[]` / `null` / `false` instead of throwing. `capture_thought` tool returns an error message, not a crash. +- **Migration failures:** S03 script skips corrupted records with a warning, continues processing remaining entries, and reports final counts. Never partially migrates without reporting. +- **LLM extraction failures:** Session startup extraction runs fire-and-forget; errors are caught and logged but do not block dispatch. +- **Token budget overflow:** `formatMemoriesForPrompt` respects `tokenBudget` parameter (~4 chars/token) and truncates at budget. Category grouping preserves priority order (gotcha → convention → architecture → pattern → environment → preference). + +## Risks and Unknowns + +- **Data loss during migration** — Users may have valuable accumulated memories in `agent.db` and `KNOWLEDGE.md` that would be lost if migration fails. **Mitigation:** Dry-run mode reports counts without modifying DB; automatic backup of `sf.db` before migration; skip-on-error with warning for corrupted records. +- **WAL contention on `sf.db`** — The `sf.db` already has a single-writer invariant. Adding memory extraction writes during session startup could create lock contention. **Mitigation:** Extraction runs fire-and-forget (does not block dispatch). If contention occurs, the single-writer invariant ensures serialized writes. +- **Breaking memory extension API contract** — The memory extension is a Pi extension with hooks and commands. Changing its storage backend changes observable behavior for external consumers. **Mitigation:** The `/memory` command output format is preserved; migration script ensures no data loss. +- **`capture_thought`/`memory_query` registration scope** — These tools should be registered in the pi-agent-core tool registry. The registration point needs to be identified before S01 implementation. +- **Node.js version requirement** — `node:sqlite` (DatabaseSync) requires Node >= 24. The project currently documents this as a minimum version. No change needed. + +## Existing Codebase / Prior Art + +- `src/resources/extensions/sf/memory-store.js` — Source of truth for the existing function-based API; already uses `node:sqlite` via `sf-db.js`. **Not to be rewritten; extended.** +- `src/resources/extensions/sf/sf-db.js` — Single-writer SQLite adapter using `node:sqlite` DatabaseSync. **Already correct; no changes needed.** +- `src/resources/extensions/sf/memory-embeddings.js` — LLM gateway for embedding computation. **Pre-existing; out of scope.** +- `src/resources/extensions/sf/memory-embeddings-llm-gateway.js` — Cross-encoder reranking. **Pre-existing; out of scope.** +- `packages/pi-coding-agent/src/resources/extensions/memory/storage.ts` — `sql.js`-based `MemoryStorage` class. **Replaced in S02.** +- `packages/pi-coding-agent/src/resources/extensions/memory/pipeline.ts` — Two-phase extraction pipeline. **Ported to `sf.db` in S02.** +- `src/resources/extensions/vectordrive/` — Rust N-API vector database. **Pre-existing; embedding integration deferred to future milestone.** +- `src/resources/extensions/sf/knowledge-injector.js` — Markdown knowledge parser and semantic similarity. **Removed or no-op'd in S03.** + +## Relevant Requirements + +- **Unified memory storage** — Covered: all three systems consolidate into `sf.db`. +- **Semantic search** — Covered: `getRelevantMemoriesRanked` with cosine + relation boost + optional rerank. +- **Session-based learning** — Covered: extraction pipeline ports to `sf.db` in S02. +- **Cross-session context persistence** — Partially covered: memories survive across sessions via `sf.db`. Multi-project sharing deferred. + +## Scope + +### In Scope + +- Add `source` column to `memories` table in `sf.db` +- Register `capture_thought` and `memory_query` as native pi tools with TypeBox schemas +- Port memory extension extraction pipeline from `sql.js`/`agent.db` to `sf.db` via `memory-store.js` functions +- Migration script: `KNOWLEDGE.md` → `sf.db` and `agent.db` → `sf.db` +- Behavioral regression test (shell/Playwright) for end-to-end verification +- Remove or no-op `knowledge-injector.js` after migration +- Remove `sql.js` dependency from `packages/pi-coding-agent` +- Remove `memory_embeddings` table and embedding code **NOT in scope** — pre-existing, functional + +### Out of Scope / Non-Goals + +- Redesigning the embedding infrastructure (VectorDrive wiring, pure-JS vectors) — deferred to future milestone +- Multi-project memory sharing or cloud sync +- Changing the `memory-embeddings.js` / `memory-embeddings-llm-gateway.js` modules +- Changing `sf-db.js` schema initialization logic +- Supporting Node < 24 + +## Technical Constraints + +- **Node >= 24 required** — `node:sqlite` DatabaseSync is built-in since Node 24. Earlier versions would need a polyfill or different approach. +- **Single-writer invariant on `sf.db`** — `sf-db.js` is the only writer. Memory functions must go through the adapter, not direct SQL. +- **`sql.js` WASM bundle** — Currently in `packages/pi-coding-agent/package.json`. Removing it requires updating the build output and verifying no other packages depend on it. + +## Integration Points + +- **LLM provider** — Extraction pipeline calls `completeSimple` for phase 1 (memory extraction) and phase 2 (consolidation). No API key changes needed. +- **`sf.db`** — Canonical store. Schema already has `memories` table; only needs `source` column added. +- **`agent.db`** — Legacy store. Migrated in S03, then deleted. +- **`KNOWLEDGE.md`** — Legacy file. Migrated in S03, then read-only fallback (removed from injection path). +- **pi-coding-agent package** — Owns the extraction pipeline and `/memory` command. S02 rewires it to `sf.db`. +- **VectorDrive** — Pre-existing vector DB. Embedding integration deferred. + +## Testing Requirements + +- **Unit tests (S01):** CRUD operations on `memories` table, ranking formula (`confidence * (1 + hit_count * 0.1)`), source filtering, graceful degradation when DB unavailable, `formatMemoriesForPrompt` truncation and category grouping. +- **Contract tests (S02):** Pipeline writes to `sf.db` with correct `source` value; `/memory view` reads from `sf.db`; fire-and-forget does not block dispatch. +- **Migration tests (S03):** Dry-run reports correct counts; backup created before migration; `KNOWLEDGE.md` entries imported with `source='migrated'`; `agent.db` stage1_outputs imported with `source='extracted'`; skip-on-error for corrupted records. +- **Behavioral regression test (S04):** Playwright or shell test that starts a session, triggers extraction, and asserts `/memory view` output contains entries from `sf.db`. + +## Acceptance Criteria + +1. `sf.db` `memories` table has `source` column; all `memory-store.js` functions accept/return `source` field. +2. `capture_thought` and `memory_query` are registered native pi tools with TypeBox schemas and are called without errors. +3. Session extraction pipeline writes to `sf.db` with `source='extracted'`; `/memory view` reads from `sf.db`. +4. S03 migration script: dry-run mode reports correct counts; backup created; `agent.db` and `KNOWLEDGE.md` entries imported; old files removed. +5. `grep` finds zero `sql.js` or `better-sqlite3` imports in memory-related code paths. +6. Behavioral regression test passes: `/memory view` output originates from `sf.db`. + +## Open Questions + +- **`capture_thought`/`memory_query` registration point** — These tools should be registered in `pi-agent-core`'s tool registry or the sf-run bootstrap. The exact registration module needs to be identified before S01 implementation. Current hypothesis: `src/resources/extensions/sf/` bootstrap or a new `memory-tools.js` module. **TBD: investigate `sf-run` tool registration flow.** +- **S04 behavioral test format** — Playwright (requires browser) or shell script (requires `sf` binary)? Shell script with `--print` output parsing is simpler and faster in CI. **Decision needed: test framework for behavioral regression.** diff --git a/src/resources/extensions/sf/auto-verification.js b/src/resources/extensions/sf/auto-verification.js index c8a979eb4..29d02df9d 100644 --- a/src/resources/extensions/sf/auto-verification.js +++ b/src/resources/extensions/sf/auto-verification.js @@ -279,7 +279,20 @@ export async function runPostUnitVerification(vctx, pauseAuto) { if (uokFlags.securityGuard) { gateRunner.register(new SecurityGate()); } - await gateRunner.run("verification-gate", { + if (uokFlags.multiPackageHealing) { + gateRunner.register(new MultiPackageGate()); + } + if (uokFlags.autonomousCostGuard) { + gateRunner.register(new CostGuardGate()); + } + if (uokFlags.outcomeLearning) { + gateRunner.register(new OutcomeLearningGate()); + } + if (uokFlags.chaosMonkey) { + gateRunner.register(new ChaosMonkeyGate({ active: true })); + } + + const baseCtx = { basePath: s.basePath, traceId: `verification:${s.currentUnit.id}`, turnId: s.currentUnit.id, @@ -288,92 +301,44 @@ export async function runPostUnitVerification(vctx, pauseAuto) { taskId: tid ?? undefined, unitType: s.currentUnit.type, unitId: s.currentUnit.id, - }); - if (uokFlags.securityGuard) { - const secResult = await gateRunner.run("security-guard", { - basePath: s.basePath, - traceId: `security-guard:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid ?? undefined, - sliceId: sid ?? undefined, - taskId: tid ?? undefined, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - if (secResult.outcome === "fail") { - result.passed = false; + iteration: s.verificationRetryCount.get(s.currentUnit.id) ?? 0, + }; + + const gateIds = gateRunner.list().map((g) => g.id); + const gateResults = await Promise.all( + gateIds.map((id) => + gateRunner + .run(id, { + ...baseCtx, + traceId: `${id}:${s.currentUnit.id}`, + }) + .catch((err) => ({ + outcome: "fail", + failureClass: "unknown", + rationale: `Gate ${id} threw: ${err instanceof Error ? err.message : String(err)}`, + })), + ), + ); + + for (let i = 0; i < gateIds.length; i++) { + const id = gateIds[i]; + const res = gateResults[i]; + if (res.outcome !== "fail") continue; + result.passed = false; + if (id === "security-guard") { result.securityFailure = true; - result.securityRationale = secResult.rationale; - result.securityFindings = secResult.findings; - } - } - if (uokFlags.multiPackageHealing) { - gateRunner.register(new MultiPackageGate()); - const mpResult = await gateRunner.run("multi-package-healing", { - basePath: s.basePath, - traceId: `multi-package-healing:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid ?? undefined, - sliceId: sid ?? undefined, - taskId: tid ?? undefined, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - if (mpResult.outcome === "fail") { - result.passed = false; + result.securityRationale = res.rationale; + result.securityFindings = res.findings; + } else if (id === "multi-package-healing") { result.multiPackageFailure = true; - result.multiPackageRationale = mpResult.rationale; - result.multiPackageFindings = mpResult.findings; - } - } - if (uokFlags.autonomousCostGuard) { - gateRunner.register(new CostGuardGate()); - const cgResult = await gateRunner.run("cost-guard", { - basePath: s.basePath, - traceId: `cost-guard:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid ?? undefined, - sliceId: sid ?? undefined, - taskId: tid ?? undefined, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - iteration: s.verificationRetryCount.get(s.currentUnit.id) ?? 0, - }); - if (cgResult.outcome === "fail") { - result.passed = false; + result.multiPackageRationale = res.rationale; + result.multiPackageFindings = res.findings; + } else if (id === "cost-guard") { result.costGuardFailure = true; - result.costGuardRationale = cgResult.rationale; - } - } - if (uokFlags.outcomeLearning) { - gateRunner.register(new OutcomeLearningGate()); - await gateRunner.run("outcome-learning", { - basePath: s.basePath, - traceId: `outcome-learning:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid ?? undefined, - sliceId: sid ?? undefined, - taskId: tid ?? undefined, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - } - if (uokFlags.chaosMonkey) { - gateRunner.register(new ChaosMonkeyGate({ active: true })); - const cmResult = await gateRunner.run("chaos-monkey", { - basePath: s.basePath, - traceId: `chaos-monkey:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid ?? undefined, - sliceId: sid ?? undefined, - taskId: tid ?? undefined, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - if (cmResult.outcome === "fail") { - result.passed = false; + result.costGuardRationale = res.rationale; + } else if (id === "chaos-monkey") { result.chaosMonkeyFailure = true; - result.chaosMonkeyRationale = cmResult.rationale; + result.chaosMonkeyRationale = res.rationale; } } } diff --git a/src/resources/extensions/sf/notification-store.js b/src/resources/extensions/sf/notification-store.js index 51698669c..7c8f63476 100644 --- a/src/resources/extensions/sf/notification-store.js +++ b/src/resources/extensions/sf/notification-store.js @@ -21,6 +21,7 @@ import { sfRuntimeRoot } from "./paths.js"; const MAX_ENTRIES = 500; const FILENAME = "notifications.jsonl"; const LOCKFILE = "notifications.lock"; +const NOTIFICATION_SCHEMA_VERSION = 1; const DEDUP_WINDOW_MS = 30_000; const DURABLE_DEDUP_WINDOW_MS = 60 * 60 * 1000; const DEDUP_PRUNE_THRESHOLD = 200; @@ -115,6 +116,7 @@ export function appendNotification( return; } const entry = { + schemaVersion: NOTIFICATION_SCHEMA_VERSION, id: randomUUID(), ts: new Date().toISOString(), severity: normalizedSeverity, @@ -294,7 +296,7 @@ function _readEntriesFromDisk(basePath) { .filter((l) => l.length > 0) .map((l) => { try { - return JSON.parse(l); + return normalizeNotificationEntry(JSON.parse(l)); } catch { return null; } @@ -304,6 +306,16 @@ function _readEntriesFromDisk(basePath) { return []; } } +function normalizeNotificationEntry(entry) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + const schemaVersion = entry.schemaVersion ?? NOTIFICATION_SCHEMA_VERSION; + if (schemaVersion !== NOTIFICATION_SCHEMA_VERSION) return null; + return { + ...entry, + schemaVersion, + read: entry.read === true, + }; +} function hasRecentPersistedDuplicate(basePath, keySeed, now) { const normalizedKey = normalizeDedupKey(keySeed); const entries = _readEntriesFromDisk(basePath); diff --git a/src/resources/extensions/sf/tests/notification-detection-headless-medium-low.test.mjs b/src/resources/extensions/sf/tests/notification-detection-headless-medium-low.test.mjs index 32a41806b..fe30138ac 100644 --- a/src/resources/extensions/sf/tests/notification-detection-headless-medium-low.test.mjs +++ b/src/resources/extensions/sf/tests/notification-detection-headless-medium-low.test.mjs @@ -7,6 +7,7 @@ import { _resetNotificationStore, appendNotification, initNotificationStore, + readNotifications, } from "../notification-store.js"; describe("S08 MEDIUM: notification + detection + headless", () => { @@ -62,6 +63,28 @@ describe("S08 MEDIUM: notification + detection + headless", () => { ); const lines = content.trim().split("\n").filter(Boolean); expect(lines.length).toBe(1); + expect(JSON.parse(lines[0]).schemaVersion).toBe(1); + }); + + it("should treat legacy notifications without schemaVersion as version 1", () => { + const filePath = join(testDir, ".sf", "notifications.jsonl"); + mkdirSync(join(testDir, ".sf"), { recursive: true }); + writeFileSync( + filePath, + JSON.stringify({ + id: "legacy-1", + ts: "2026-05-07T00:00:00.000Z", + severity: "warning", + message: "legacy warning", + source: "test", + }) + "\n", + "utf-8", + ); + + const [entry] = readNotifications(testDir); + + expect(entry.schemaVersion).toBe(1); + expect(entry.read).toBe(false); }); }); diff --git a/src/resources/extensions/sf/tests/phases-fsm.test.ts b/src/resources/extensions/sf/tests/phases-fsm.test.ts new file mode 100644 index 000000000..520addefe --- /dev/null +++ b/src/resources/extensions/sf/tests/phases-fsm.test.ts @@ -0,0 +1,457 @@ +/** + * Phase 3: Property-based tests for FSM correctness using fast-check. + * + * Purpose: Generate arbitrary dispatch sequences and verify FSM invariants: + * 1. Every unit reaches a terminal state (done/failed/blocked) + * 2. State transitions are valid (no illegal combinations) + * 3. Invariants hold under arbitrary input + * 4. No infinite loops or stuck states + * + * Consumer: auto-dispatch FSM uses state transitions; property tests verify + * correctness across all possible paths, not just happy paths. + */ + +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; + +// ─── FSM State & Transition Model ─────────────────────────────────────────── + +const FSM_STATES = { + PENDING: "pending", + RUNNING: "running", + DONE: "done", + FAILED: "failed", + BLOCKED: "blocked", +}; + +const TERMINAL_STATES = new Set([FSM_STATES.DONE, FSM_STATES.FAILED]); // BLOCKED is not terminal! + +/** Valid state transitions for dispatch FSM */ +const VALID_TRANSITIONS = { + [FSM_STATES.PENDING]: [FSM_STATES.RUNNING, FSM_STATES.BLOCKED], + [FSM_STATES.RUNNING]: [FSM_STATES.DONE, FSM_STATES.FAILED, FSM_STATES.BLOCKED], + [FSM_STATES.DONE]: [], + [FSM_STATES.FAILED]: [], + [FSM_STATES.BLOCKED]: [FSM_STATES.PENDING, FSM_STATES.RUNNING], // Can retry +}; + +/** Apply a transition to a unit state */ +function transition(currentState, nextState) { + if (!VALID_TRANSITIONS[currentState]) { + throw new Error(`Invalid current state: ${currentState}`); + } + if (!VALID_TRANSITIONS[currentState].includes(nextState)) { + throw new Error(`Invalid transition: ${currentState} → ${nextState}`); + } + return nextState; +} + +/** Check if a state is terminal (no more transitions possible) */ +function isTerminal(state) { + return TERMINAL_STATES.has(state); +} + +// ─── Arbitraries for Property Generation ──────────────────────────────────── + +/** Generate arbitrary unit IDs */ +const arbitraryUnitId = () => + fc.string({ minLength: 3, maxLength: 10 }); + +/** Generate valid state transitions */ +const arbitraryTransition = (fromState) => { + const validNext = VALID_TRANSITIONS[fromState]; + return fc.constantFrom(...validNext); +}; + +/** Generate arbitrary dispatch events */ +const arbitraryDispatchEvent = () => + fc.record({ + unitId: arbitraryUnitId(), + eventType: fc.constantFrom("start", "complete", "fail", "block", "unblock"), + timestamp: fc.integer({ min: 0, max: 1000000 }), + }); + +/** Generate a sequence of arbitrary units with random initial states */ +const arbitraryUnitSequence = () => + fc.array( + fc.record({ + id: arbitraryUnitId(), + status: fc.constantFrom( + FSM_STATES.PENDING, + FSM_STATES.RUNNING, + FSM_STATES.DONE, + FSM_STATES.FAILED, + FSM_STATES.BLOCKED, + ), + }), + { minLength: 1, maxLength: 50 }, + ); + +// ─── FSM Simulator ────────────────────────────────────────────────────────── + +/** Simulate a single unit through the FSM */ +function simulateUnit(initialState, events) { + let state = initialState; + const history = [state]; + + for (const event of events) { + if (isTerminal(state)) { + break; // Terminal state, no more transitions + } + + let nextState; + switch (event) { + case "start": + if (state === FSM_STATES.PENDING) { + nextState = FSM_STATES.RUNNING; + } + break; + case "complete": + if (state === FSM_STATES.RUNNING) { + nextState = FSM_STATES.DONE; + } + break; + case "fail": + if (state === FSM_STATES.RUNNING) { + nextState = FSM_STATES.FAILED; + } + break; + case "block": + if (state === FSM_STATES.RUNNING) { + nextState = FSM_STATES.BLOCKED; + } + break; + case "unblock": + if (state === FSM_STATES.BLOCKED) { + nextState = FSM_STATES.PENDING; + } + break; + } + + if (nextState) { + state = nextState; + history.push(state); + } + } + + return { finalState: state, history }; +} + +// ─── Property Tests ───────────────────────────────────────────────────────── + +describe("FSM property-based tests", () => { + describe("FSM invariants", () => { + it("every unit reaches terminal state with complete events", () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + id: arbitraryUnitId(), + status: fc.constantFrom(FSM_STATES.PENDING), + }), + { minLength: 1, maxLength: 20 }, + ), + (units) => { + // Use a complete path: start → complete + const events = ["start", "complete"]; + const results = units.map((u) => simulateUnit(u.status, events)); + // All should reach terminal state (DONE) + return results.every((r) => r.finalState === FSM_STATES.DONE); + }, + ), + { numRuns: 50 }, + ); + }); + + it("state transitions are never invalid (INVARIANT 2)", () => { + fc.assert( + fc.property( + fc.constant(FSM_STATES.PENDING), + fc.array(fc.constantFrom("start", "complete", "fail", "block"), { + minLength: 1, + maxLength: 50, + }), + (initialState, events) => { + try { + simulateUnit(initialState, events); + return true; // All transitions valid + } catch (err) { + if (err.message.includes("Invalid transition")) { + return false; // Found invalid transition + } + throw err; + } + }, + ), + ); + }); + + it("terminal states have no outgoing transitions (INVARIANT 3)", () => { + fc.assert( + fc.property( + fc.constantFrom(FSM_STATES.DONE, FSM_STATES.FAILED), + fc.array(fc.constantFrom("start", "complete", "fail", "block"), { + minLength: 1, + maxLength: 10, + }), + (terminalState, events) => { + const result = simulateUnit(terminalState, events); + // Terminal state (DONE, FAILED) should not change + return result.finalState === terminalState; + }, + ), + ); + }); + + it("dispatch always terminates (no infinite loops)", () => { + fc.assert( + fc.property( + arbitraryUnitSequence(), + fc.array(fc.constantFrom("start", "complete", "fail", "block"), { + minLength: 1, + maxLength: 100, + }), + (units, events) => { + // Simulate with timeout check + const startTime = Date.now(); + const results = units.map((u) => simulateUnit(u.status, events)); + const elapsed = Date.now() - startTime; + + // Should complete quickly (within 100ms for reasonable input) + return elapsed < 100 && results.every((r) => r.history.length > 0); + }, + ), + { numRuns: 50 }, + ); + }); + }); + + describe("state transition correctness", () => { + it("pending → running → done is valid", () => { + fc.assert( + fc.property(arbitraryUnitId(), (unitId) => { + const result = simulateUnit(FSM_STATES.PENDING, ["start", "complete"]); + return result.finalState === FSM_STATES.DONE; + }), + ); + }); + + it("pending → running → failed is valid", () => { + fc.assert( + fc.property(arbitraryUnitId(), (unitId) => { + const result = simulateUnit(FSM_STATES.PENDING, ["start", "fail"]); + return result.finalState === FSM_STATES.FAILED; + }), + ); + }); + + it("pending → running → blocked → pending (retry) is valid", () => { + fc.assert( + fc.property(arbitraryUnitId(), (unitId) => { + const result = simulateUnit(FSM_STATES.PENDING, ["start", "block", "unblock"]); + return result.finalState === FSM_STATES.PENDING; + }), + ); + }); + + it("once done, cannot transition (final)", () => { + fc.assert( + fc.property( + fc.array(fc.constantFrom("start", "complete", "fail", "block"), { + maxLength: 100, + }), + (events) => { + const result = simulateUnit(FSM_STATES.DONE, events); + return result.finalState === FSM_STATES.DONE; + }, + ), + ); + }); + }); + + describe("concurrent dispatch", () => { + it("FSM handles arbitrary unit sequences without errors", () => { + fc.assert( + fc.property( + arbitraryUnitSequence(), + fc.array(fc.constantFrom("start", "complete", "fail", "block", "unblock"), { + maxLength: 50, + }), + (units, events) => { + try { + units.map((u) => simulateUnit(u.status, events)); + return true; // Success - no crashes + } catch (err) { + return false; // Should not throw + } + }, + ), + { numRuns: 50 }, + ); + }); + + it("valid transitions sequence works correctly", () => { + const units = [ + { id: "u-001", status: FSM_STATES.PENDING }, + { id: "u-002", status: FSM_STATES.PENDING }, + { id: "u-003", status: FSM_STATES.PENDING }, + ]; + // Events that form a valid path: PENDING → RUNNING → DONE + const events = ["start", "complete"]; + + const results = units.map((u) => simulateUnit(u.status, events)); + + // All units should reach DONE state + expect(results.every((r) => r.finalState === FSM_STATES.DONE)).toBe(true); + }); + }); + + describe("error scenarios and degradation", () => { + it("FSM processes events without throwing", () => { + fc.assert( + fc.property( + arbitraryUnitSequence(), + fc.array(fc.constantFrom("start", "complete", "fail", "block", "unblock"), { + maxLength: 100, + }), + (units, events) => { + try { + units.map((u) => simulateUnit(u.status, events)); + return true; // Success - no crashes + } catch (err) { + return false; // Should not throw + } + }, + ), + { numRuns: 50 }, + ); + }); + + it("specific valid transitions work correctly", () => { + // PENDING → RUNNING → DONE + let result = simulateUnit(FSM_STATES.PENDING, ["start", "complete"]); + expect(result.finalState).toBe(FSM_STATES.DONE); + + // PENDING → RUNNING → FAILED + result = simulateUnit(FSM_STATES.PENDING, ["start", "fail"]); + expect(result.finalState).toBe(FSM_STATES.FAILED); + + // PENDING → RUNNING → BLOCKED → PENDING + result = simulateUnit(FSM_STATES.PENDING, ["start", "block", "unblock"]); + expect(result.finalState).toBe(FSM_STATES.PENDING); + }); + }); + + describe("state history coherence", () => { + it("state history has no invalid transitions", () => { + fc.assert( + fc.property( + arbitraryUnitSequence(), + fc.array(fc.constantFrom("start", "complete", "fail", "block"), { + minLength: 1, + maxLength: 50, + }), + (units, events) => { + const results = units.map((u) => simulateUnit(u.status, events)); + + return results.every((r) => { + // Check each transition in history + for (let i = 1; i < r.history.length; i++) { + const from = r.history[i - 1]; + const to = r.history[i]; + if (!VALID_TRANSITIONS[from].includes(to)) { + return false; + } + } + return true; + }); + }, + ), + { numRuns: 100 }, + ); + }); + + it("initial state is always in history", () => { + fc.assert( + fc.property( + fc.constantFrom(...Object.values(FSM_STATES)), + fc.array(fc.constantFrom("start", "complete", "fail", "block"), { + minLength: 1, + maxLength: 50, + }), + (initialState, events) => { + const result = simulateUnit(initialState, events); + return result.history[0] === initialState; + }, + ), + ); + }); + }); + + describe("performance under adversarial input", () => { + it("handles large unit count without degradation", () => { + fc.assert( + fc.property( + fc.integer({ min: 100, max: 500 }).chain((count) => + fc.constant(Array(count).fill({ status: FSM_STATES.PENDING })), + ), + fc.array(fc.constantFrom("start", "complete"), { + minLength: 1, + maxLength: 5, + }), + (units, events) => { + const start = Date.now(); + units.forEach((u) => simulateUnit(u.status, events)); + const elapsed = Date.now() - start; + + // Should handle 500 units in <1s + return elapsed < 1000; + }, + ), + { numRuns: 5 }, + ); + }); + + it("handles long event sequences without memory leak", () => { + fc.assert( + fc.property( + fc.array(fc.constantFrom("start", "complete", "fail", "block"), { + minLength: 10, + maxLength: 500, + }), + (events) => { + const result = simulateUnit(FSM_STATES.PENDING, events); + // History should be reasonable size (not unbounded) + return result.history.length < events.length + 10; + }, + ), + { numRuns: 20 }, + ); + }); + }); +}); + +// ─── Shrinking Verification ───────────────────────────────────────────────── + +describe("FSM shrinking verification", () => { + it("fast-check shrinks to minimal failing input", () => { + // This test verifies fast-check can shrink complex failing cases to simple ones + const prop = (units) => { + // Deliberately fail on exactly 5 units + if (units.length === 5) { + return false; + } + return true; + }; + + let minimalFailure = null; + try { + fc.assert(fc.property(arbitraryUnitSequence(), prop)); + } catch (err) { + minimalFailure = err.message; + } + + // Should have found the minimal case + expect(minimalFailure).toBeDefined(); + }); +}); diff --git a/src/resources/extensions/sf/uok/gate-runner.js b/src/resources/extensions/sf/uok/gate-runner.js index 5d473e919..0ac0ca764 100644 --- a/src/resources/extensions/sf/uok/gate-runner.js +++ b/src/resources/extensions/sf/uok/gate-runner.js @@ -21,14 +21,25 @@ const RETRY_MATRIX = { unknown: 0, }; -function resolveCircuitBreakerThresholds() { +function envKeyForGate(gateId, suffix) { + const normalized = gateId.replace(/-/g, "_").toUpperCase(); + return process.env[`SF_CIRCUIT_BREAKER_${normalized}_${suffix}`]; +} + +function resolveCircuitBreakerThresholds(gateId) { return { failureThreshold: - Number(process.env.SF_CIRCUIT_BREAKER_FAILURE_THRESHOLD) || 5, + Number(envKeyForGate(gateId, "THRESHOLD")) || + Number(process.env.SF_CIRCUIT_BREAKER_FAILURE_THRESHOLD) || + 5, openDurationMs: - Number(process.env.SF_CIRCUIT_BREAKER_OPEN_DURATION_MS) || 60_000, + Number(envKeyForGate(gateId, "OPEN_DURATION_MS")) || + Number(process.env.SF_CIRCUIT_BREAKER_OPEN_DURATION_MS) || + 60_000, halfOpenMaxAttempts: - Number(process.env.SF_CIRCUIT_BREAKER_HALF_OPEN_MAX_ATTEMPTS) || 3, + Number(envKeyForGate(gateId, "HALF_OPEN_MAX_ATTEMPTS")) || + Number(process.env.SF_CIRCUIT_BREAKER_HALF_OPEN_MAX_ATTEMPTS) || + 3, }; } @@ -51,13 +62,20 @@ export class UokGateRunner { getHealthSummary() { const gates = this.list(); + const ids = + gates.length > 0 + ? gates.map((g) => g.id) + : getDistinctGateIds().length > 0 + ? getDistinctGateIds() + : []; return { - gates: gates.map((g) => { - const stats = getGateRunStats(g.id, 24); - const cb = getGateCircuitBreaker(g.id); + gates: ids.map((id) => { + const stats = getGateRunStats(id, 24); + const cb = getGateCircuitBreaker(id); + const registered = this.registry.get(id); return { - id: g.id, - type: g.type, + id, + type: registered?.type ?? "unknown", ...stats, circuitBreaker: cb.state, failureStreak: cb.failureStreak, @@ -68,7 +86,7 @@ export class UokGateRunner { _checkCircuitBreaker(gateId) { const { openDurationMs, halfOpenMaxAttempts } = - resolveCircuitBreakerThresholds(); + resolveCircuitBreakerThresholds(gateId); const breaker = getGateCircuitBreaker(gateId); if (breaker.state === "open") { const openedAt = breaker.openedAt ? Date.parse(breaker.openedAt) : 0; @@ -127,7 +145,7 @@ export class UokGateRunner { }); return; } - const { failureThreshold } = resolveCircuitBreakerThresholds(); + const { failureThreshold } = resolveCircuitBreakerThresholds(gateId); if (nextStreak >= failureThreshold) { updateGateCircuitBreaker(gateId, { state: "open",