feat(sf): align node sqlite uok runtime

This commit is contained in:
Mikael Hugo 2026-05-08 02:52:05 +02:00 committed by Mikael Hugo
parent 760564dbfb
commit 19bfc3d3f6
93 changed files with 30516 additions and 27240 deletions

View file

@ -77,7 +77,7 @@ body:
attributes:
label: Node.js version
description: Run `node --version`.
placeholder: "e.g. v24.14.0"
placeholder: "e.g. v26.1.0"
- type: input
id: os

View file

@ -106,7 +106,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: "https://registry.npmjs.org"
cache: "npm"

View file

@ -105,7 +105,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
- name: Validate skill references
run: node scripts/check-skill-references.mjs
@ -129,7 +129,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
cache: 'npm'
- name: Install dependencies
@ -181,7 +181,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
cache: 'npm'
- name: Install dependencies
@ -225,7 +225,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
cache: 'npm'
- name: Install dependencies
@ -273,7 +273,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
cache: 'npm'
- name: Install dependencies

View file

@ -15,7 +15,7 @@ jobs:
steps:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
- name: Unpublish old dev versions

View file

@ -40,7 +40,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'
@ -111,7 +111,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'

View file

@ -39,7 +39,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'
@ -102,7 +102,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'

View file

@ -38,7 +38,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'
@ -96,7 +96,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'
@ -165,7 +165,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'

View file

@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
# Use the GitHub API to get changed files — no fork code is executed.
- name: Get changed files

View file

@ -30,7 +30,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '24.15'
node-version: '26.1'
registry-url: https://registry.npmjs.org
cache: 'npm'

2
.mise.toml Normal file
View file

@ -0,0 +1,2 @@
[tools]
node = "26.1.0"

View file

@ -1 +1 @@
24.15.0
26.1.0

2
.nvmrc
View file

@ -1 +1 @@
24.15.0
26.1.0

View file

@ -303,7 +303,7 @@ Open the repository in VS Code with the Dev Containers extension, or run:
devcontainer up --workspace-folder .
```
The container includes Node 24, Rust, GitHub CLI, Docker-in-Docker, and recommended VS Code extensions.
The container includes Node 26, Rust, GitHub CLI, Docker-in-Docker, and recommended VS Code extensions.
## Dependency Updates

View file

@ -3,7 +3,7 @@
# Image: ghcr.io/singularity-ng/singularity-foundry
# Used by: end users via docker run
# ──────────────────────────────────────────────
FROM node:24.15-slim AS runtime
FROM node:26.1-slim AS runtime
# Git is required for SF's git operations
RUN apt-get update && apt-get install -y --no-install-recommends \

View file

@ -10,7 +10,7 @@ Sources checked 2026-05-08:
<https://github.com/features/copilot/cli>
- GitHub Changelog, "GitHub Copilot CLI is now generally available"
<https://github.blog/changelog/2026-02-25-github-copilot-cli-is-now-generally-available/>
- GitHub Changelog, "GitHub Copilot CLI now supports BYOK and local models"
- GitHub Changelog, "Copilot CLI now supports BYOK and local models"
<https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/>
- Factory Droid, "Autonomy Level"
<https://docs.factory.ai/cli/user-guides/auto-run>
@ -102,6 +102,8 @@ modelMode: fast | smart | deep
surface: tui | web | headless | rpc
```
Note: `repair` is a `workMode`, not a separate subsystem. The `/doctor` command is the diagnostic engine; `/repair` switches `workMode` to `repair`.
Examples:
```text
@ -295,9 +297,9 @@ Required surfaces:
This should not use `/sf`.
## Repair Mode
## Repair Work Mode
`repair` is the right name for doctor-like work.
`repair` is a `workMode`, not a separate subsystem.
Commands:
@ -434,7 +436,7 @@ Direct command:
It should show:
- autonomous units
- autonomous units (durable state: todo | in_progress | review | done | retrying | failed | cancelled)
- parallel workers
- scheduled autonomous dispatches
- background shell sessions
@ -443,10 +445,12 @@ It should show:
- current cost/budget state
- last checkpoint and next action
Task lifecycle uses ORCH-style states. `todo` means ready to run, not "queued."
This complements, not replaces:
- `/status`
- `/queue`
- `/queue` (milestone dispatch order, not task state)
- `/parallel status`
- `/session-report`
- `/logs`
@ -1040,3 +1044,176 @@ If Node 26 passes those gates, SF should run itself on Node 26 internally even
before raising public `engines.node`. Once stable, raise the repo baseline and
start replacing fragile `Date`/millisecond logic with Temporal in the schedule,
lease, journal, and background task surfaces.
---
## Appendix A: Related Source Files
This section maps the concepts in this document to actual code in the repo.
### A.1 Operating Model (Already Exists)
**File:** `src/resources/extensions/sf/operating-model.js`
Already exports canonical vocabulary:
```js
export const RUN_CONTROL_MODES = ["manual", "assisted", "autonomous"];
export const PERMISSION_PROFILES = ["restricted", "normal", "trusted", "unrestricted"];
```
Tests: `src/resources/extensions/sf/tests/operating-model.test.mjs`
**Gap:** No `workMode` or `modelMode` constants yet. Add to this file.
### A.2 Execution Policy (Already Exists)
**File:** `src/resources/extensions/sf/execution-policy.js`
Maps permission profiles to concrete tool restrictions:
```js
EXECUTION_POLICY_PROFILES = {
restricted: { filesystem: "read-mostly", network: "read-only", git: "read-only", mutation: "planning-artifacts-only" },
normal: { filesystem: "workspace-write", network: "allowed", git: "normal", mutation: "workspace" },
trusted: { filesystem: "workspace-write", network: "allowed", git: "normal", mutation: "workspace" },
unrestricted: { filesystem: "danger-full-access", network: "allowed", git: "dangerous", mutation: "host" }
};
```
**Gap:** Not yet wired to tool-call boundaries. Enforcement is in `write-gate.js` and `destructive-guard.js` but not unified.
### A.3 Auto Session State (Already Exists)
**File:** `src/resources/extensions/sf/auto/session.js`
`AutoSession` class holds:
- `active`, `paused`, `stepMode`, `canAskUser`
- `currentUnit`, `currentMilestoneId`
- `autoModeStartModel`, `currentUnitModel`
**Gap:** No `workMode` property. Add to `AutoSession` and `reset()`.
### A.4 Command Registration (Already Exists)
**File:** `src/resources/extensions/sf/commands/index.js`
Registers direct commands via `pi.registerCommand()`:
```js
for (const command of DIRECT_SF_COMMANDS) {
pi.registerCommand(command.cmd, { ... });
}
```
**File:** `src/resources/extensions/sf/commands/catalog.js`
Defines `TOP_LEVEL_SUBCOMMANDS` and `DIRECT_SF_COMMANDS`.
**Gap:** Commands still use `/sf` prefix in user-facing strings. `SF_COMMAND_DESCRIPTION` lists `/sf help|start|...`.
### A.5 TUI Extension (Already Exists)
**File:** `src/resources/extensions/sf-tui/index.js`
Registers shortcuts:
- `Ctrl+Alt+H` — prompt history
- `Ctrl+Shift+H` — prompt history fallback
- `Ctrl+Alt+M` — marketplace
**File:** `src/resources/extensions/sf-tui/header.js`
Renders header with project name, branch, model. No mode badge yet.
**File:** `src/resources/extensions/sf-tui/footer.js`
Renders footer with git status, cost, context usage. No mode badge yet.
**File:** `src/resources/extensions/sf-tui/extension-manifest.json`
Declares hooks: `session_start`, `session_switch`, `before_agent_start`, `tool_result`, `agent_start`, `agent_end`.
**Gap:** No mode badge rendering. No mode-switching shortcuts. Header hidden during auto mode.
### A.6 UOK Parity Report (Already Uses runControl)
**File:** `src/resources/extensions/sf/tests/uok-parity-report.test.mjs`
Tests verify `runControl` and `permissionProfile` in UOK events:
```js
assert.equal(events[0].runControl, "autonomous");
assert.equal(events[0].permissionProfile, "normal");
```
**Gap:** No `workMode` in UOK events yet.
### A.7 Routing History (Already Exists)
**File:** `src/resources/extensions/sf/routing-history.js`
Tracks model tier success/failure per task pattern.
**Gap:** Not yet connected to `modelMode` (`fast`/`smart`/`deep`). Currently uses `light`/`standard`/`heavy` tiers.
### A.8 Doctor System (Already Exists)
**File:** `src/resources/extensions/sf/doctor.js`
**File:** `src/resources/extensions/sf/doctor-proactive.js`
**File:** `src/resources/extensions/sf/doctor-checks.js`
Health checks, auto-fix, proactive monitoring.
**Gap:** No `repair` work mode. Doctor is diagnostic-only, not a workflow.
### A.9 Self-Feedback (Already Exists)
**File:** `src/resources/extensions/sf/self-feedback.js`
Records anomalies, blocking entries, version-bump resolution.
**Gap:** Not connected to `workMode` transitions.
### A.10 Skills (Partially Exists)
**File:** `src/resources/extensions/sf/skill-discovery.js`
**File:** `src/resources/extensions/sf/skill-health.js`
**File:** `src/resources/extensions/sf/skill-telemetry.js`
Skill loading, health monitoring, telemetry.
**Gap:** No `.agents/skills/` directory structure. No YAML frontmatter. No auto-creation flow.
---
## Appendix B: Implementation Priority
| Priority | Item | Files to Touch | Effort |
|----------|------|----------------|--------|
| P0 | Add `workMode` + `modelMode` to `operating-model.js` | `operating-model.js`, `operating-model.test.mjs` | Small |
| P0 | Add `workMode` to `AutoSession` | `auto/session.js`, `auto.js` | Small |
| P0 | Add mode badge to TUI header | `sf-tui/header.js`, `sf-tui/index.js` | Small |
| P0 | Add mode-switching shortcuts | `sf-tui/index.js`, `extension-manifest.json` | Small |
| P0 | Deprecate `/sf` prefix in commands | `commands/catalog.js`, `commands/index.js` | Medium |
| P1 | Add `/mode`, `/control`, `/trust`, `/model-mode` commands | `commands/handlers/*.js`, `commands/catalog.js` | Medium |
| P1 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/write-gate.js`, `safety/destructive-guard.js` | Medium |
| P1 | Add `/tasks` background work surface | New: `tasks-overlay.js`, `tasks-db.js` | Large |
| P1 | Make `repair` first-class work mode | `commands/handlers/core.js`, `doctor.js` | Medium |
| P2 | Add `.agents/skills/` structure | New: `skills-directory.js`, skill templates | Large |
| P2 | Add skill YAML frontmatter parser | New: `skill-frontmatter.js` | Medium |
| P2 | Add skill eval harness | New: `skill-eval.js`, eval templates | Large |
| P2 | Adopt Temporal in `sf schedule` | `schedule/*.js` | Medium |
| P2 | Node 26 canary | `package.json`, CI | Medium |
---
## Appendix C: Open Questions
1. Should paused autonomous show previous badge dimmed, or `[P]` for paused?
2. Should mode be per-session or per-project? (Current: per-session)
3. Should badge appear in tmux/terminal window titles?
4. Should mode transitions have sound/notification?
5. Should `repair` auto-transition be `ask` by default for new projects?
6. Should skill eval cases run in CI or only on-demand?
7. Should `/tasks` be a TUI overlay or a separate scrollable panel?
8. Should `modelMode` replace or supplement the existing tier system (`light`/`standard`/`heavy`)?

View file

@ -3,7 +3,7 @@
# Image: ghcr.io/sf-build/sf-ci-builder
# Used by: pipeline.yml Dev stage
# ──────────────────────────────────────────────
FROM node:24-bookworm
FROM node:26-bookworm
# Rust toolchain (stable, minimal profile)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal

View file

@ -4,7 +4,7 @@
# Purpose: Isolated environment for SF auto mode
# Usage: docker sandbox create --template ./docker
# ──────────────────────────────────────────────
FROM node:24-bookworm-slim
FROM node:26-bookworm-slim
# System dependencies required by SF
RUN apt-get update && apt-get install -y --no-install-recommends \

View file

@ -249,7 +249,7 @@ Persist learning: when a unit produces a gotcha or anti-pattern, write to sf's m
| Dependency down | Behaviour |
|---|---|
| Native engine (`forge_engine.node`) | Fall back to JS implementations; log degraded mode. Never silently proceed without confirming fallback path is wired. |
| `node:sqlite` and `better-sqlite3` both unavailable | Block DB-owned operations; there is no normal no-DB planning mode. Read files only as human evidence. |
| `node:sqlite` unavailable | Block DB-owned operations; there is no normal no-DB planning mode or alternate SQLite engine fallback. Read files only as human evidence. |
| LLM provider | Try next allowed provider per `~/.sf/preferences.md`; if exhausted, halt unit with `ErrModelUnavailable` (no silent skip). |
| SOPS unavailable | Use already-exported env vars; log that secret refresh is unavailable. Block secret-touching commands. |

View file

@ -48,7 +48,7 @@ UOK Kernel (executes units)
↓ records outcomes via
Unit Runtime (recordUnitOutcomeInMemory)
↓ stores patterns in
Memory System (SQLite, Node 24 native)
Memory System (SQLite, Node 26 native)
↓ queried by
Dispatch (enhanceUnitRankingWithMemory)
↓ boosts scores for matching patterns
@ -70,7 +70,7 @@ Dispatch (enhanceUnitRankingWithMemory)
1. **Maximize kernel + DB** — Single UOK kernel, memory as DB layer, no multiplication
2. **Fire-and-forget async** — Memory never blocks critical path; safe degradation
3. **Existing infrastructure** — SF already has 10 memory modules; no duplication
4. **Node 24 native SQLite** — No external dependencies; efficient storage
4. **Node 26 native SQLite** — No external dependencies; efficient storage
5. **Confidence scoring** — Learned patterns inform but don't dominate decisions
6. **Pure diagnostic gates** — Gate failures become learning opportunities, not gate logic change
@ -119,7 +119,7 @@ All memory operations fail silently without blocking:
- Category assignment
- Unit type extraction
**Phase 2 Tests:** 21 test cases (syntax correct, require Node 24.15)
**Phase 2 Tests:** 21 test cases (syntax correct, require Node 26.1)
- Memory-enhanced ranking
- Embedding computation
- Score boosting formula

View file

@ -2,7 +2,7 @@
## Overview
Singularity-forge includes a **complete autonomous memory system** built on SQLite (Node 24 native) with no external dependencies. The memory system enables SF to:
Singularity-forge includes a **complete autonomous memory system** built on SQLite (Node 26 native) with no external dependencies. The memory system enables SF to:
- **Learn** from unit execution patterns and outcomes
- **Recall** similar past situations for context-aware decisions
@ -286,7 +286,7 @@ memory_sources (
### **sf-db.js** (SQLite Backend)
**Location:** `src/resources/extensions/sf/sf-db.js`
**Purpose:** Core SQLite database abstraction (Node 24 native, no external deps).
**Purpose:** Core SQLite database abstraction (Node 26 native, no external deps).
**Tables:**
- `memories` — Memory entries
@ -295,7 +295,7 @@ memory_sources (
- `memory_sources` — Source tracking
- Plus other SF tables (uok, env, etc.)
**Key Advantage:** Node 24.15+ has native SQLite support (`node:sqlite`)
**Key Advantage:** Node 26.1+ has native SQLite support (`node:sqlite`)
---
@ -443,7 +443,7 @@ const gotchas = await getRelevantMemoriesRanked(
│ ↓ │
│ ┌─────────────────────────────┐ │
│ │ SQLite (sf-db.js) │ │
│ │ Node 24 native sqlite │ │
│ │ Node 26 native sqlite │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
@ -517,7 +517,7 @@ All memory operations follow the **fire-and-forget pattern**:
**Memory is NOT exposed as MCP server.**
- **SF is an MCP *client* only** — SF consumes MCP tools from external services
- **Memory is internal SF infrastructure** — uses SQLite (Node 24 native)
- **Memory is internal SF infrastructure** — uses SQLite (Node 26 native)
- **Memory exported as SF tools** — LLM agents within SF call memory functions
- **No external exposure** — Memory system is not a service; it's SF's autonomous learning mechanism

View file

@ -269,7 +269,7 @@ all.forEach(m => console.log(`${m.id}: ${m.hit_count} hits`));
## Architecture: Memory is Internal
- **No MCP server** — memory stays inside SF
- **SQLite only** — Node 24 native (no external deps)
- **SQLite only** — Node 26 native (no external deps)
- **Fire-and-forget** — never blocks dispatch
- **Private learning** — autonomous pattern extraction

View file

@ -1,312 +1,46 @@
# SQLite Migration Guide for Model Learning
# SQLite Runtime Baseline
**Status**: Planned for Node 24.15.0 upgrade
**Current**: JSON-based storage (model-learner.js, self-report-fixer.js)
**Target**: Native `node:sqlite` integration
**Status:** complete for the Node 26.1 runtime baseline.
## Why SQLite?
SF uses built-in `node:sqlite` and the project-local `.sf/sf.db` as the
canonical structured store for planning state, UOK execution state, learning
outcomes, schedules, memory extraction queues, and generated projections.
1. **Zero dependencies**: Node 24+ has built-in `node:sqlite` (no package install)
2. **Queryable**: SQL joins with UOK's `llm_task_outcomes` table for unified learning database
3. **Transactional**: Atomic outcome recording prevents partial state corruption
4. **Performant**: Indexes on (task_type, model_id) for per-task-type ranking queries
5. **Durable**: WAL mode ensures data survives crashes
## Runtime Rule
## Current State (Node 20)
- **Node baseline:** 26.1+
- **Canonical database:** `.sf/sf.db`
- **SQLite binding:** built-in `node:sqlite`
- **Sidecar stores:** not allowed for active SF state
### JSON-Based Storage
- `model-learner.js`: `.sf/model-performance.json` (nested object hierarchy)
```json
{
"execute-task": {
"gpt-4o": {
"successes": 42,
"failures": 3,
"successRate": 0.93
}
}
}
Runtime code must not introduce `sql.js`, `better-sqlite3`, `sqlite3` CLI
shell-outs, or JSON fallback databases for DB-owned state. If a feature needs
ordering, validation, joins, leases, TTLs, or history, put it in `.sf/sf.db`.
## Current Surfaces
- **Learning outcomes:** `llm_task_outcomes`
- **UOK execution graphs:** `uok_execution_graphs`, `uok_graph_nodes`,
`uok_graph_progress`
- **UOK coordination:** `uok_kv`, `uok_stream_entries`, `uok_queue_items`
- **Memory extraction:** `threads`, `stage1_outputs`, `jobs`
- **Schedules:** `schedule_entries`
## Development Rules
1. Use SF query/writer helpers when operating inside the SF extension.
2. Use `node:sqlite` directly only for isolated tooling or package code that
cannot import the SF extension runtime.
3. Prefer read-only SQLite handles for monitors and inspection overlays.
4. Do not keep compatibility code for retired JSON or sidecar DB stores unless
an explicit migration command owns it.
5. Add behavior tests before changing persistence semantics.
## Verification
```bash
npm run typecheck:extensions
npx vitest run --config vitest.config.ts \
src/resources/extensions/sf/learning/*.test.mjs \
src/resources/extensions/sf/tests/uok-*.test.mjs
```
- `self-report-fixer.js`: Stateless (no persistent storage)
- `triage-self-feedback.js`: Reads/writes `REQUIREMENTS.md`, `ARCHITECTURE.md`
### Pain Points
- Entire file read/write on every outcome (O(n) latency)
- No queryable schema (must load all data, filter in-memory)
- No transactions (partial failures possible)
- No natural joins with UOK database
## SQLite Schema (Target)
### Table 1: model_outcomes
Raw event log for every model outcome.
```sql
CREATE TABLE model_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL, -- "execute-task", "plan-slice", etc.
model_id TEXT NOT NULL, -- "gpt-4o", "claude-opus", etc.
success INTEGER NOT NULL, -- 1 = success, 0 = failure
timeout INTEGER NOT NULL DEFAULT 0, -- 1 = timed out, 0 = normal
tokens_used INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0.0,
timestamp TEXT NOT NULL, -- ISO 8601
FOREIGN KEY (task_type, model_id) REFERENCES model_stats(task_type, model_id)
);
CREATE INDEX idx_outcomes_task_model ON model_outcomes(task_type, model_id);
CREATE INDEX idx_outcomes_timestamp ON model_outcomes(timestamp DESC);
```
### Table 2: model_stats
Aggregated per-task-per-model statistics (updated atomically with each outcome).
```sql
CREATE TABLE model_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
model_id TEXT NOT NULL,
successes INTEGER NOT NULL DEFAULT 0,
failures INTEGER NOT NULL DEFAULT 0,
timeouts INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_cost REAL NOT NULL DEFAULT 0.0,
last_used TEXT, -- ISO 8601 timestamp of last outcome
UNIQUE(task_type, model_id)
);
CREATE INDEX idx_stats_task_model ON model_stats(task_type, model_id);
```
## Migration Steps
### Phase 1: Refactor `ModelPerformanceTracker` (model-learner.js)
**Before** (JSON):
```javascript
recordOutcome(taskType, modelId, outcome) {
if (!this.data[taskType]) this.data[taskType] = {};
if (!this.data[taskType][modelId]) {
this.data[taskType][modelId] = { successes: 0, failures: 0, ... };
}
const stats = this.data[taskType][modelId];
if (outcome.success) stats.successes += 1;
else stats.failures += 1;
this._save(); // Entire file rewrite
}
```
**After** (SQLite):
```javascript
recordOutcome(taskType, modelId, outcome) {
this.db.exec("BEGIN");
// Insert event
const insertStmt = this.db.prepare(`
INSERT INTO model_outcomes (task_type, model_id, success, timeout, ...)
VALUES (?, ?, ?, ?, ...)
`);
insertStmt.run(taskType, modelId, outcome.success ? 1 : 0, ...);
// Upsert stats
const updateStmt = this.db.prepare(`
INSERT INTO model_stats (task_type, model_id, successes, ...)
VALUES (?, ?, ?, ...)
ON CONFLICT(task_type, model_id) DO UPDATE SET
successes = successes + ?,
failures = failures + ?,
...
`);
updateStmt.run(...);
this.db.exec("COMMIT");
}
```
**Benefits**:
- O(1) outcome recording (single INSERT)
- Atomic transaction (both tables updated together)
- No full-file rewrite
### Phase 2: Update Query Methods
**getRankedModels** → SQL SELECT with ORDER BY
```javascript
getRankedModels(taskType, minSamples = 3) {
const query = this.db.prepare(`
SELECT model_id, successes, failures, total_tokens, total_cost, last_used
FROM model_stats
WHERE task_type = ? AND (successes + failures) >= ?
ORDER BY (CAST(successes AS FLOAT) / (successes + failures)) DESC
`);
return query.all(taskType, minSamples).map(row => ({
modelId: row.model_id,
successRate: row.successes / (row.successes + row.failures),
...
}));
}
```
### Phase 3: Integrate with UOK Database (Optional)
If UOK stores outcomes in its database, consider a **federated schema**:
- Keep model_learner SQLite database separate (`.sf/model-performance.db`)
- OR: Create view in UOK database that joins with UOK's `llm_task_outcomes`
```sql
-- In UOK database:
CREATE VIEW model_performance AS
SELECT
outcome.task_type,
outcome.model_id,
COUNT(CASE WHEN outcome.success = 1 THEN 1 END) as successes,
COUNT(CASE WHEN outcome.success = 0 THEN 1 END) as failures,
SUM(outcome.tokens_used) as total_tokens,
SUM(outcome.cost_usd) as total_cost
FROM llm_task_outcomes outcome
GROUP BY outcome.task_type, outcome.model_id;
```
### Phase 4: Data Migration (JSON → SQLite)
Create migration function in constructor:
```javascript
_initDb() {
const db = new DatabaseSync(this.dbPath);
// ... create tables ...
// Migrate existing JSON data
if (existsSync(this.oldJsonPath)) {
const jsonData = JSON.parse(readFileSync(this.oldJsonPath, 'utf-8'));
this._migrateFromJson(db, jsonData);
// After migration: delete old JSON or archive
}
return db;
}
_migrateFromJson(db, jsonData) {
db.exec("BEGIN");
for (const [taskType, models] of Object.entries(jsonData)) {
for (const [modelId, stats] of Object.entries(models)) {
const insertStmt = db.prepare(`
INSERT INTO model_stats
(task_type, model_id, successes, failures, timeouts, total_tokens, total_cost, last_used)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertStmt.run(
taskType, modelId,
stats.successes, stats.failures, stats.timeouts || 0,
stats.totalTokens, stats.totalCost, stats.lastUsed
);
}
}
db.exec("COMMIT");
}
```
## Testing Strategy
### Unit Tests (No Changes Needed)
Existing tests in `model-learner.test.ts` should pass unchanged:
- `recordOutcome()` API remains the same
- `getRankedModels()` returns same shape
- `shouldDemote()`, `getABTestCandidates()` unchanged
### Integration Tests (Add SQLite-Specific)
```typescript
test("persists to SQLite database", () => {
const learner = new ModelLearner(basePath);
learner.recordOutcome("execute-task", "gpt-4o", { success: true, tokensUsed: 100 });
// Verify record in model_outcomes table
const query = learner.tracker.db.prepare(`
SELECT COUNT(*) as count FROM model_outcomes
WHERE task_type = ? AND model_id = ?
`);
const result = query.get("execute-task", "gpt-4o");
expect(result.count).toBe(1);
});
test("transactions are atomic", () => {
// Simulate failure during upsert
// Verify both INSERT and UPDATE succeed or both rollback
});
```
## Timeline
1. **When Node 24.15.0 becomes standard** (6-8 weeks)
- Update `.nvmrc`, `package.json` engines
- Enable snap to run Node 24
2. **Migration PR** (2 days of work)
- Refactor `ModelPerformanceTracker` class
- Add migration function
- Test with existing unit tests
3. **Rollout** (1 day)
- Deploy with backward-compatible JSON→SQLite auto-migration
- Monitor for edge cases
- Archive old JSON files after 1 week
## Backward Compatibility
- **Auto-migrate**: On first run with Node 24, detect `.sf/model-performance.json` and import to SQLite
- **Keep JSON**: Don't delete old JSON file immediately (keep for 1 week as backup)
- **Graceful fallback**: If SQLite init fails, log error and fall back to JSON (degraded mode)
## Future Opportunities
Once SQLite is in place:
1. **Dashboard**: Query performance metrics
```sql
SELECT model_id,
ROUND(100.0 * successes / (successes + failures), 1) as success_rate,
total_tokens, total_cost
FROM model_stats
WHERE task_type = ?
ORDER BY success_rate DESC;
```
2. **Trend analysis**: Model performance over time
```sql
SELECT DATE(timestamp) as day, model_id, COUNT(*) as attempts,
SUM(success) as wins,
ROUND(100.0 * SUM(success) / COUNT(*), 1) as daily_success_rate
FROM model_outcomes
WHERE task_type = ? AND timestamp > date('now', '-30 days')
GROUP BY day, model_id
ORDER BY day DESC;
```
3. **A/B testing**: Compare challenger vs incumbent in detail
```sql
SELECT
model_id,
COUNT(*) as trials,
SUM(success) as wins,
ROUND(AVG(tokens_used), 0) as avg_tokens,
ROUND(AVG(cost_usd), 4) as avg_cost
FROM model_outcomes
WHERE task_type = ? AND timestamp > ?
GROUP BY model_id;
```
4. **Federated learning**: Export performance data for cross-project analysis
```sql
SELECT * FROM model_stats
WHERE successes + failures >= 10 -- High-confidence entries only
ORDER BY success_rate DESC;
```
## References
- Node.js `node:sqlite` docs: https://nodejs.org/api/sqlite.html
- UOK `llm_task_outcomes` schema: See `docs/dev/UOK-SELF-EVOLUTION.md`
- SQLite WAL mode: https://www.sqlite.org/wal.html

View file

@ -153,8 +153,8 @@ For `@dev` or `@next` rollbacks, the next successful merge will overwrite the ta
| Image | Base | Purpose | Tags |
|-------|------|---------|------|
| `ghcr.io/singularity-forge/sf-ci-builder` | `node:24-bookworm` | CI build environment with Rust toolchain | `:latest`, `:<date>` |
| `ghcr.io/singularity-forge/sf-run` | `node:24-slim` | User-facing runtime | `:latest`, `:next`, `:v<version>` |
| `ghcr.io/singularity-forge/sf-ci-builder` | `node:26-bookworm` | CI build environment with Rust toolchain | `:latest`, `:<date>` |
| `ghcr.io/singularity-forge/sf-run` | `node:26-slim` | User-facing runtime | `:latest`, `:next`, `:v<version>` |
The CI builder image is rebuilt automatically when the `Dockerfile` changes. It eliminates ~3-5 min of toolchain setup per CI run.

View file

@ -1,194 +1,35 @@
## M001-6377a4: Consolidate Memory Systems into Unified node:sqlite Store
## M001-6377a4: Consolidate Memory State into `.sf/sf.db`
**Gathered:** 2026-05-07
**Status:** Promoted plan
**Source:** Promoted from `.sf/milestones/M001-6377a4/M001-6377a4-CONTEXT.md`
**Status:** Implemented baseline, remaining work is product integration.
## Project Description
## Purpose
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`.
Memory state must follow the same rule as the rest of SF state: structured,
queryable, project-owned data lives in `.sf/sf.db`. Runtime memory code should
not write global sidecar databases or introduce a second SQLite engine.
**Three systems being consolidated:**
## Current Baseline
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`.
- SF memory APIs use `node:sqlite` through the project database.
- The Pi memory extraction queue now opens `<cwd>/.sf/sf.db`.
- Extracted memory artifacts are written under `<cwd>/.sf/memory`.
- Runtime package dependencies no longer include a WASM SQLite engine for the
memory pipeline.
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.
## Remaining Product Work
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.
- Make `/memory view` prefer DB-backed memory rows over generated markdown when
both exist.
- Connect extracted stage outputs to the existing `memories` table instead of
only producing markdown summaries.
- Add a migration command only if we decide old user data should be imported.
Do not keep passive compatibility code in startup paths.
## Why This Milestone
## Acceptance
**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.**
- `/memory rebuild` records extraction queue state in `.sf/sf.db`.
- `/memory view` reads the same project memory source that UOK and prompt
assembly use.
- Grep finds no runtime memory import of alternate SQLite engines.
- Tests cover the DB path, rebuild path, and clear path.

View file

@ -0,0 +1,623 @@
# SF Agent Mode System
> **Status:** Draft specification. Promoted from `copilot-thoughts.md` research notes.
> **Scope:** TUI mode surface, command structure, orthogonal state axes, skills, background work, runtime target.
> **Decision authority:** Product + architecture review required before implementation.
---
## 1. Problem Statement
SF's current command surface (`/sf autonomous`, `/sf next`, `/sf pause`, `/sf stop`) treats mode switching as separate commands rather than persistent states. There is no visible indicator of the current mode, and the `/sf` prefix positions SF as a plugin rather than the system itself.
Competitors (Copilot CLI, Factory Droid, Amp) have cleaner mode surfaces with visible state and orthogonal controls. SF has deeper autonomous machinery but weaker presentation.
**Goal:** Make SF's mode system as obvious as Vim's insert/normal mode indicator, with the control depth of Factory Droid's autonomy levels and the skill system of Amp.
---
## 2. Orthogonal State Axes
SF state is five independent axes, not one overloaded "mode."
```text
workMode: chat | plan | build | review | repair | research
runControl: manual | assisted | autonomous
permissionProfile: restricted | normal | trusted | unrestricted
modelMode: fast | smart | deep
surface: tui | web | headless | rpc
```
### 2.1 Axis Definitions
| Axis | Question It Answers | Values |
|------|---------------------|--------|
| `workMode` | What kind of work is SF doing? | `chat`, `plan`, `build`, `review`, `repair`, `research` |
| `runControl` | Who advances the loop? | `manual` (user), `assisted` (one unit then pause), `autonomous` (continuous) |
| `permissionProfile` | What may proceed without approval? | `restricted`, `normal`, `trusted`, `unrestricted` |
| `modelMode` | Speed/cost/reasoning posture? | `fast` (cheap), `smart` (balanced), `deep` (reasoning) |
| `surface` | How is the user connected? | `tui`, `web`, `headless`, `rpc` |
### 2.2 Example Combinations
```text
plan | manual | normal | deep → user plans with reasoning model
build | autonomous | trusted | smart → continuous implementation
repair | assisted | normal | smart → one repair unit at a time
research | autonomous | restricted | deep → continuous research, read-only
review | manual | restricted | deep → user reviews with reasoning model
```
### 2.3 Rules
- `permissionProfile` never implies `runControl`. Autonomous run with `restricted` permissions is valid.
- `runControl` never implies `permissionProfile`. Manual run with `unrestricted` permissions is valid.
- Denylists and safety gates override `permissionProfile` regardless of value.
- Every risk decision logs all five axis values.
---
## 3. Work Modes
### 3.1 `chat`
Default conversational mode. Questions, explanations, low-commitment exploration. No durable artifacts created without explicit user request.
### 3.2 `plan`
Research, clarify, write/update specs, derive tasks, produce explicit acceptance point before implementation. Primary user journey starts here.
**Plan → Build handoff:**
```text
plan | manual | normal | deep
accept plan
build | autonomous | selected-permission-profile | smart
```
Surfaces:
- TUI: plan acceptance prompt includes "run autonomously" button
- Web: plan acceptance button includes "run autonomously"
- Headless: `--autonomous` chains into direct `/autonomous`
- RPC: machine event records transition explicitly
### 3.3 `build`
Implement, test, lint, typecheck, verify, prepare commit-ready changes. The autonomous default.
### 3.4 `review`
Inspect diffs, tests, risks, regressions, security issues, missing evidence. Requires reasoning model (`deep`).
### 3.5 `repair`
Fix SF health, repo health, runtime drift, broken generated state, bad command surfaces, failing workflow infrastructure, stale locks, broken installed runtime copies.
**Doctor is the diagnostic engine, not the mode.** `/doctor` inspects. `/repair` switches work mode.
Commands:
```text
/doctor → inspect and report
/doctor fix → deterministic auto-fix
/doctor heal → LLM-assisted deep healing
/repair → switch workMode to repair
/repair --autonomous → repair until clean, blocked, or limit-hit
```
**Auto-transition to repair:**
```text
build | autonomous | trusted | smart
→ repair | autonomous | normal | smart
```
Allowed when:
- pre-dispatch health gate fails
- installed runtime drift detected
- SF cannot dispatch safely
- repo workflow state corrupted
Policy: configurable per project. Options: `auto`, `ask`, `log-only`.
### 3.6 `research`
Longer-form codebase, competitor, design, API, or dependency research. Uses web search, local code exploration, cross-repo research, helper agents.
---
## 4. Run Control
| Value | Behavior |
|-------|----------|
| `manual` | User drives every step. Tool calls require approval. |
| `assisted` | SF executes one unit, then pauses for user review. |
| `autonomous` | SF continues until done, blocked, interrupted, budget-hit, or limit-hit. |
### 4.1 Commands
```text
/control manual
/control assisted
/control autonomous
/autonomous → alias for /control autonomous
/next → alias for /control assisted (one unit)
/pause → pause autonomous, preserve state
/stop → stop autonomous, clear state
```
### 4.2 Transition Scopes
| Scope | Behavior |
|-------|----------|
| `now` | Apply immediately if no tool active. Abort current tool if policy allows. |
| `after-current-tool` | Finish active tool, then switch. |
| `after-current-unit` | Finish current SF unit, then switch. |
| `next-milestone` | Switch after current milestone completes. |
Autonomous changes affect future decisions, never mutate active tool calls mid-execution.
### 4.3 Transition Logging
Every transition persists:
```json
{
"timestamp": "2026-05-08T10:00:00Z",
"from": {"workMode": "build", "runControl": "autonomous"},
"to": {"workMode": "repair", "runControl": "autonomous"},
"reason": "pre-dispatch health gate failed",
"scope": "after-current-unit",
"sessionId": "..."
}
```
---
## 5. Permission Profiles
| Profile | Description |
|---------|-------------|
| `restricted` | Read-only and explicitly allowlisted actions. |
| `normal` | Safe edits, non-destructive local commands. |
| `trusted` | Build/test/install/local commits and bounded repo automation. |
| `unrestricted` | High-risk orchestration only in intentionally trusted environments. |
### 5.1 Enforcement
Permission profile is enforced at three layers:
1. **Tool registry:** Each tool declares required profile. Tools below profile are hidden from model.
2. **Execution gate:** Each tool call checks profile at invocation. Violation = error.
3. **Safety harness:** Destructive operations (delete, push to production, etc.) require explicit confirmation regardless of profile.
### 5.2 Commands
```text
/trust restricted
/trust normal
/trust trusted
/trust unrestricted
```
---
## 6. Model Modes
| Mode | Use Case | Routing Hint |
|------|----------|--------------|
| `fast` | Small bounded tasks | Cheapest available model |
| `smart` | Default balanced work | Default routing table |
| `deep` | Planning, debugging, research, review | Reasoning model (o1, Claude Opus, etc.) |
`modelMode` guides routing. It does not replace explicit `/model` selection.
---
## 7. Mode Switching UX
### 7.1 Direct Commands
```text
/mode chat
/mode plan
/mode build
/mode review
/mode repair
/mode research
/control manual
/control assisted
/control autonomous
/trust restricted
/trust normal
/trust trusted
/trust unrestricted
/model-mode fast
/model-mode smart
/model-mode deep
```
### 7.2 Combined Forms
```text
/mode repair --autonomous --trust normal
/mode build --autonomous --trust trusted
/mode research --autonomous --trust restricted --model-mode deep
```
### 7.3 Autonomous Steering
```text
/steer mode repair
/steer mode review after-current-unit
/steer trust restricted now
/steer model-mode deep for-next-unit
```
### 7.4 Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+Shift+M` | Cycle workMode: chat → plan → build → review → repair → research → chat |
| `Ctrl+Shift+A` | Set runControl to autonomous |
| `Ctrl+Shift+S` | Set runControl to assisted (step) |
| `Ctrl+Shift+I` | Set runControl to manual (interactive) |
| `Ctrl+Shift+R` | Set workMode to repair |
| `Ctrl+Shift+P` | Cycle permissionProfile: restricted → normal → trusted → unrestricted → restricted |
---
## 8. Status and Mode Badge
### 8.1 Full Status Line
```text
SF build | autonomous | trusted | smart
```
### 8.2 Compact Badge Form
For narrow terminals (< 80 cols):
```text
[B][A][T][S]
```
### 8.3 Critical State Labels
When workMode is `repair` or `review`, show full labels regardless of width:
```text
repair | autonomous | normal | smart
review | assisted | normal | deep
```
### 8.4 Badge Placement
| Surface | Placement |
|---------|-----------|
| TUI header | Left side, after "SF" logo |
| TUI status bar | Bottom line when header hidden |
| tmux/terminal title | `SF[build|A|trusted|smart] project-name` |
| Web | Top bar, color-coded chip |
### 8.5 Badge During Auto Mode
Current code hides header/footer during auto mode (`if (isAutoActive()) return []`). This must change:
- Show **minimal header** during auto: badge + project name only
- Or show badge in **dedicated status bar** separate from header/footer
- Badge color pulses slowly during autonomous execution (subtle animation)
### 8.6 Badge Colors
| Axis | Value | Color |
|------|-------|-------|
| workMode | `chat` | dim |
| workMode | `plan` | accent |
| workMode | `build` | success |
| workMode | `review` | warning |
| workMode | `repair` | error |
| workMode | `research` | info |
| runControl | `manual` | dim |
| runControl | `assisted` | warning |
| runControl | `autonomous` | success (pulsing) |
| permissionProfile | `restricted` | success |
| permissionProfile | `normal` | dim |
| permissionProfile | `trusted` | warning |
| permissionProfile | `unrestricted` | error |
---
## 9. Background Work Surface (`/tasks`)
Unified view of all background work. Replaces scattered `/status`, `/queue`, `/parallel status` for work inspection.
### 9.1 What `/tasks` Shows
- autonomous units (current + queued)
- parallel workers
- scheduled autonomous dispatches
- background shell sessions
- stuck or resumable sessions
- remote questions waiting for answers
- current cost/budget state
- last checkpoint and next action
### 9.2 Data Model
SQLite tables:
```sql
-- Durable task state
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
work_mode TEXT NOT NULL,
run_control TEXT NOT NULL,
permission_profile TEXT NOT NULL,
model_mode TEXT NOT NULL,
status TEXT NOT NULL, -- pending | running | review | done | retrying | failed | cancelled
dependency_blockers TEXT, -- JSON array of task IDs
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
checkpoint_ref TEXT, -- git ref or patch file
cost_budget REAL,
cost_spent REAL DEFAULT 0,
created_at TEXT, -- Temporal.Instant ISO
started_at TEXT,
completed_at TEXT,
next_action_at TEXT, -- Temporal.ZonedDateTime for scheduled
intent_claim TEXT -- for parallel workers: "I will edit src/foo.ts lines 10-50"
);
-- Ephemeral running state
CREATE TABLE task_runtime (
task_id TEXT PRIMARY KEY REFERENCES tasks(id),
process_pid INTEGER,
worktree_path TEXT,
current_model TEXT,
context_usage_percent REAL,
last_heartbeat_at TEXT, -- Temporal.Instant
FOREIGN KEY (task_id) REFERENCES tasks(id)
);
-- Transition log
CREATE TABLE task_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
from_status TEXT NOT NULL,
to_status TEXT NOT NULL,
reason TEXT,
scope TEXT,
timestamp TEXT NOT NULL -- Temporal.Instant
);
```
### 9.3 Complementary Commands
`/tasks` does not replace:
- `/status` → project health dashboard
- `/queue` → milestone/slice dispatch order
- `/parallel status` → parallel orchestrator detail
- `/session-report` → cost/token summary
- `/logs` → activity logs
- `/forensics` → execution forensics
---
## 10. Skills System
### 10.1 Directory Structure
```text
.agents/skills/<skill-name>/
SKILL.md -- skill definition with YAML frontmatter
scripts/ -- supporting scripts
schemas/ -- JSON schemas for inputs/outputs
checklists/ -- verification checklists
mcp.json -- MCP server config if applicable
```
### 10.2 Skill Frontmatter
```yaml
---
name: forge-command-surface
description: Use when changing SF slash commands, browser command parity, or headless command dispatch.
user-invocable: true
model-invocable: true
side-effects: code-edits
permission-profile: normal
---
```
Fields:
- `name`: unique identifier
- `description`: when to use this skill
- `user-invocable`: can user explicitly invoke?
- `model-invocable`: can model auto-invoke when relevant?
- `side-effects`: `none`, `code-edits`, `production-mutation`, etc.
- `permission-profile`: minimum profile required
### 10.3 Skill Categories
| Type | Example | `model-invocable` |
|------|---------|-------------------|
| Background knowledge | `forge-autonomous-runtime` | true |
| User tool | `production-deploy` | false |
| Shared capability | `forge-command-surface` | true |
Dangerous skills (`production-mutation`) are never model-invoked by default.
### 10.4 Auto-Creation Flow
1. Detect repeated repo-specific evidence (same files, commands, failure modes, rules)
2. Propose skill in manual/restricted contexts
3. Generate/update automatically only when policy allows
4. Record source evidence in `.sf` state
5. Keep narrow and testable
6. Commit with repo when accepted
### 10.5 Skill Eval Cases
Every auto-created skill needs eval cases:
```text
.agents/skills/<skill-name>/evals/
case-1/
task.md -- user-like prompt
grader.js -- deterministic checker
hidden/ -- reference answers (not visible to agent)
work/ -- agent workspace
```
Graders inspect: files, artifacts, `answer.json`, `trace.jsonl`, result state.
Failed trials preserve workspace for debugging.
---
## 11. Migration from `/sf` Commands
### 11.1 Command Mapping
| Old | New | Status |
|-----|-----|--------|
| `/sf` | `/next` | migrate |
| `/sf autonomous` | `/autonomous` | migrate |
| `/sf next` | `/next` | migrate |
| `/sf stop` | `/stop` | migrate |
| `/sf pause` | `/pause` | migrate |
| `/sf status` | `/status` | migrate |
| `/sf doctor` | `/doctor` | migrate |
| `/sf rate` | `/rate` | migrate |
| `/sf session-report` | `/session-report` | migrate |
| `/sf parallel` | `/parallel` | migrate |
| `/sf remote` | `/remote` | migrate |
| `/sf tasks` | `/tasks` | new |
### 11.2 Migration Timeline
| Phase | Action |
|-------|--------|
| Phase 1 (now) | Accept both `/sf X` and `/X`. Log deprecation warning for `/sf`. |
| Phase 2 (2 releases) | `/sf X` shows warning: "Use /X instead. /sf will be removed." |
| Phase 3 (4 releases) | `/sf X` errors: "Unknown command. Did you mean /X?" |
| Phase 4 (6 releases) | Remove `/sf` handler entirely. |
### 11.3 Shell Surface
Machine surface remains prefixed:
```text
sf headless autonomous
sf headless --autonomous ...
```
---
## 12. Runtime Target: Node 26
### 12.1 Policy
```text
current compatibility floor: Node 26.1+
internal target runtime: Node 26.1
canonical baseline: Node 26.1
Node 25: skip except quick probes
```
### 12.2 Why Node 26
- `Temporal` enabled by default
- V8 14.6 baseline
- Undici 8 HTTP/fetch baseline
- Removes legacy APIs, hardens against old assumptions
### 12.3 Temporal Adoption
Store semantic type, not just formatted string:
| Concept | Temporal Type | Use Case |
|---------|---------------|----------|
| Exact instant | `Temporal.Instant` | Journal events, checkpoints, lock leases |
| Local time | `Temporal.ZonedDateTime` | Reminders, schedules, audits |
| Calendar date | `Temporal.PlainDate` | Daily reports, milestone reviews |
| Wall-clock time | `Temporal.PlainTime` | Recurring policies |
| Time amount | `Temporal.Duration` | Budgets, leases, cooldowns, retry delays |
### 12.4 Adoption Priority
1. `sf schedule` — highest user-visible impact
2. Lock/lease — highest operational correctness
3. Journals/traces — highest debugging impact
4. Session reports — nice to have
5. Background tasks — future work
### 12.5 Gate
```text
node@26 --version
npm run lint
npm run typecheck:extensions
npm run build
npm test
sf --version
sf --help
sf --print "ping"
```
---
## 13. Implementation Pull-Through
### 13.1 Already Directionally Right
- UOK lifecycle records carry `runControl`
- UOK lifecycle records carry `permissionProfile`
- Schedule command state uses `autonomous_dispatch`
- DB-backed state, recovery, verification, scheduling, captures, forensics
- Skills and project-specific skill paths exist
- Parallel orchestration and remote-question infrastructure
### 13.2 Still Needed
| Priority | Item | Effort |
|----------|------|--------|
| P0 | Remove `/sf` internal dispatch, docs, tests, help text | Medium |
| P0 | Make `workMode` durable state (SQLite + `.sf/`) | Medium |
| P0 | Add direct `/mode`, `/control`, `/trust`, `/model-mode` commands | Medium |
| P0 | Add visible mode badge to TUI header/status bar | Small |
| P1 | Make `--autonomous` chain into direct `/autonomous` | Small |
| P1 | Expose autonomous continuation limits in settings and status | Small |
| P1 | Add `/tasks` with durable + ephemeral state | Large |
| P1 | Make `repair` first-class workflow over `doctor` | Medium |
| P2 | Policy-aware project skill suggestion/generation | Large |
| P2 | Skill eval cases for generated skills | Large |
| P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium |
| P2 | Intent/claim records for parallel workers | Medium |
| P2 | Audit subagent provider/model/permission inheritance | Medium |
| P2 | Audit remote steering as full-session surface | Medium |
---
## 14. Open Questions
1. Should `plan` mode show badge `[P]` or `plan` text in full?
2. Should paused autonomous show previous badge dimmed, or `[P]` for paused?
3. Should mode be per-session or per-project? (Current: per-session)
4. Should badge appear in tmux/terminal window titles?
5. Should mode transitions have sound/notification?
6. Should `repair` auto-transition be `ask` by default for new projects?
7. Should skill eval cases run in CI or only on-demand?
8. Should `/tasks` be a TUI overlay or a separate scrollable panel?
---
## 15. References
- GitHub Docs, "Allowing GitHub Copilot CLI to work autonomously" — <https://docs.github.com/en/copilot/concepts/agents/copilot-cli/autopilot>
- Factory Droid, "Autonomy Level" — <https://docs.factory.ai/cli/user-guides/auto-run>
- Amp manual — <https://ampcode.com/manual>
- Smelt (mode cycling) — <https://github.com/leonardcser/smelt>
- ORCH (task state machine) — <https://github.com/oxgeneral/ORCH>
- AgentPlane (schema-first tasks) — <https://github.com/basilisk-labs/agentplane>
- Relay (channels and tickets) — <https://github.com/jcast90/relay>
- Sage (runtime-neutral orchestration) — <https://github.com/youwangd/SageCLI>
- Wit (symbol-level locks) — <https://github.com/amaar-mc/wit>

View file

@ -55,8 +55,8 @@ sudo pacman -S nodejs npm git
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
source ~/.bashrc # or ~/.zshrc
nvm install 24
nvm use 24
nvm install 26
nvm use 26
```
#### All distros: Steps 2-7
@ -64,7 +64,7 @@ nvm use 24
**Step 2 — Verify dependencies are installed:**
```bash
node --version # should print v24.x or higher
node --version # should print v26.x or higher
git --version # should print 2.20+
```
@ -309,7 +309,7 @@ Or from within a session:
| `sf` runs `git svn dcommit` | oh-my-zsh conflict — `unalias sf` or use `sf-cli` |
| Permission errors on `npm install -g` | Fix npm prefix (see Linux notes) or use nvm |
| Can't connect to LLM | Check API key with `sf config`, verify network access |
| `sf` hangs on start | Check Node.js version: `node --version` (need 24+) |
| `sf` hangs on start | Check Node.js version: `node --version` (need 26+) |
For more, see [Troubleshooting](./troubleshooting.md).

View file

@ -151,11 +151,11 @@ rm -rf "$(dirname .sf)/.sf.lock"
- If the error persists, close tools that may be holding the file open and then retry.
- If repeated failures continue, run `/doctor` to confirm the repo state is still healthy and report the exact path + error code.
### Node v24 web boot failure
### Node v26 web boot failure
**Symptoms:** `sf --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v24.
**Symptoms:** `sf --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v26.
**Cause:** Node v24 changed type-stripping behavior for `node_modules`, breaking the Next.js web build.
**Cause:** Node v26 changed type-stripping behavior for `node_modules`, breaking the Next.js web build.
**Fix:** Fixed in v2.42.0+ (#1864). Upgrade to the latest version.

View file

@ -52,9 +52,9 @@ The web server binds to `localhost:3000` by default. Use `--host`, `--port`, and
|----------|-------------|
| `SF_WEB_PROJECT_CWD` | Default project path when `?project=` is not specified |
## Node v24 Compatibility
## Node v26 Compatibility
Node v24 introduced breaking changes to type stripping that caused `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on web boot. This is fixed in v2.42.0+ (#1864). If you encounter this error, upgrade SF.
Node v26 introduced breaking changes to type stripping that caused `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on web boot. This is fixed in v2.42.0+ (#1864). If you encounter this error, upgrade SF.
## Auth Token Persistence

147
package-lock.json generated
View file

@ -50,7 +50,6 @@
"remark-parse": "^11.0.0",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
"sql.js": "^1.14.1",
"strip-ansi": "^7.1.0",
"undici": "^7.24.2",
"unified": "^11.0.5",
@ -66,8 +65,8 @@
"sf-server": "packages/daemon/dist/cli.js"
},
"devDependencies": {
"@biomejs/biome": "^2.4.13",
"@types/node": "^24.12.0",
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.6.2",
"@types/picomatch": "^4.0.2",
"@types/shell-quote": "^1.7.5",
"@vitest/coverage-v8": "^4.1.5",
@ -78,7 +77,7 @@
"vitest": "^4.1.5"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
@ -1181,9 +1180,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz",
"integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.14.tgz",
"integrity": "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@ -1197,20 +1196,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.13",
"@biomejs/cli-darwin-x64": "2.4.13",
"@biomejs/cli-linux-arm64": "2.4.13",
"@biomejs/cli-linux-arm64-musl": "2.4.13",
"@biomejs/cli-linux-x64": "2.4.13",
"@biomejs/cli-linux-x64-musl": "2.4.13",
"@biomejs/cli-win32-arm64": "2.4.13",
"@biomejs/cli-win32-x64": "2.4.13"
"@biomejs/cli-darwin-arm64": "2.4.14",
"@biomejs/cli-darwin-x64": "2.4.14",
"@biomejs/cli-linux-arm64": "2.4.14",
"@biomejs/cli-linux-arm64-musl": "2.4.14",
"@biomejs/cli-linux-x64": "2.4.14",
"@biomejs/cli-linux-x64-musl": "2.4.14",
"@biomejs/cli-win32-arm64": "2.4.14",
"@biomejs/cli-win32-x64": "2.4.14"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz",
"integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.14.tgz",
"integrity": "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==",
"cpu": [
"arm64"
],
@ -1225,9 +1224,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz",
"integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.14.tgz",
"integrity": "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==",
"cpu": [
"x64"
],
@ -1242,13 +1241,16 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz",
"integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.14.tgz",
"integrity": "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@ -1259,13 +1261,16 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz",
"integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.14.tgz",
"integrity": "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@ -1276,13 +1281,16 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz",
"integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.14.tgz",
"integrity": "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@ -1293,13 +1301,16 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz",
"integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.14.tgz",
"integrity": "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@ -1310,9 +1321,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz",
"integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.14.tgz",
"integrity": "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==",
"cpu": [
"arm64"
],
@ -1327,9 +1338,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz",
"integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.14.tgz",
"integrity": "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==",
"cpu": [
"x64"
],
@ -6314,13 +6325,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -6415,12 +6419,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"version": "25.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@types/normalize-package-data": {
@ -6518,17 +6522,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/sql.js": {
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.9.tgz",
"integrity": "sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/emscripten": "*",
"@types/node": "*"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@ -12341,12 +12334,6 @@
"integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==",
"license": "CC0-1.0"
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -12895,9 +12882,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT"
},
"node_modules/unicorn-magic": {
@ -13602,11 +13589,11 @@
"sf-server": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^24.12.0",
"@types/node": "^25.6.2",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
},
"packages/daemon/node_modules/zod": {
@ -13623,7 +13610,7 @@
"version": "2.75.3",
"license": "MIT",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"optionalDependencies": {
"@singularity-forge/engine-darwin-arm64": ">=2.75.0",
@ -13637,7 +13624,7 @@
"name": "@singularity-forge/pi-agent-core",
"version": "2.75.3",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
},
"packages/pi-ai": {
@ -13665,7 +13652,7 @@
"@smithy/node-http-handler": "^4.5.0"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
},
"packages/pi-ai/node_modules/@smithy/node-http-handler": {
@ -13701,7 +13688,6 @@
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",
"sql.js": "^1.14.1",
"strip-ansi": "^7.1.0",
"undici": "^7.24.2",
"yaml": "^2.8.2"
@ -13710,11 +13696,10 @@
"@types/diff": "^7.0.2",
"@types/express": "^4.17.21",
"@types/hosted-git-info": "^3.0.5",
"@types/proper-lockfile": "^4.1.4",
"@types/sql.js": "^1.4.9"
"@types/proper-lockfile": "^4.1.4"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
},
"packages/pi-coding-agent/node_modules/accepts": {
@ -14021,7 +14006,7 @@
"@types/mime-types": "^2.1.4"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"optionalDependencies": {
"koffi": "^2.9.0"
@ -14032,7 +14017,7 @@
"version": "2.75.3",
"license": "MIT",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
}
}

View file

@ -38,7 +38,7 @@
"configDir": ".sf"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"packageManager": "npm@11.13.0",
"scripts": {
@ -148,7 +148,6 @@
"remark-parse": "^11.0.0",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
"sql.js": "^1.14.1",
"strip-ansi": "^7.1.0",
"undici": "^7.24.2",
"unified": "^11.0.5",
@ -159,7 +158,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^24.12.0",
"@types/node": "^25.6.2",
"@types/picomatch": "^4.0.2",
"@types/shell-quote": "^1.7.5",
"@vitest/coverage-v8": "^4.1.5",

View file

@ -36,11 +36,11 @@
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.12.0",
"@types/node": "^25.6.2",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"files": [
"dist",

View file

@ -81,22 +81,22 @@ describe("generatePlist", () => {
it("uses the absolute node path from opts", () => {
const opts = basePlistOpts({
nodePath: "/home/user/.nvm/versions/node/v24.0.0/bin/node",
nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node",
});
const xml = generatePlist(opts);
assert.ok(
xml.includes(
"<string>/home/user/.nvm/versions/node/v24.0.0/bin/node</string>",
"<string>/home/user/.nvm/versions/node/v26.1.0/bin/node</string>",
),
);
});
it("includes NVM bin directory in PATH", () => {
const opts = basePlistOpts({
nodePath: "/home/user/.nvm/versions/node/v24.0.0/bin/node",
nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node",
});
const xml = generatePlist(opts);
assert.ok(xml.includes("/home/user/.nvm/versions/node/v24.0.0/bin"));
assert.ok(xml.includes("/home/user/.nvm/versions/node/v26.1.0/bin"));
});
it("sets KeepAlive with SuccessfulExit false", () => {

View file

@ -93,7 +93,7 @@
"dist"
],
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"optionalDependencies": {
"@singularity-forge/engine-darwin-arm64": ">=2.75.0",

View file

@ -3,7 +3,7 @@
* for Node.js module resolution (ESM/CJS compatibility).
*
* Regression test for #2861: "type": "module" + "import"-only export
* conditions caused crashes on Node.js v24 when the parent package also
* conditions caused crashes on Node.js v26 when the parent package also
* declared "type": "module" and strict ESM resolution was enforced.
*/
@ -26,7 +26,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
assert.notEqual(
pkg.type,
"module",
'package.json must not set "type": "module" — this causes crashes on Node.js v24 ' +
'package.json must not set "type": "module" — this causes crashes on Node.js v26 ' +
"when the parent package also declares ESM (see #2861)",
);
});
@ -55,7 +55,7 @@ describe("@singularity-forge/native module compatibility (#2861)", () => {
assert.ok(
!conditions.import || conditions.default,
`exports["${subpath}"] uses "import" condition without "default" — ` +
`this breaks CJS consumers and Node.js v24 strict resolution`,
`this breaks CJS consumers and Node.js v26 strict resolution`,
);
}
});

View file

@ -16,6 +16,6 @@
},
"dependencies": {},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
}

View file

@ -44,6 +44,6 @@
"@smithy/node-http-handler": "^4.5.0"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
}

View file

@ -33,18 +33,16 @@
"proper-lockfile": "^4.1.2",
"strip-ansi": "^7.1.0",
"undici": "^7.24.2",
"sql.js": "^1.14.1",
"yaml": "^2.8.2",
"express": "^4.19.2"
},
"devDependencies": {
"@types/sql.js": "^1.4.9",
"@types/diff": "^7.0.2",
"@types/hosted-git-info": "^3.0.5",
"@types/proper-lockfile": "^4.1.4",
"@types/express": "^4.17.21"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
}

View file

@ -11,7 +11,6 @@
* - /memory command: view, clear, rebuild, stats
*/
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { completeSimple } from "@singularity-forge/pi-ai";
@ -23,26 +22,25 @@ import {
import { getFullMemory, getMemorySummary, runStartup } from "./pipeline.js";
import { MemoryStorage } from "./storage.js";
/** Encode cwd to a filesystem-safe directory name */
function encodeCwd(cwd: string): string {
return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
}
/** Get the memory directory for a project */
function getMemoryDir(cwd: string): string {
return join(getAgentDir(), "memories", encodeCwd(cwd));
return join(cwd, ".sf", "memory");
}
/** Get the database path */
function getDbPath(): string {
return join(getAgentDir(), "agent.db");
function getDbPath(cwd: string): string {
return join(cwd, ".sf", "sf.db");
}
let storageInstance: MemoryStorage | null = null;
let storageDbPath: string | null = null;
async function getStorage(): Promise<MemoryStorage> {
if (!storageInstance) {
storageInstance = await MemoryStorage.create(getDbPath());
async function getStorage(cwd: string): Promise<MemoryStorage> {
const dbPath = getDbPath(cwd);
if (!storageInstance || storageDbPath !== dbPath) {
storageInstance?.close();
storageInstance = await MemoryStorage.create(dbPath);
storageDbPath = dbPath;
}
return storageInstance;
}
@ -130,7 +128,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
// Fire and forget
runStartup(
await getStorage(),
await getStorage(cwd),
{
sessionsDir,
memoryDir,
@ -212,7 +210,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
"Delete all extracted memories for this project?",
);
if (confirmed) {
(await getStorage()).clearForCwd(ctx.cwd);
(await getStorage(ctx.cwd)).clearForCwd(ctx.cwd);
if (existsSync(projectMemoryDir)) {
rmSync(projectMemoryDir, { recursive: true, force: true });
}
@ -227,7 +225,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
"Re-extract all memories from session history? This may take a while.",
);
if (confirmed) {
(await getStorage()).resetAllForCwd(ctx.cwd);
(await getStorage(ctx.cwd)).resetAllForCwd(ctx.cwd);
if (existsSync(projectMemoryDir)) {
rmSync(projectMemoryDir, { recursive: true, force: true });
}
@ -240,7 +238,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
}
case "stats": {
const stats = (await getStorage()).getStats();
const stats = (await getStorage(ctx.cwd)).getStats();
const statsText = [
"Memory Pipeline Statistics:",
` Total sessions tracked: ${stats.totalThreads}`,
@ -274,6 +272,7 @@ export default function memoryExtension(api: ExtensionAPI): void {
if (storageInstance) {
storageInstance.close();
storageInstance = null;
storageDbPath = null;
}
});
}

View file

@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, it } from "vitest";
@ -10,11 +10,7 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "sf-memory-storage-test-"));
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("MemoryStorage debounced persistence", () => {
describe("MemoryStorage node:sqlite persistence", () => {
let dir: string;
afterEach(() => {
@ -23,14 +19,11 @@ describe("MemoryStorage debounced persistence", () => {
}
});
it("multiple rapid mutations only trigger one persist write", async () => {
it("multiple rapid mutations are immediately queryable", async () => {
dir = makeTmpDir();
const dbPath = join(dir, "test.db");
const storage = await MemoryStorage.create(dbPath);
const initialStat = readFileSync(dbPath);
const _initialMtime = initialStat.length;
storage.upsertThreads([
{
threadId: "t1",
@ -59,35 +52,18 @@ describe("MemoryStorage debounced persistence", () => {
},
]);
const afterMutationsBuf = readFileSync(dbPath);
assert.deepEqual(
afterMutationsBuf,
initialStat,
"File should not have been written yet (debounce window has not elapsed)",
);
await wait(700);
const afterDebounceBuf = readFileSync(dbPath);
assert.notDeepEqual(
afterDebounceBuf,
initialStat,
"File should have been written after debounce window elapsed",
);
assert.equal(existsSync(dbPath), true);
const stats = storage.getStats();
assert.equal(stats.totalThreads, 3);
storage.close();
});
it("close() flushes pending changes immediately without waiting for debounce", async () => {
it("close() releases the database and persisted rows reopen", async () => {
dir = makeTmpDir();
const dbPath = join(dir, "test.db");
const storage = await MemoryStorage.create(dbPath);
const initialBuf = readFileSync(dbPath);
storage.upsertThreads([
{
threadId: "t1",
@ -98,22 +74,8 @@ describe("MemoryStorage debounced persistence", () => {
},
]);
const beforeCloseBuf = readFileSync(dbPath);
assert.deepEqual(
beforeCloseBuf,
initialBuf,
"File should not have been written yet (debounce window has not elapsed)",
);
storage.close();
const afterCloseBuf = readFileSync(dbPath);
assert.notDeepEqual(
afterCloseBuf,
initialBuf,
"File should have been written immediately on close()",
);
const reopened = await MemoryStorage.create(dbPath);
const stats = reopened.getStats();
assert.equal(

View file

@ -8,9 +8,9 @@
*/
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import initSqlJs, { type Database as SqlJsDatabase } from "sql.js";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
export interface ThreadRow {
thread_id: string;
@ -44,13 +44,10 @@ export interface JobRow {
}
export class MemoryStorage {
private db: SqlJsDatabase;
private dbPath: string;
private persistTimer: ReturnType<typeof setTimeout> | null = null;
private db: DatabaseSync;
private constructor(db: SqlJsDatabase, dbPath: string) {
private constructor(db: DatabaseSync) {
this.db = db;
this.dbPath = dbPath;
}
static async create(dbPath: string): Promise<MemoryStorage> {
@ -59,36 +56,23 @@ export class MemoryStorage {
mkdirSync(dir, { recursive: true });
}
const SQL = await initSqlJs();
const buffer = existsSync(dbPath) ? readFileSync(dbPath) : undefined;
const db = buffer ? new SQL.Database(buffer) : new SQL.Database();
const db = new DatabaseSync(dbPath);
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
db.run("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA synchronous = NORMAL");
db.exec("PRAGMA busy_timeout = 5000");
const storage = new MemoryStorage(db, dbPath);
const storage = new MemoryStorage(db);
storage.initSchema();
return storage;
}
private persist(): void {
const data = this.db.export();
writeFileSync(this.dbPath, Buffer.from(data));
}
private schedulePersist(): void {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
}
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
this.persist();
}, 500);
private run(sql: string, params: unknown[] = []): void {
this.db.prepare(sql).run(...(params as SQLInputValue[]));
}
private initSchema(): void {
this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS threads (
thread_id TEXT PRIMARY KEY,
file_path TEXT NOT NULL,
@ -101,7 +85,7 @@ export class MemoryStorage {
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS stage1_outputs (
thread_id TEXT PRIMARY KEY,
extraction_json TEXT NOT NULL,
@ -109,7 +93,7 @@ export class MemoryStorage {
FOREIGN KEY (thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE
)
`);
this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
phase TEXT NOT NULL,
@ -123,30 +107,23 @@ export class MemoryStorage {
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
this.db.run(
this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_jobs_phase_status ON jobs(phase, status)",
);
this.db.run(
this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status)",
);
this.db.run("CREATE INDEX IF NOT EXISTS idx_threads_cwd ON threads(cwd)");
this.persist();
this.db.exec("CREATE INDEX IF NOT EXISTS idx_threads_cwd ON threads(cwd)");
}
private queryAll<T>(sql: string, params: unknown[] = []): T[] {
const stmt = this.db.prepare(sql);
stmt.bind(params as (string | number | null | Uint8Array)[]);
const rows: T[] = [];
while (stmt.step()) {
rows.push(stmt.getAsObject() as T);
}
stmt.free();
return rows;
return this.db.prepare(sql).all(...(params as SQLInputValue[])) as T[];
}
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
const rows = this.queryAll<T>(sql, params);
return rows[0];
return this.db.prepare(sql).get(...(params as SQLInputValue[])) as
| T
| undefined;
}
/**
@ -177,11 +154,11 @@ export class MemoryStorage {
);
if (!existing) {
this.db.run(
this.run(
"INSERT INTO threads (thread_id, file_path, file_size, file_mtime, cwd, status) VALUES (?, ?, ?, ?, ?, 'pending')",
[t.threadId, t.filePath, t.fileSize, t.fileMtime, t.cwd],
);
this.db.run(
this.run(
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
[randomUUID(), t.threadId],
);
@ -190,12 +167,12 @@ export class MemoryStorage {
existing.file_size !== t.fileSize ||
existing.file_mtime !== t.fileMtime
) {
this.db.run(
this.run(
"UPDATE threads SET file_path = ?, file_size = ?, file_mtime = ?, cwd = ?, status = 'pending', updated_at = datetime('now') WHERE thread_id = ?",
[t.filePath, t.fileSize, t.fileMtime, t.cwd, t.threadId],
);
if (existing.status === "done" || existing.status === "error") {
this.db.run(
this.run(
"INSERT OR IGNORE INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
[randomUUID(), t.threadId],
);
@ -206,7 +183,6 @@ export class MemoryStorage {
}
}
this.schedulePersist();
return { inserted, updated, skipped };
}
@ -222,7 +198,7 @@ export class MemoryStorage {
const token = randomUUID();
const expiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString();
this.db.run(
this.run(
`UPDATE jobs SET
status = 'claimed',
worker_id = ?,
@ -243,8 +219,6 @@ export class MemoryStorage {
[token],
);
this.schedulePersist();
return rows.map((r) => ({
jobId: r.id,
threadId: r.thread_id,
@ -256,34 +230,32 @@ export class MemoryStorage {
* Mark a stage1 job as complete and store the extraction output.
*/
completeStage1Job(threadId: string, output: string): void {
this.db.run(
this.run(
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
[threadId],
);
this.db.run(
this.run(
"INSERT OR REPLACE INTO stage1_outputs (thread_id, extraction_json, created_at) VALUES (?, ?, datetime('now'))",
[threadId, output],
);
this.db.run(
this.run(
"UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?",
[threadId],
);
this.schedulePersist();
}
/**
* Mark a stage1 job as errored.
*/
failStage1Job(threadId: string, errorMessage: string): void {
this.db.run(
this.run(
"UPDATE jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ? AND phase = 'stage1' AND status = 'claimed'",
[errorMessage, threadId],
);
this.db.run(
this.run(
"UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?",
[errorMessage, threadId],
);
this.schedulePersist();
}
/**
@ -322,12 +294,11 @@ export class MemoryStorage {
}
const jobId = randomUUID();
this.db.run(
this.run(
"INSERT INTO jobs (id, phase, status, worker_id, ownership_token, lease_expires_at) VALUES (?, 'stage2', 'claimed', ?, ?, ?)",
[jobId, workerId, token, expiresAt],
);
this.schedulePersist();
return { jobId, ownershipToken: token };
}
@ -335,11 +306,10 @@ export class MemoryStorage {
* Complete the phase 2 consolidation job.
*/
completePhase2Job(jobId: string): void {
this.db.run(
this.run(
"UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'",
[jobId],
);
this.schedulePersist();
}
/**
@ -432,41 +402,39 @@ export class MemoryStorage {
* Clear all data (for /memory clear).
*/
clearAll(): void {
this.db.run("DELETE FROM stage1_outputs");
this.db.run("DELETE FROM jobs");
this.db.run("DELETE FROM threads");
this.schedulePersist();
this.db.exec("DELETE FROM stage1_outputs");
this.db.exec("DELETE FROM jobs");
this.db.exec("DELETE FROM threads");
}
/**
* Clear data for a specific cwd (for /memory clear in project scope).
*/
clearForCwd(cwd: string): void {
this.db.run(
this.run(
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd],
);
this.db.run(
this.run(
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd],
);
this.db.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
this.schedulePersist();
this.run("DELETE FROM threads WHERE cwd = ?", [cwd]);
}
/**
* Reset all threads to pending (for /memory rebuild).
*/
resetAllForCwd(cwd: string): void {
this.db.run(
this.run(
"DELETE FROM stage1_outputs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd],
);
this.db.run(
this.run(
"DELETE FROM jobs WHERE thread_id IN (SELECT thread_id FROM threads WHERE cwd = ?)",
[cwd],
);
this.db.run(
this.run(
"UPDATE threads SET status = 'pending', updated_at = datetime('now') WHERE cwd = ?",
[cwd],
);
@ -477,20 +445,14 @@ export class MemoryStorage {
);
for (const t of threads) {
this.db.run(
this.run(
"INSERT INTO jobs (id, phase, thread_id, status) VALUES (?, 'stage1', ?, 'pending')",
[randomUUID(), t.thread_id],
);
}
this.schedulePersist();
}
close(): void {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
this.persist();
this.db.close();
}
}

View file

@ -26,32 +26,6 @@ declare module "proper-lockfile" {
export default lockfile;
}
declare module "sql.js" {
export interface Statement {
bind(values: (string | number | null | Uint8Array)[]): void;
step(): boolean;
getAsObject(): Record<string, unknown>;
free(): void;
}
export interface Database {
run(sql: string, params?: unknown[]): void;
prepare(sql: string): Statement;
export(): Uint8Array;
close(): void;
}
export interface SqlJsStatic {
Database: new (data?: Uint8Array | ArrayBuffer | Buffer) => Database;
}
export interface SqlJsConfig {
locateFile?: (file: string) => string;
}
export default function initSqlJs(config?: SqlJsConfig): Promise<SqlJsStatic>;
}
declare module "hosted-git-info" {
export interface HostedGitInfo {
domain?: string;

View file

@ -28,6 +28,6 @@
"koffi": "^2.9.0"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
}

View file

@ -29,6 +29,6 @@
"test": "node --test dist/rpc-client.test.js"
},
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
}

View file

@ -2,7 +2,7 @@
"name": "sf",
"version": "2.75.3",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"piConfig": {
"name": "sf",

View file

@ -25,7 +25,7 @@
* Data sources:
* .sf/parallel/M0xx.status.json heartbeat, cost, state (written by orchestrator)
* .sf/worktrees/M0xx/.sf/auto.lock current unit type + ID (written by worker)
* .sf/worktrees/M0xx/.sf/sf.db task/slice completion (SQLite, queried via cli)
* .sf/worktrees/M0xx/.sf/sf.db task/slice completion (read-only node:sqlite query)
* .sf/parallel/M0xx.stdout.log NDJSON events (cost extraction, notify messages)
* .sf/parallel/M0xx.stderr.log error surfacing
*
@ -44,6 +44,7 @@
import { execSync, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
// ─── Configuration ───────────────────────────────────────────────────────────
@ -175,26 +176,38 @@ function readAutoLock(mid) {
return readJsonSafe(lockPath);
}
function queryRows(dbPath, sql, params = []) {
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
return db.prepare(sql).all(...params).map((row) => ({ ...row }));
} finally {
db.close();
}
}
function querySliceProgress(mid) {
const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`);
if (!fs.existsSync(dbPath)) return [];
try {
const sql = `SELECT s.id, s.status, COUNT(t.id), SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) FROM slices s LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id WHERE s.milestone_id='${mid}' GROUP BY s.id ORDER BY s.id`;
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, {
timeout: 3000,
encoding: "utf-8",
}).trim();
if (!out) return [];
return out.split("\n").map((line) => {
const [id, status, total, done] = line.split("|");
return {
id,
status,
total: parseInt(total, 10),
done: parseInt(done || "0", 10),
};
});
return queryRows(
dbPath,
`SELECT s.id AS id,
s.status AS status,
COUNT(t.id) AS total,
SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
FROM slices s
LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
WHERE s.milestone_id=?
GROUP BY s.id
ORDER BY s.id`,
[mid],
).map((row) => ({
id: row.id,
status: row.status,
total: Number(row.total ?? 0),
done: Number(row.done ?? 0),
}));
} catch {
return [];
}
@ -631,17 +644,23 @@ function queryRecentCompletions(mid) {
try {
// Completed tasks with timestamps, most recent first
const sql = `SELECT id, slice_id, one_liner, completed_at FROM tasks WHERE milestone_id='${mid}' AND status='complete' AND completed_at IS NOT NULL ORDER BY completed_at DESC LIMIT 5`;
const out = execSync(`sqlite3 "${dbPath}" "${sql}"`, {
timeout: 3000,
encoding: "utf-8",
}).trim();
if (!out) return [];
return out.split("\n").map((line) => {
const [taskId, sliceId, oneLiner, completedAt] = line.split("|");
return queryRows(
dbPath,
`SELECT id AS taskId,
slice_id AS sliceId,
one_liner AS oneLiner,
completed_at AS completedAt
FROM tasks
WHERE milestone_id=?
AND status='complete'
AND completed_at IS NOT NULL
ORDER BY completed_at DESC
LIMIT 5`,
[mid],
).map((row) => {
return {
ts: completedAt ? new Date(completedAt).getTime() : Date.now(),
msg: `${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`,
ts: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(),
msg: `${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
mid,
};
});

View file

@ -44,7 +44,7 @@ import { stopWebMode } from "./web-mode.js";
import { loadStoredEnvKeys } from "./wizard.js";
// ---------------------------------------------------------------------------
// V8 compile cache — Node 24+ can cache compiled bytecode across runs,
// V8 compile cache — Node 26+ can cache compiled bytecode across runs,
// eliminating repeated parse/compile overhead for unchanged modules.
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
// ---------------------------------------------------------------------------

View file

@ -180,7 +180,7 @@ if (
// package.json (already parsed above) and verifies git is available.
// ---------------------------------------------------------------------------
{
const MIN_NODE_MAJOR = 24;
const MIN_NODE_MAJOR = 26;
const red = "\x1b[31m";
const bold = "\x1b[1m";
const dim = "\x1b[2m";

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"scripts": {
"test": "node --test tests/*.test.mjs"

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {
"extensions": [

View file

@ -4,7 +4,7 @@
"type": "module",
"description": "cmux integration library — used by other extensions, not an extension itself",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {}
}

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {
"extensions": [

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {
"extensions": [

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {
"extensions": [

View file

@ -1,6 +1,6 @@
{
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
}
}

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {
"extensions": [

View file

@ -334,7 +334,7 @@ export function syncProjectRootToWorktree(
}
// Always clean up WAL/SHM sidecar files when the main DB was deleted
// or is already missing. Orphaned WAL/SHM files cause SQLite WAL
// recovery on next open, which triggers a CPU spin on Node 24's
// recovery on next open, which triggers a CPU spin on Node 26's
// node:sqlite DatabaseSync implementation (#2478).
if (deleteSidecars) {
for (const suffix of ["-wal", "-shm"]) {

View file

@ -15,6 +15,14 @@
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
* `let` or `var` declarations.
*/
import {
buildModeState,
resolveModelMode,
resolvePermissionProfile,
resolveRunControlMode,
resolveWorkMode,
} from "../operating-model.js";
// ─── Constants ───────────────────────────────────────────────────────────────
export const MAX_UNIT_DISPATCHES = 3;
export const STUB_RECOVERY_THRESHOLD = 2;
@ -48,6 +56,31 @@ export class AutoSession {
activeEngineId = null;
activeRunDir = null;
cmdCtx = null;
// ── Mode state ───────────────────────────────────────────────────────────
/**
* Current work mode: chat | plan | build | review | repair | research.
* Defaults to "chat" for new sessions.
*/
workMode = "chat";
/**
* Current permission profile: restricted | normal | trusted | unrestricted.
* Defaults to "restricted" for safety.
*/
permissionProfile = "restricted";
/**
* Current model mode: fast | smart | deep.
* Defaults to "smart".
*/
modelMode = "smart";
/**
* Surface identifier: tui | web | headless | rpc.
* Defaults to "tui".
*/
surface = "tui";
/**
* ISO timestamp of last mode transition.
*/
modeUpdatedAt = null;
// ── Paths ────────────────────────────────────────────────────────────────
basePath = "";
originalBasePath = "";
@ -224,6 +257,12 @@ export class AutoSession {
this.activeEngineId = null;
this.activeRunDir = null;
this.cmdCtx = null;
// Mode state
this.workMode = "chat";
this.permissionProfile = "restricted";
this.modelMode = "smart";
this.surface = "tui";
this.modeUpdatedAt = null;
// Paths
this.basePath = "";
this.originalBasePath = "";
@ -296,6 +335,47 @@ export class AutoSession {
this.sigtermHandler = null;
// Loop promise state lives in auto-loop.ts module scope
}
/**
* Update mode state with validation and timestamp.
*
* Purpose: centralize mode transitions so every change is logged and
* validated against canonical vocabulary.
*
* Consumer: command handlers and auto health gates.
*/
setMode({
workMode,
runControl,
permissionProfile,
modelMode,
surface,
} = {}) {
const prev = this.getMode();
if (workMode !== undefined) this.workMode = resolveWorkMode(workMode);
if (runControl !== undefined) {
const mode = resolveRunControlMode(runControl);
this.stepMode = mode === "assisted";
}
if (permissionProfile !== undefined) {
this.permissionProfile = resolvePermissionProfile(permissionProfile);
}
if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode);
if (surface !== undefined) this.surface = surface;
this.modeUpdatedAt = new Date().toISOString();
return { from: prev, to: this.getMode() };
}
/**
* Get current mode state as a canonical object.
*/
getMode() {
return buildModeState({
workMode: this.workMode,
runControl: this.stepMode ? "assisted" : this.active ? "autonomous" : "manual",
permissionProfile: this.permissionProfile,
modelMode: this.modelMode,
surface: this.surface,
});
}
toJSON() {
return {
active: this.active,
@ -307,6 +387,7 @@ export class AutoSession {
currentMilestoneId: this.currentMilestoneId,
currentUnit: this.currentUnit,
unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
mode: this.getMode(),
};
}
}

View file

@ -318,7 +318,7 @@ test("writeFallbackChains warns via log when project-level .sf/agent/settings.js
}
});
test("writeFallbackChains emits Kimi K2.6 through the Kimi Code wire route", () => {
test("writeFallbackChains emits canonical Kimi K2.6 on the Kimi Code provider", () => {
const { dir, settingsPath } = makeTempSettingsDir();
try {
// Deps deliberately minimal — no overrides, no enabledModels — so
@ -334,8 +334,7 @@ test("writeFallbackChains emits Kimi K2.6 through the Kimi Code wire route", ()
assert.equal(mainChain.length, 1, "main chain has exactly 1 direct entry");
assert.equal(mainChain[0].provider, "kimi-coding");
// Provider wire ID for Kimi K2.6.
assert.equal(mainChain[0].model, "kimi-for-coding");
assert.equal(mainChain[0].model, "kimi-k2.6");
assert.equal(mainChain[0].priority, 0);
assert.ok(
@ -379,7 +378,7 @@ test("hardcoded main chain coexists with blender-computed per-unit-type chains",
assert.ok(Array.isArray(chains.main), "main chain present");
assert.equal(chains.main.length, 1);
assert.equal(chains.main[0].provider, "kimi-coding");
assert.equal(chains.main[0].model, "kimi-for-coding");
assert.equal(chains.main[0].model, "kimi-k2.6");
// Blender-computed per-unit-type chain also present
assert.ok(Array.isArray(chains.planning), "planning chain present");

View file

@ -279,7 +279,7 @@ test("registerRoutingHook: registers handler + reload command and routes a simul
const pi = makeFakePi();
// Route the DB to a non-existent path so the lazy open returns null and
// the handler runs in priors-only mode (no better-sqlite3 dependency).
// the handler runs in priors-only mode (no persisted DB dependency).
registerRoutingHook(pi, {
dbPath: "/tmp/sf-learning-test-nonexistent.db",
notify: true,

View file

@ -4,7 +4,7 @@
* Wires together the four S01-S04 modules into a single registerable plugin:
*
* loadCapabilityOverrides priors (per (unit_type, model))
* outcome-recorder write llm_task_outcomes rows
* sf-db outcome writer write llm_task_outcomes rows
* outcome-aggregator rolling-window observed stats
* bayesian-blender α · prior + (1-α) · observed + UCB1
* hook-handler translates the above into a before_model_select handler
@ -13,7 +13,6 @@
*
* import { init } from "./index.mjs";
* const plugin = await init(pi, {
* dbPath: "~/.sf/sf-learning.db",
* priorsPath: "./src/data/model-benchmarks.json",
* weightsPath: "./src/data/unit-weights.json",
* nPrior: 10,
@ -25,23 +24,27 @@
* // plugin.unregister() on tear down
*
* ## Side effects
* - Opens (or creates) a SQLite database at the resolved dbPath
* - Bootstraps the schema if absent
* - Uses the already-open `.sf/sf.db`, or opens `<basePath>/.sf/sf.db`
* - Relies on the shared SF DB bootstrap for `llm_task_outcomes`
* - Registers a hook on the supplied pi instance
*
* ## Errors
* - Init failures are wrapped with a stage label so callers can see where
* things broke ("loading priors", "opening db", "applying schema",
* "registering hook")
* things broke ("loading priors", "opening db", "registering hook")
* - Once init succeeds, the running handler is fire-and-forget it cannot
* crash the dispatch path
*
* @module sf-learning
*/
import { readFileSync } from "node:fs";
import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { dirname, join } from "node:path";
import {
getDatabase,
insertLlmTaskOutcome,
openDatabase as openSfDatabase,
} from "../sf-db.js";
import { writeFallbackChains } from "./fallback-chain-writer.mjs";
import {
createBeforeModelSelectHandler,
@ -49,26 +52,24 @@ import {
} from "./hook-handler.mjs";
import { loadCapabilityOverrides } from "./loadCapabilityOverrides.mjs";
import { aggregateAllForUnitType } from "./outcome-aggregator.mjs";
import { ensureSchema, recordOutcome } from "./outcome-recorder.mjs";
const MODULE_DIRECTORY = import.meta.dirname;
const SCHEMA_PATH = resolve(MODULE_DIRECTORY, "outcome-schema.sql");
const DEFAULT_DB_PATH = "~/.sf/sf-learning.db";
const DEFAULT_N_PRIOR = 10;
const DEFAULT_ROLLING_DAYS = 30;
const DEFAULT_EXPLORATION_C = 1.4;
const DEFAULT_DB_SUBPATH = [".sf", "sf.db"];
const HOME_REGEX = /^~(?=$|\/)/;
/**
* @typedef {Object} PluginConfig
* @property {string} [dbPath] - default: ~/.sf/sf-learning.db
* @property {string} [basePath] - default: process.cwd()
* @property {string} [dbPath] - default: <basePath>/.sf/sf.db
* @property {string} [priorsPath] - default: <plugin>/data/model-benchmarks.json
* @property {string} [weightsPath] - default: <plugin>/data/unit-weights.json
* @property {number} [nPrior=10]
* @property {number} [rollingDays=30]
* @property {number} [explorationC=1.4]
* @property {boolean} [explorationEnabled=true]
* @property {Object} [db] - pre-opened db handle (overrides dbPath)
* @property {Object} [db] - pre-opened db handle for hook reads
* @property {(msg: string) => void} [log]
*/
@ -92,93 +93,45 @@ function expandPath(path) {
}
/**
* Load the outcome-schema SQL file. Read once at init time; cheap.
* Resolve the shared SF database path for learning state.
*
* @returns {string}
*/
function loadSchemaSql() {
return readFileSync(SCHEMA_PATH, "utf8");
function defaultDbPath(config) {
return join(config.basePath ?? process.cwd(), ...DEFAULT_DB_SUBPATH);
}
/**
* Detect whether we're running under Bun. better-sqlite3 is a Node native
* addon and Bun has not shipped compatibility yet (tracked upstream in
* https://github.com/oven-sh/bun/issues/4290), so under Bun we use the
* built-in `bun:sqlite` module instead its Statement API (`prepare`,
* `run`, `get`, `all`, `exec`, `transaction`) is a drop-in superset of the
* surface this plugin consumes.
* Resolve the learning outcomes database from SF's shared database handle.
*
* @returns {boolean}
*/
function isBunRuntime() {
return typeof globalThis.Bun !== "undefined";
}
/**
* Dynamically import bun's built-in sqlite module. Only callable under Bun
* the import specifier `bun:sqlite` throws under Node.
*
* @returns {Promise<Function|null>}
*/
async function tryImportBunSqlite() {
try {
const mod = await import("bun:sqlite");
return mod.Database ?? mod.default ?? null;
} catch (_err) {
return null;
}
}
/**
* Dynamically import better-sqlite3. Returns null if the package is not
* installed so we can produce a clear error rather than an opaque module
* resolution failure.
*
* @returns {Promise<Function|null>} the better-sqlite3 default export, or null
*/
async function tryImportBetterSqlite() {
try {
const mod = await import("better-sqlite3");
return mod.default ?? mod;
} catch (_err) {
return null;
}
}
/**
* Open a database handle, either from the caller-supplied one or by
* dynamically loading a sqlite binding. Prefers `bun:sqlite` when running
* under Bun (better-sqlite3 is a Node native addon that Bun can't load),
* and falls back to `better-sqlite3` everywhere else.
* Purpose: keep UOK outcome learning, model stats, and learned routing on one
* `.sf/sf.db` ledger instead of splitting feedback into a sidecar database.
*
* @param {PluginConfig} config
* @returns {Promise<Object>} duck-typed sqlite handle
* @returns {Object} duck-typed sqlite handle
*/
async function openDatabase(config) {
function openDatabase(config) {
if (config.db) {
return config.db;
}
const dbPath = expandPath(config.dbPath ?? DEFAULT_DB_PATH);
if (isBunRuntime()) {
const BunDatabase = await tryImportBunSqlite();
if (!BunDatabase) {
throw new Error(
"sf-learning is running under Bun but failed to import `bun:sqlite`. This module ships with Bun itself — if this fails the Bun install is broken.",
);
}
return new BunDatabase(dbPath);
const activeDb = getDatabase();
if (activeDb) {
return activeDb;
}
const Database = await tryImportBetterSqlite();
if (!Database) {
throw new Error(
"sf-learning needs better-sqlite3 to open the outcomes database. Install it with `npm install better-sqlite3` or `bun add better-sqlite3`, or pass a pre-opened db handle via config.db.",
);
const dbPath = expandPath(config.dbPath ?? defaultDbPath(config));
if (dbPath !== ":memory:") {
mkdirSync(dirname(dbPath), { recursive: true });
}
return new Database(dbPath);
if (!openSfDatabase(dbPath)) {
throw new Error(`failed to open shared SF database at ${dbPath}`);
}
const db = getDatabase();
if (!db) {
throw new Error(`shared SF database did not become available at ${dbPath}`);
}
return db;
}
/**
@ -225,7 +178,7 @@ function wrapInitError(stage, err) {
}
/**
* Initialize the plugin: load priors, open db, bootstrap schema, register hook.
* Initialize the plugin: load priors, resolve shared db, register hook.
*
* @param {Object} pi
* @param {PluginConfig} [config={}]
@ -249,13 +202,6 @@ export async function init(pi, config = {}) {
throw wrapInitError("opening db", err);
}
try {
const schemaSql = loadSchemaSql();
ensureSchema(db, schemaSql);
} catch (err) {
throw wrapInitError("applying schema", err);
}
const deps = buildHookDeps(db, priors, config);
let unregister;
@ -293,7 +239,7 @@ export async function init(pi, config = {}) {
return {
unregister,
fallbackWriteSummary,
recordOutcome: (outcome) => recordOutcome(db, outcome),
recordOutcome: (outcome) => insertLlmTaskOutcome(outcome),
reloadPriors: async () => {
const fresh = await loadCapabilityOverrides({
benchmarksPath: config.priorsPath,

View file

@ -0,0 +1,63 @@
/**
* sf-learning plugin entrypoint tests.
*
* Exercises init-time wiring against a real Node built-in SQLite database so
* the plugin cannot regress to an undeclared external SQLite binding.
*/
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import { closeDatabase } from "../sf-db.js";
import { init } from "./index.mjs";
function createMockPi() {
const handlers = [];
return {
handlers,
on(event, handler) {
if (event === "before_model_select") handlers.push(handler);
},
off(event, handler) {
if (event !== "before_model_select") return;
const index = handlers.indexOf(handler);
if (index >= 0) handlers.splice(index, 1);
},
};
}
test("init_when_no_db_is_injected_uses_shared_sf_database", async () => {
const tmp = mkdtempSync(join(tmpdir(), "sf-learning-"));
const dbPath = join(tmp, ".sf", "sf.db");
let plugin;
try {
plugin = await init(createMockPi(), {
basePath: tmp,
explorationEnabled: false,
});
assert.equal(existsSync(dbPath), true);
assert.equal(
plugin.recordOutcome({
modelId: "test/model",
provider: "test",
unitType: "execute-task",
unitId: "M001/S01/T01",
succeeded: true,
}),
true,
);
const row = plugin.deps.db
.prepare("SELECT COUNT(*) AS count FROM llm_task_outcomes")
.get();
assert.equal(row.count, 1);
} finally {
plugin?.unregister?.();
closeDatabase();
rmSync(tmp, { recursive: true, force: true });
}
});

View file

@ -19,13 +19,13 @@ import {
} from "./hook-handler.mjs";
/**
* Fake in-memory db that mimics enough of better-sqlite3 for
* Fake in-memory db that mimics enough of node:sqlite DatabaseSync for
* outcome-recorder + outcome-aggregator to operate against array-backed rows.
*
* The aggregator runs SELECT ... GROUP BY model_id; rather than implementing a
* SQL parser, we recognize each statement by regex and compute the aggregate
* in JavaScript. This is sufficient for these tests and isolates them from a
* real native dependency.
* real file-backed database.
*/
function createFakeDb() {
const rows = [];

View file

@ -12,7 +12,8 @@
*
* ## Dependencies
* - Duck-typed SQLite handle exposing `prepare(sql).get(...params)` and
* `prepare(sql).all(...params)`. Compatible with `better-sqlite3`.
* `prepare(sql).all(...params)`. Compatible with Node's `node:sqlite`
* DatabaseSync.
*
* ## Contract
* - All SQL is parameterized no string interpolation of caller input.

View file

@ -10,4 +10,3 @@ export declare function recordOutcomeBatch(
db: unknown,
outcomes: Array<Record<string, unknown>>,
): number;
export declare function ensureSchema(db: unknown, schemaSql?: string): void;

View file

@ -6,7 +6,6 @@
* ## Responsibilities
* - Validate outcome shape before insertion
* - Insert one or many outcomes via parameterized SQL
* - Bootstrap the schema on a fresh database
*
* ## Contract fire-and-forget
* `recordOutcome` and `recordOutcomeBatch` must NEVER throw. They catch
@ -17,7 +16,7 @@
* ## Dependencies
* - Duck-typed SQLite handle exposing `prepare(sql).run(...params)`,
* `prepare(sql).get(...params)`, `prepare(sql).all(...params)` and
* ideally `exec(sql)`. Compatible with `better-sqlite3`.
* ideally `exec(sql)`. Compatible with Node's `node:sqlite` DatabaseSync.
* - No hard import of any SQLite library keeps this module standalone
* and unit-testable with an in-memory fake.
*
@ -233,8 +232,9 @@ export function recordOutcome(db, outcome) {
* Record many outcomes in a single transaction. Fire-and-forget never throws.
*
* Invalid rows are skipped and counted; valid rows are inserted. If the
* database supports `transaction()` (better-sqlite3 style), the inserts run
* inside it; otherwise they run sequentially.
* database supports `transaction()`, the inserts run inside it. With
* `node:sqlite`, batches are wrapped in an explicit SQL transaction to avoid
* repeated writer-lock churn.
*
* @param {object} db Duck-typed SQLite handle
* @param {Outcome[]} outcomes
@ -273,6 +273,23 @@ export function recordOutcomeBatch(db, outcomes) {
if (typeof db.transaction === "function") {
const txn = db.transaction(insertAll);
txn();
} else if (typeof db.exec === "function") {
let began = false;
try {
db.exec("BEGIN IMMEDIATE");
began = true;
insertAll();
db.exec("COMMIT");
} catch (err) {
if (began) {
try {
db.exec("ROLLBACK");
} catch (_rollbackErr) {
// Preserve the original failure for the outer catch.
}
}
throw err;
}
} else {
insertAll();
}
@ -284,43 +301,3 @@ export function recordOutcomeBatch(db, outcomes) {
return result;
}
/**
* Bootstrap the schema on a fresh database. Fire-and-forget never throws.
*
* Uses `db.exec(sql)` if available (better-sqlite3 style) so multi-statement
* DDL works in one call. Otherwise splits on `;` and runs each statement
* via `db.prepare(stmt).run()`.
*
* @param {object} db Duck-typed SQLite handle
* @param {string} schemaSql Raw schema SQL (CREATE TABLE / CREATE INDEX ...)
* @returns {boolean} true if schema applied, false on error
*
* @example
* import {readFileSync} from "node:fs";
* const sql = readFileSync(new URL("./outcome-schema.sql", import.meta.url), "utf8");
* ensureSchema(db, sql);
*/
export function ensureSchema(db, schemaSql) {
if (typeof schemaSql !== "string" || schemaSql.length === 0) {
return false;
}
try {
if (typeof db.exec === "function") {
db.exec(schemaSql);
return true;
}
const statements = schemaSql
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0 && !s.startsWith("--"));
for (const stmt of statements) {
db.prepare(stmt).run();
}
return true;
} catch (_err) {
return false;
}
}

View file

@ -2,7 +2,7 @@
* sf-learning: outcome-recorder + outcome-aggregator tests
*
* Uses node:test with a minimal in-memory fake `db` that mimics the
* better-sqlite3 surface (`prepare(sql).run/get/all`, `exec`,
* node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`,
* `transaction`). The fake parses just enough SQL to verify the
* insert and aggregate semantics without spinning up real SQLite.
*/
@ -16,14 +16,13 @@ import {
totalSamples,
} from "./outcome-aggregator.mjs";
import {
ensureSchema,
recordOutcome,
recordOutcomeBatch,
validateOutcome,
} from "./outcome-recorder.mjs";
// ---------------------------------------------------------------------------
// Minimal in-memory fake of better-sqlite3
// Minimal in-memory fake of the SQLite surface consumed by sf-learning.
// ---------------------------------------------------------------------------
const INSERT_COLUMNS = [
@ -42,8 +41,9 @@ const INSERT_COLUMNS = [
"recorded_at",
];
function createFakeDb({ throwOnPrepare = false } = {}) {
function createFakeDb({ includeTransaction = true, throwOnPrepare = false } = {}) {
const rows = [];
const execSql = [];
let nextId = 1;
function prepare(sql) {
@ -92,7 +92,6 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
};
}
// CREATE TABLE / CREATE INDEX from ensureSchema fallback path
if (
normalized.startsWith("create table") ||
normalized.startsWith("create index")
@ -107,7 +106,8 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
throw new Error(`fake db: unsupported sql: ${normalized.slice(0, 80)}`);
}
function exec(_sql) {
function exec(sql) {
execSql.push(sql);
// no-op — schema bootstrap success path
}
@ -117,12 +117,16 @@ function createFakeDb({ throwOnPrepare = false } = {}) {
};
}
return {
const db = {
prepare,
exec,
transaction,
_rows: rows,
_execSql: execSql,
};
if (includeTransaction) {
db.transaction = transaction;
}
return db;
}
function runAggregate(sql, params, rows) {
@ -363,30 +367,14 @@ test("recordOutcomeBatch handles empty array", () => {
assert.deepEqual(result, { inserted: 0, skipped: 0 });
});
// ---------------------------------------------------------------------------
// ensureSchema
// ---------------------------------------------------------------------------
test("ensureSchema returns true via db.exec path", () => {
const db = createFakeDb();
const ok = ensureSchema(db, "CREATE TABLE foo (x INTEGER);");
assert.equal(ok, true);
});
test("ensureSchema returns false on empty input", () => {
const db = createFakeDb();
assert.equal(ensureSchema(db, ""), false);
assert.equal(ensureSchema(db, null), false);
});
test("ensureSchema falls back to per-statement prepare when no exec()", () => {
const db = createFakeDb();
delete db.exec;
const ok = ensureSchema(
db,
"CREATE TABLE foo (x INTEGER); CREATE INDEX idx_foo ON foo(x);",
);
assert.equal(ok, true);
test("recordOutcomeBatch_when_no_transaction_helper_wraps_batch_with_sql_transaction", () => {
const db = createFakeDb({ includeTransaction: false });
const result = recordOutcomeBatch(db, [
minimalOutcome({ unitId: "T01" }),
minimalOutcome({ unitId: "T02" }),
]);
assert.deepEqual(result, { inserted: 2, skipped: 0 });
assert.deepEqual(db._execSql, ["BEGIN IMMEDIATE", "COMMIT"]);
});
// ---------------------------------------------------------------------------

View file

@ -1,30 +0,0 @@
-- sf-learning: llm_task_outcomes
-- Records per-unit LLM dispatch outcomes for Bayesian learning.
-- Shape is compatible with ace-coder's approved 2026-03-06 design so
-- cross-project data sharing can happen later without migration pain.
CREATE TABLE IF NOT EXISTS llm_task_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id TEXT NOT NULL,
provider TEXT NOT NULL,
unit_type TEXT NOT NULL,
unit_id TEXT NOT NULL,
succeeded INTEGER NOT NULL CHECK (succeeded IN (0, 1)),
retries INTEGER NOT NULL DEFAULT 0,
escalated INTEGER NOT NULL DEFAULT 0 CHECK (escalated IN (0, 1)),
verification_passed INTEGER CHECK (verification_passed IS NULL OR verification_passed IN (0, 1)),
blocker_discovered INTEGER NOT NULL DEFAULT 0 CHECK (blocker_discovered IN (0, 1)),
duration_ms INTEGER,
tokens_total INTEGER,
cost_usd REAL,
recorded_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_outcomes_model_unit_time
ON llm_task_outcomes (model_id, unit_type, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_outcomes_unit_time
ON llm_task_outcomes (unit_type, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_outcomes_provider_time
ON llm_task_outcomes (provider, recorded_at DESC);

View file

@ -8,6 +8,15 @@
* that need stable product terms.
*/
export const WORK_MODES = Object.freeze([
"chat",
"plan",
"build",
"review",
"repair",
"research",
]);
export const RUN_CONTROL_MODES = Object.freeze([
"manual",
"assisted",
@ -21,6 +30,23 @@ export const PERMISSION_PROFILES = Object.freeze([
"unrestricted",
]);
export const MODEL_MODES = Object.freeze([
"fast",
"smart",
"deep",
]);
/**
* Returns true for a canonical SF work mode.
*
* Purpose: let surfaces reject aliases before they leak into state.
*
* Consumer: command parsers and session state builders.
*/
export function isWorkMode(value) {
return WORK_MODES.includes(value);
}
/**
* Returns true for a canonical SF run-control mode.
*
@ -44,6 +70,28 @@ export function isPermissionProfile(value) {
return PERMISSION_PROFILES.includes(value);
}
/**
* Returns true for a canonical SF model mode.
*
* Purpose: let routing and command surfaces reject invalid model mode names.
*
* Consumer: model selection and command parsers.
*/
export function isModelMode(value) {
return MODEL_MODES.includes(value);
}
/**
* Resolve an unknown work mode to the conservative chat mode.
*
* Purpose: fail closed when work mode is absent or misspelled.
*
* Consumer: session state construction and command handlers.
*/
export function resolveWorkMode(value) {
return isWorkMode(value) ? value : "chat";
}
/**
* Resolve an unknown run-control value to the conservative manual mode.
*
@ -67,6 +115,17 @@ export function resolvePermissionProfile(value) {
return isPermissionProfile(value) ? value : "restricted";
}
/**
* Resolve an unknown model mode to smart.
*
* Purpose: provide a safe default when model mode is absent.
*
* Consumer: model routing and command handlers.
*/
export function resolveModelMode(value) {
return isModelMode(value) ? value : "smart";
}
/**
* Derive the UOK run-control mode from the live auto session state.
*
@ -90,3 +149,50 @@ export function runControlModeForSession(session) {
export function defaultPermissionProfileForRunControl(mode) {
return resolveRunControlMode(mode) === "manual" ? "restricted" : "normal";
}
/**
* Choose the default model mode for a work mode.
*
* Purpose: guide model routing without replacing explicit model selection.
*
* Consumer: model routing and session initialization.
*/
export function defaultModelModeForWorkMode(workMode) {
switch (resolveWorkMode(workMode)) {
case "plan":
case "review":
case "research":
return "deep";
case "build":
case "repair":
return "smart";
case "chat":
default:
return "fast";
}
}
/**
* Build a canonical mode state object.
*
* Purpose: standardize the five orthogonal axes so every surface stores
* and displays the same shape.
*
* Consumer: session state, TUI badges, command handlers, audit logs.
*/
export function buildModeState({
workMode = "chat",
runControl = "manual",
permissionProfile = "restricted",
modelMode = "smart",
surface = "tui",
} = {}) {
return {
workMode: resolveWorkMode(workMode),
runControl: resolveRunControlMode(runControl),
permissionProfile: resolvePermissionProfile(permissionProfile),
modelMode: resolveModelMode(modelMode),
surface,
updatedAt: new Date().toISOString(),
};
}

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {
"extensions": [

View file

@ -7,7 +7,6 @@
* Reads the same data sources as `scripts/parallel-monitor.mjs` but
* renders as a native pi-tui overlay with theme integration.
*/
import { spawn } from "node:child_process";
import {
closeSync,
existsSync,
@ -18,25 +17,19 @@ import {
statSync,
} from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { Key, matchesKey } from "@singularity-forge/pi-tui";
import { formatDuration } from "../shared/mod.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
// ─── Async SQLite Helper ──────────────────────────────────────────────────
function runSqliteAsync(dbPath, sql) {
return new Promise((resolve) => {
const child = spawn("sqlite3", [dbPath, sql], { timeout: 3000 });
const chunks = [];
child.stdout.on("data", (chunk) => chunks.push(chunk));
child.on("close", (code) => {
if (code !== 0) {
resolve("");
} else {
resolve(Buffer.concat(chunks).toString("utf-8"));
// ─── SQLite Helper ────────────────────────────────────────────────────────
function queryRows(dbPath, sql, params = []) {
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
return db.prepare(sql).all(...params).map((row) => ({ ...row }));
} finally {
db.close();
}
});
child.on("error", () => resolve(""));
});
}
// ─── Data Helpers ─────────────────────────────────────────────────────────
function readJsonSafe(filePath) {
@ -98,22 +91,28 @@ function discoverWorkers(basePath) {
}
return [...mids].sort();
}
async function querySliceProgress(basePath, mid) {
function querySliceProgress(basePath, mid) {
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
if (!existsSync(dbPath)) return [];
try {
const sql = `SELECT s.id, s.status, COUNT(t.id), SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) FROM slices s LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id WHERE s.milestone_id='${mid}' GROUP BY s.id ORDER BY s.id`;
const out = (await runSqliteAsync(dbPath, sql)).trim();
if (!out) return [];
return out.split("\n").map((line) => {
const [id, status, total, done] = line.split("|");
return {
id,
status,
total: parseInt(total, 10),
done: parseInt(done || "0", 10),
};
});
return queryRows(
dbPath,
`SELECT s.id AS id,
s.status AS status,
COUNT(t.id) AS total,
SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done
FROM slices s
LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id
WHERE s.milestone_id=?
GROUP BY s.id
ORDER BY s.id`,
[mid],
).map((row) => ({
id: row.id,
status: row.status,
total: Number(row.total ?? 0),
done: Number(row.done ?? 0),
}));
} catch {
return [];
}
@ -141,17 +140,25 @@ function extractCostFromNdjson(basePath, mid) {
return 0;
}
}
async function queryRecentCompletions(basePath, mid) {
function queryRecentCompletions(basePath, mid) {
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
if (!existsSync(dbPath)) return [];
try {
const sql = `SELECT id, slice_id, one_liner FROM tasks WHERE milestone_id='${mid}' AND status='complete' AND completed_at IS NOT NULL ORDER BY completed_at DESC LIMIT 5`;
const out = (await runSqliteAsync(dbPath, sql)).trim();
if (!out) return [];
return out.split("\n").map((line) => {
const [taskId, sliceId, oneLiner] = line.split("|");
return `${mid}/${sliceId}/${taskId}${oneLiner ? ": " + oneLiner : ""}`;
});
return queryRows(
dbPath,
`SELECT id AS taskId,
slice_id AS sliceId,
one_liner AS oneLiner
FROM tasks
WHERE milestone_id=?
AND status='complete'
AND completed_at IS NOT NULL
ORDER BY completed_at DESC
LIMIT 5`,
[mid],
).map((row) =>
`${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
);
} catch {
return [];
}

View file

@ -1,5 +1,5 @@
// SF Database Abstraction Layer
// Provides a SQLite database via node:sqlite (Node >= 24 built-in).
// Provides a SQLite database via node:sqlite (Node >= 26 built-in).
//
// Exposes a unified sync API for decisions and requirements storage.
// Schema is initialized on first open with WAL mode for file-backed DBs.
@ -29,7 +29,7 @@ let loadAttempted = false;
function loadProvider() {
if (loadAttempted) return;
loadAttempted = true;
// node:sqlite is built-in in Node >= 24
// node:sqlite is built-in in Node >= 26
}
function normalizeRow(row) {
if (row == null) return undefined;
@ -4860,7 +4860,7 @@ export function insertLlmTaskOutcome(input) {
":duration_ms": input.duration_ms ?? null,
":tokens_total": input.tokens_total ?? null,
":cost_usd": input.cost_usd ?? null,
":recorded_at": input.recorded_at,
":recorded_at": input.recorded_at ?? Date.now(),
});
return true;
} catch {

View file

@ -18,7 +18,7 @@ import { dirname, join } from "node:path";
import { describe, expect, test, vi } from "vitest";
const NODE_VERSION = parseInt(process.version.slice(1).split(".")[0], 10);
const HAS_SQLITE = NODE_VERSION >= 24;
const HAS_SQLITE = NODE_VERSION >= 26;
// ─── Helpers ───────────────────────────────────────────────────────────────
@ -114,7 +114,7 @@ describe("buildMemoryLLMCall apiKey resolution", () => {
expect(result).toBeNull();
}
: () => {
// Skip: requires node:sqlite (Node 24+)
// Skip: requires node:sqlite (Node 26+)
},
);
});
@ -132,7 +132,7 @@ describe("invalidateAllCaches", () => {
expect(() => invalidateAllCaches()).not.toThrow();
}
: () => {
// Skip: requires node:sqlite (Node 24+)
// Skip: requires node:sqlite (Node 26+)
},
);
});
@ -152,7 +152,7 @@ describe("createMemory", () => {
expect(result).toBeNull();
}
: () => {
// Skip: requires node:sqlite (Node 24+)
// Skip: requires node:sqlite (Node 26+)
},
);
});

View file

@ -1,17 +1,38 @@
import assert from "node:assert/strict";
import { describe, test } from "vitest";
import {
buildModeState,
defaultModelModeForWorkMode,
defaultPermissionProfileForRunControl,
isModelMode,
isPermissionProfile,
isRunControlMode,
isWorkMode,
MODEL_MODES,
PERMISSION_PROFILES,
RUN_CONTROL_MODES,
resolveModelMode,
resolvePermissionProfile,
resolveRunControlMode,
resolveWorkMode,
RUN_CONTROL_MODES,
WORK_MODES,
runControlModeForSession,
} from "../operating-model.js";
describe("operating model vocabulary", () => {
test("workModes_are_chat_plan_build_review_repair_research", () => {
assert.deepEqual(WORK_MODES, [
"chat",
"plan",
"build",
"review",
"repair",
"research",
]);
assert.equal(isWorkMode("chat"), true);
assert.equal(isWorkMode("debug"), false);
});
test("runControlModes_are_manual_assisted_autonomous_only", () => {
assert.deepEqual(RUN_CONTROL_MODES, ["manual", "assisted", "autonomous"]);
assert.equal(isRunControlMode("auto"), false);
@ -28,9 +49,17 @@ describe("operating model vocabulary", () => {
assert.equal(isPermissionProfile("autonomous"), false);
});
test("modelModes_are_fast_smart_deep", () => {
assert.deepEqual(MODEL_MODES, ["fast", "smart", "deep"]);
assert.equal(isModelMode("smart"), true);
assert.equal(isModelMode("rush"), false);
});
test("resolvers_fail_closed", () => {
assert.equal(resolveWorkMode("debug"), "chat");
assert.equal(resolveRunControlMode("auto"), "manual");
assert.equal(resolvePermissionProfile("full"), "restricted");
assert.equal(resolveModelMode("rush"), "smart");
});
test("session_step_bit_derives_assisted_or_autonomous_run_control", () => {
@ -43,4 +72,52 @@ describe("operating model vocabulary", () => {
assert.equal(defaultPermissionProfileForRunControl("assisted"), "normal");
assert.equal(defaultPermissionProfileForRunControl("manual"), "restricted");
});
test("default_model_mode_matches_work_mode_intent", () => {
assert.equal(defaultModelModeForWorkMode("plan"), "deep");
assert.equal(defaultModelModeForWorkMode("review"), "deep");
assert.equal(defaultModelModeForWorkMode("research"), "deep");
assert.equal(defaultModelModeForWorkMode("build"), "smart");
assert.equal(defaultModelModeForWorkMode("repair"), "smart");
assert.equal(defaultModelModeForWorkMode("chat"), "fast");
assert.equal(defaultModelModeForWorkMode("unknown"), "fast");
});
test("buildModeState_normalizes_all_axes", () => {
const state = buildModeState({
workMode: "build",
runControl: "autonomous",
permissionProfile: "trusted",
modelMode: "smart",
surface: "tui",
});
assert.equal(state.workMode, "build");
assert.equal(state.runControl, "autonomous");
assert.equal(state.permissionProfile, "trusted");
assert.equal(state.modelMode, "smart");
assert.equal(state.surface, "tui");
assert.ok(state.updatedAt);
});
test("buildModeState_fails_closed_on_invalid_values", () => {
const state = buildModeState({
workMode: "debug",
runControl: "auto",
permissionProfile: "full",
modelMode: "rush",
});
assert.equal(state.workMode, "chat");
assert.equal(state.runControl, "manual");
assert.equal(state.permissionProfile, "restricted");
assert.equal(state.modelMode, "smart");
});
test("buildModeState_has_safe_defaults", () => {
const state = buildModeState();
assert.equal(state.workMode, "chat");
assert.equal(state.runControl, "manual");
assert.equal(state.permissionProfile, "restricted");
assert.equal(state.modelMode, "smart");
assert.equal(state.surface, "tui");
});
});

View file

@ -0,0 +1,108 @@
/**
* uok-coordination-store.test.mjs SQLite-backed UOK coordination contracts.
*
* Purpose: prove UOK has Redis-like durable primitives without requiring a
* Redis server: TTL keys, append-only streams, and lease-based queues.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js";
import {
ensureCoordinationSchema,
UokCoordinationStore,
} from "../uok/coordination-store.js";
afterEach(() => {
closeDatabase();
});
function makeStore(now = () => 1_000) {
assert.equal(openDatabase(":memory:"), true);
const db = getDatabase();
return new UokCoordinationStore(db, { now });
}
test("ensureCoordinationSchema_creates_kv_stream_and_queue_tables", () => {
assert.equal(openDatabase(":memory:"), true);
const db = getDatabase();
ensureCoordinationSchema(db);
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all()
.map((r) => r.name);
assert.ok(tables.includes("uok_kv"));
assert.ok(tables.includes("uok_stream_entries"));
assert.ok(tables.includes("uok_queue_items"));
});
test("kv_set_get_and_expire_behaves_like_ttl_store", () => {
let now = 1_000;
const store = makeStore(() => now);
store.set("lock:unit", { owner: "agent-a" }, { ttlMs: 100 });
assert.deepEqual(store.get("lock:unit"), { owner: "agent-a" });
now = 1_101;
assert.equal(store.get("lock:unit"), null);
});
test("kv_cleanupExpired_removes_expired_rows", () => {
let now = 1_000;
const store = makeStore(() => now);
store.set("a", 1, { ttlMs: 1 });
store.set("b", 2);
now = 1_002;
assert.equal(store.cleanupExpired(), 1);
assert.equal(store.get("a"), null);
assert.equal(store.get("b"), 2);
});
test("streams_append_and_read_after_id_in_order", () => {
const store = makeStore();
const first = store.xadd("events", "node-start", { id: "a" });
const second = store.xadd("events", "node-complete", { id: "a" });
assert.equal(first, 1);
assert.equal(second, 2);
assert.deepEqual(
store.xread("events", { afterId: 1 }).map((e) => e.type),
["node-complete"],
);
assert.deepEqual(store.xread("events").map((e) => e.payload.id), ["a", "a"]);
});
test("queue_claim_ack_and_release_enforces_owner_leases", () => {
let now = 1_000;
const store = makeStore(() => now);
store.enqueue("work", "task-1", { unitId: "T01" });
const claimed = store.claim("work", "agent-a", { leaseMs: 50 });
assert.equal(claimed.id, "task-1");
assert.equal(claimed.leaseOwner, "agent-a");
assert.equal(store.claim("work", "agent-b"), null);
assert.equal(store.ack("work", "task-1", "agent-b"), false);
assert.equal(store.release("work", "task-1", "agent-a"), true);
const reclaimed = store.claim("work", "agent-b", { leaseMs: 50 });
assert.equal(reclaimed.leaseOwner, "agent-b");
assert.equal(store.ack("work", "task-1", "agent-b"), true);
assert.equal(store.peekQueueItem("work", "task-1").status, "done");
now = 2_000;
assert.equal(store.reapExpiredLeases("work"), 0);
});
test("queue_reapExpiredLeases_returns_stale_leased_work_to_ready", () => {
let now = 1_000;
const store = makeStore(() => now);
store.enqueue("work", "task-1", { unitId: "T01" });
store.claim("work", "agent-a", { leaseMs: 10 });
now = 1_011;
assert.equal(store.reapExpiredLeases("work"), 1);
const claimed = store.claim("work", "agent-b", { leaseMs: 10 });
assert.equal(claimed.leaseOwner, "agent-b");
assert.equal(claimed.attempts, 2);
});

View file

@ -0,0 +1,320 @@
/**
* uok-execution-graph-persist.test.mjs Execution graph persistence contract tests.
*
* Purpose: verify SQLite schema creation, graph/node/progress CRUD, and
* query surfaces for /tasks display.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import {
closeDatabase,
getDatabase,
openDatabase,
} from "../sf-db.js";
import {
ensureExecutionGraphSchema,
getGraphProgressStream,
getGraphStateSummary,
persistFullGraph,
persistGraphNode,
persistGraphSnapshot,
persistProgressEvent,
queryTasksByState,
} from "../uok/execution-graph-persist.js";
afterEach(() => {
closeDatabase();
});
function makeDb() {
assert.equal(openDatabase(":memory:"), true);
return getDatabase();
}
// ─── Schema ────────────────────────────────────────────────────────────────
test("ensureExecutionGraphSchema_creates_tables_and_indexes", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all()
.map((r) => r.name);
assert.ok(tables.includes("uok_execution_graphs"));
assert.ok(tables.includes("uok_graph_nodes"));
assert.ok(tables.includes("uok_graph_progress"));
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type='index'")
.all()
.map((r) => r.name);
assert.ok(indexes.some((n) => n.includes("idx_graph_nodes_graph")));
assert.ok(indexes.some((n) => n.includes("idx_graph_nodes_status")));
});
// ─── Graph Snapshot ────────────────────────────────────────────────────────
test("persistGraphSnapshot_inserts_new_graph", () => {
const db = makeDb();
persistGraphSnapshot(db, {
id: "g1",
milestoneId: "M001",
sessionId: "sess-1",
phase: "executing",
nodeCount: 5,
createdAt: new Date().toISOString(),
status: "active",
});
const row = db
.prepare("SELECT * FROM uok_execution_graphs WHERE id = :id")
.get({ id: "g1" });
assert.equal(row.id, "g1");
assert.equal(row.milestone_id, "M001");
assert.equal(row.status, "active");
});
test("persistGraphSnapshot_updates_existing_graph", () => {
const db = makeDb();
const id = "g1";
persistGraphSnapshot(db, {
id,
milestoneId: "M001",
phase: "executing",
nodeCount: 5,
createdAt: new Date().toISOString(),
status: "active",
});
persistGraphSnapshot(db, {
id,
milestoneId: "M001",
phase: "completing-milestone",
nodeCount: 5,
createdAt: new Date().toISOString(),
status: "completed",
});
const row = db
.prepare("SELECT * FROM uok_execution_graphs WHERE id = :id")
.get({ id });
assert.equal(row.phase, "completing-milestone");
assert.equal(row.status, "completed");
assert.ok(row.completed_at);
});
// ─── Graph Node ────────────────────────────────────────────────────────────
test("persistGraphNode_inserts_node", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitType: "execute-task",
unitId: "M001.S01.T01",
milestoneId: "M001",
sliceId: "S01",
taskId: "T01",
title: "Implement auth",
dependsOn: ["n0"],
writes: ["src/auth.js"],
state: "todo",
});
const row = db
.prepare("SELECT * FROM uok_graph_nodes WHERE id = :id")
.get({ id: "n1" });
assert.equal(row.id, "n1");
assert.equal(row.unit_type, "execute-task");
assert.equal(row.status, "todo");
assert.equal(JSON.parse(row.depends_on)[0], "n0");
});
test("persistGraphNode_updates_existing_node", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitType: "execute-task",
unitId: "M001.S01.T01",
state: "todo",
});
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitType: "execute-task",
unitId: "M001.S01.T01",
state: "done",
workerId: "w-1",
durationMs: 1500,
gateResults: [{ outcome: "pass", gateId: "security" }],
});
const row = db
.prepare("SELECT * FROM uok_graph_nodes WHERE id = :id")
.get({ id: "n1" });
assert.equal(row.status, "done");
assert.equal(row.worker_id, "w-1");
assert.equal(row.duration_ms, 1500);
});
// ─── Full Graph Persistence ────────────────────────────────────────────────
test("persistFullGraph_writes_snapshot_and_nodes_in_transaction", () => {
const db = makeDb();
const nodes = [
{ id: "n1", kind: "unit", unitType: "execute-task", unitId: "U1", state: "todo" },
{ id: "n2", kind: "unit", unitType: "execute-task", unitId: "U2", state: "todo" },
];
persistFullGraph(db, "g2", nodes, {
milestoneId: "M002",
phase: "executing",
});
const graph = db
.prepare("SELECT * FROM uok_execution_graphs WHERE id = :id")
.get({ id: "g2" });
assert.equal(graph.node_count, 2);
const nodeRows = db
.prepare("SELECT * FROM uok_graph_nodes WHERE graph_id = :id")
.all({ id: "g2" });
assert.equal(nodeRows.length, 2);
});
// ─── Query Tasks ───────────────────────────────────────────────────────────
test("queryTasksByState_filters_by_state", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "done" });
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "in_progress" });
const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] });
assert.equal(todo.length, 1);
assert.equal(todo[0].id, "n1");
const done = queryTasksByState(db, { graphId: "g1", states: ["done"] });
assert.equal(done.length, 1);
assert.equal(done[0].id, "n2");
});
test("queryTasksByState_filters_by_milestone", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", milestoneId: "M001", state: "todo" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", milestoneId: "M002", state: "todo" });
const m1 = queryTasksByState(db, { milestoneId: "M001" });
assert.equal(m1.length, 1);
assert.equal(m1[0].id, "n1");
});
test("queryTasksByState_parses_json_columns", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", {
id: "n1",
kind: "unit",
unitId: "U1",
state: "done",
dependsOn: ["n0"],
writes: ["a.js", "b.js"],
gateResults: [{ outcome: "pass", gateId: "g1" }],
});
const tasks = queryTasksByState(db, { graphId: "g1" });
assert.equal(tasks[0].dependsOn.length, 1);
assert.equal(tasks[0].writes.length, 2);
assert.equal(tasks[0].gateResults.length, 1);
});
// ─── State Summary ─────────────────────────────────────────────────────────
test("getGraphStateSummary_computes_counts_and_progress", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "in_progress" });
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "done" });
persistGraphNode(db, "g1", { id: "n4", kind: "unit", unitId: "U4", state: "done" });
const summary = getGraphStateSummary(db, "g1");
assert.equal(summary.total, 4);
assert.equal(summary.terminal, 2);
assert.equal(summary.progress, 50);
assert.equal(summary.isComplete, false);
assert.equal(summary.counts.todo, 1);
assert.equal(summary.counts.in_progress, 1);
assert.equal(summary.counts.done, 2);
});
test("getGraphStateSummary_complete_when_all_terminal", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "done" });
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "failed" });
const summary = getGraphStateSummary(db, "g1");
assert.equal(summary.progress, 100);
assert.equal(summary.isComplete, true);
});
// ─── Progress Stream ───────────────────────────────────────────────────────
test("persistProgressEvent_inserts_event", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistProgressEvent(db, "g1", {
type: "node-start",
nodeId: "n1",
ts: new Date().toISOString(),
});
const events = getGraphProgressStream(db, "g1", 10);
assert.equal(events.length, 1);
assert.equal(events[0].type, "node-start");
assert.equal(events[0].node_id, "n1");
});
test("getGraphProgressStream_returns_recent_first", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistProgressEvent(db, "g1", { type: "first", ts: "2024-01-01T00:00:00Z" });
persistProgressEvent(db, "g1", { type: "second", ts: "2024-01-01T00:00:01Z" });
const events = getGraphProgressStream(db, "g1", 10);
assert.equal(events[0].type, "second");
assert.equal(events[1].type, "first");
});
test("getGraphProgressStream_parses_payload_json", () => {
const db = makeDb();
ensureExecutionGraphSchema(db);
persistProgressEvent(db, "g1", {
type: "node-complete",
nodeId: "n1",
payload: JSON.stringify({ extra: "data" }),
ts: new Date().toISOString(),
});
const events = getGraphProgressStream(db, "g1", 10);
assert.equal(events[0].payload.extra, "data");
});

View file

@ -0,0 +1,434 @@
/**
* uok-scheduler-v2.test.mjs ExecutionGraphSchedulerV2 contract tests.
*
* Purpose: verify cancellation tokens, progress streaming, worker pool
* enforcement, and task state integration.
*/
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js";
import {
getGraphProgressStream,
getGraphStateSummary,
queryTasksByState,
} from "../uok/execution-graph-persist.js";
import {
CancellationToken,
ExecutionGraphSchedulerV2,
ProgressStream,
WorkerPool,
} from "../uok/scheduler-v2.js";
afterEach(() => {
closeDatabase();
});
// ─── CancellationToken ─────────────────────────────────────────────────────
test("token_initially_not_cancelled", () => {
const token = new CancellationToken();
assert.equal(token.isCancelled, false);
assert.equal(token.reason, null);
});
test("token_cancel_sets_state", () => {
const token = new CancellationToken();
token.cancel("timeout");
assert.equal(token.isCancelled, true);
assert.equal(token.reason, "timeout");
});
test("token_cancel_idempotent", () => {
const token = new CancellationToken();
token.cancel("first");
token.cancel("second");
assert.equal(token.reason, "first");
});
test("token_throwIfCancelled_throws_when_cancelled", () => {
const token = new CancellationToken();
token.cancel("abort");
assert.throws(() => token.throwIfCancelled(), /abort/);
});
test("token_throwIfCancelled_does_not_throw_when_active", () => {
const token = new CancellationToken();
assert.doesNotThrow(() => token.throwIfCancelled());
});
test("token_onCancel_fires_immediately_if_already_cancelled", () => {
const token = new CancellationToken();
token.cancel("done");
let fired = false;
token.onCancel(() => { fired = true; });
assert.equal(fired, true);
});
test("token_onCancel_fires_when_cancelled_later", () => {
const token = new CancellationToken();
let fired = false;
token.onCancel(() => { fired = true; });
assert.equal(fired, false);
token.cancel("later");
assert.equal(fired, true);
});
test("token_onCancel_unsubscribe_works", () => {
const token = new CancellationToken();
let fired = false;
const unsub = token.onCancel(() => { fired = true; });
unsub();
token.cancel("nope");
assert.equal(fired, false);
});
// ─── ProgressStream ────────────────────────────────────────────────────────
test("stream_emit_delivers_to_listener", () => {
const stream = new ProgressStream();
const events = [];
stream.onProgress((ev) => events.push(ev));
stream.emit({ type: "test", data: 1 });
assert.equal(events.length, 1);
assert.equal(events[0].type, "test");
assert.ok(events[0].ts);
});
test("stream_replays_history_to_new_listener", () => {
const stream = new ProgressStream();
stream.emit({ type: "first" });
stream.emit({ type: "second" });
const events = [];
stream.onProgress((ev) => events.push(ev));
assert.equal(events.length, 2);
assert.equal(events[0].type, "first");
assert.equal(events[1].type, "second");
});
test("stream_history_capped_at_max", () => {
const stream = new ProgressStream();
// Private maxHistory = 100; emit 105 events
for (let i = 0; i < 105; i++) {
stream.emit({ type: `ev-${i}` });
}
const events = [];
stream.onProgress((ev) => events.push(ev));
assert.equal(events.length, 100);
assert.equal(events[0].type, "ev-5");
});
// ─── WorkerPool ────────────────────────────────────────────────────────────
test("pool_enforces_max_workers", async () => {
const pool = new WorkerPool(2);
const token1 = new CancellationToken();
const token2 = new CancellationToken();
const token3 = new CancellationToken();
const w1 = await pool.acquire("node-1", token1);
const w2 = await pool.acquire("node-2", token2);
// Third acquire should queue, not resolve immediately
let w3Resolved = false;
const w3Promise = pool.acquire("node-3", token3).then((w) => {
w3Resolved = true;
return w;
});
assert.equal(pool.activeCount, 2);
assert.equal(pool.queuedCount, 1);
assert.equal(w3Resolved, false);
w1.release();
const w3 = await w3Promise;
assert.equal(w3Resolved, true);
assert.equal(pool.activeCount, 2);
w2.release();
w3.release();
assert.equal(pool.activeCount, 0);
});
test("pool_cancel_queued_rejects_queued_acquire", async () => {
const pool = new WorkerPool(1);
const token1 = new CancellationToken();
const token2 = new CancellationToken();
const w1 = await pool.acquire("node-1", token1);
const acquire2 = pool.acquire("node-2", token2);
pool.cancelAll("drain");
await assert.rejects(acquire2, /drain/);
w1.release();
});
test("pool_getActive_returns_current_workers", async () => {
const pool = new WorkerPool(2);
const token = new CancellationToken();
const w = await pool.acquire("node-a", token);
const active = pool.getActive();
assert.equal(active.length, 1);
assert.equal(active[0].nodeId, "node-a");
assert.ok(active[0].durationMs >= 0);
w.release();
});
// ─── ExecutionGraphSchedulerV2 — Serial ────────────────────────────────────
test("scheduler_serial_runs_all_nodes_in_order", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const order = [];
scheduler.registerHandler("unit", async (node) => {
order.push(node.id);
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
{ id: "c", kind: "unit", dependsOn: ["b"], metadata: {} },
];
const result = await scheduler.run(nodes, { parallel: false });
assert.deepEqual(order, ["a", "b", "c"]);
assert.deepEqual(result.order, ["a", "b", "c"]);
});
test("scheduler_serial_respects_dependencies", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const order = [];
scheduler.registerHandler("unit", async (node) => {
order.push(node.id);
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: [], metadata: {} },
{ id: "c", kind: "unit", dependsOn: ["a", "b"], metadata: {} },
];
await scheduler.run(nodes, { parallel: false });
assert.ok(order.indexOf("c") > order.indexOf("a"));
assert.ok(order.indexOf("c") > order.indexOf("b"));
});
test("scheduler_serial_cancellation_stops_execution", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const order = [];
const globalToken = new CancellationToken();
scheduler.registerHandler("unit", async (node, token) => {
if (node.id === "b") globalToken.cancel("stop");
globalToken.throwIfCancelled();
order.push(node.id);
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
{ id: "c", kind: "unit", dependsOn: ["b"], metadata: {} },
];
await assert.rejects(
scheduler.run(nodes, { parallel: false, globalToken }),
/stop/,
);
assert.deepEqual(order, ["a"]);
});
// ─── ExecutionGraphSchedulerV2 — Parallel ──────────────────────────────────
test("scheduler_parallel_runs_independent_nodes_concurrently", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
const starts = [];
scheduler.registerHandler("unit", async (node, token, progress) => {
starts.push(node.id);
await new Promise((r) => setTimeout(r, 50));
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: [], metadata: {} },
];
const result = await scheduler.run(nodes, { parallel: true, maxWorkers: 2 });
assert.equal(result.order.length, 2);
assert.ok(result.order.includes("a"));
assert.ok(result.order.includes("b"));
});
test("scheduler_parallel_enforces_max_workers", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
let concurrent = 0;
let maxConcurrent = 0;
scheduler.registerHandler("unit", async (node) => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 50));
concurrent--;
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
{ id: "b", kind: "unit", dependsOn: [], metadata: {} },
{ id: "c", kind: "unit", dependsOn: [], metadata: {} },
{ id: "d", kind: "unit", dependsOn: [], metadata: {} },
];
await scheduler.run(nodes, { parallel: true, maxWorkers: 2 });
assert.equal(maxConcurrent, 2);
});
test("scheduler_parallel_reports_progress_events", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => {
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
});
const nodes = [
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
];
const events = [];
scheduler.progress.onProgress((ev) => events.push(ev));
await scheduler.run(nodes, { parallel: true, maxWorkers: 1 });
const startEvent = events.find((e) => e.type === "node-start");
const completeEvent = events.find((e) => e.type === "node-complete");
assert.ok(startEvent);
assert.ok(completeEvent);
assert.equal(completeEvent.nodeId, "a");
assert.equal(completeEvent.state, "done");
});
test("scheduler_parallel_task_records_have_correct_state", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => {
return {
gateResults: [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
],
};
});
const nodes = [
{ id: "task-1", kind: "unit", dependsOn: [], metadata: { unitId: "U1" } },
];
await scheduler.run(nodes, { parallel: true, maxWorkers: 1 });
const task = scheduler.getTask("task-1");
assert.equal(task.state, "done");
assert.equal(task.unitId, "U1");
assert.ok(task.startedAt);
assert.ok(task.endedAt);
});
test("scheduler_parallel_detects_cyclic_dependencies", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
const nodes = [
{ id: "a", kind: "unit", dependsOn: ["b"], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
];
await assert.rejects(
scheduler.run(nodes, { parallel: true }),
/cyclic/,
);
});
test("scheduler_parallel_detects_deadlock", async () => {
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
// a depends on b, b depends on a — cyclic, but also no nodes with in-degree 0
const nodes = [
{ id: "a", kind: "unit", dependsOn: ["b"], metadata: {} },
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
];
await assert.rejects(
scheduler.run(nodes, { parallel: true }),
/cyclic/,
);
});
// ─── Progress stream wiring ────────────────────────────────────────────────
test("scheduler_wires_progress_to_parent_stream", async () => {
const parent = new ProgressStream();
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async () => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
const parentEvents = [];
parent.onProgress((ev) => parentEvents.push(ev));
const nodes = [
{ id: "x", kind: "unit", dependsOn: [], metadata: {} },
];
await scheduler.run(nodes, { parallel: false, parentStream: parent });
assert.ok(parentEvents.some((e) => e.type === "node-complete"));
});
// ─── SQLite persistence ───────────────────────────────────────────────────
test("scheduler_when_db_given_persists_graph_tasks_and_progress", async () => {
assert.equal(openDatabase(":memory:"), true);
const db = getDatabase();
const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async () => ({
gateResults: [{ outcome: "pass", gateId: "test" }],
}));
const nodes = [
{
id: "task-1",
kind: "unit",
dependsOn: [],
writes: ["src/a.js"],
metadata: {
unitType: "execute-task",
unitId: "M001/S01/T01",
milestoneId: "M001",
sliceId: "S01",
taskId: "T01",
title: "Implement A",
},
},
];
await scheduler.run(nodes, {
parallel: false,
db,
graphId: "graph-persisted",
graphMeta: { milestoneId: "M001", phase: "executing" },
});
const tasks = queryTasksByState(db, { graphId: "graph-persisted" });
assert.equal(tasks.length, 1);
assert.equal(tasks[0].id, "task-1");
assert.equal(tasks[0].status, "done");
assert.deepEqual(tasks[0].writes, ["src/a.js"]);
const summary = getGraphStateSummary(db, "graph-persisted");
assert.equal(summary.total, 1);
assert.equal(summary.progress, 100);
assert.equal(summary.isComplete, true);
const events = getGraphProgressStream(db, "graph-persisted", 10);
assert.ok(events.some((e) => e.type === "node-start"));
assert.ok(events.some((e) => e.type === "node-complete"));
});

View file

@ -0,0 +1,238 @@
/**
* uok-task-state.test.mjs Task state machine contract tests.
*
* Purpose: verify gate-outcome-to-task-state mapping, unit-runtime bridging,
* state transition validity, and aggregate computation.
*/
import assert from "node:assert/strict";
import { test } from "vitest";
import {
TASK_STATES,
TASK_TERMINAL_STATES,
TASK_STATE_TRANSITIONS,
aggregateTaskStates,
buildTaskRecord,
canTransitionTaskState,
gateOutcomesToTaskState,
unitRuntimeToTaskState,
} from "../uok/task-state.js";
// ─── Constants ─────────────────────────────────────────────────────────────
test("TASK_STATES_has_all_orch_states", () => {
assert.deepEqual(TASK_STATES, [
"todo",
"in_progress",
"review",
"done",
"retrying",
"failed",
"cancelled",
]);
});
test("TASK_TERMINAL_STATES_includes_done_failed_cancelled", () => {
assert.equal(TASK_TERMINAL_STATES.has("done"), true);
assert.equal(TASK_TERMINAL_STATES.has("failed"), true);
assert.equal(TASK_TERMINAL_STATES.has("cancelled"), true);
assert.equal(TASK_TERMINAL_STATES.has("todo"), false);
assert.equal(TASK_TERMINAL_STATES.has("in_progress"), false);
});
// ─── Gate outcomes → task state ────────────────────────────────────────────
test("gateOutcomesToTaskState_empty_returns_todo", () => {
assert.equal(gateOutcomesToTaskState([]), "todo");
assert.equal(gateOutcomesToTaskState(null), "todo");
assert.equal(gateOutcomesToTaskState(undefined), "todo");
});
test("gateOutcomesToTaskState_all_pass_returns_done", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
];
assert.equal(gateOutcomesToTaskState(results), "done");
});
test("gateOutcomesToTaskState_any_fail_returns_failed", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "fail", gateId: "cost", failureClass: "policy" },
];
assert.equal(gateOutcomesToTaskState(results), "failed");
});
test("gateOutcomesToTaskState_any_manual_attention_returns_review", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "manual-attention", gateId: "verification" },
];
assert.equal(gateOutcomesToTaskState(results), "review");
});
test("gateOutcomesToTaskState_any_retry_returns_retrying", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "retry", gateId: "execution" },
];
assert.equal(gateOutcomesToTaskState(results), "retrying");
});
test("gateOutcomesToTaskState_mixed_nonterminal_returns_in_progress", () => {
const results = [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
{ outcome: "pass", gateId: "verification" },
];
// All pass = done, not in_progress
assert.equal(gateOutcomesToTaskState(results), "done");
});
// ─── Unit runtime → task state ─────────────────────────────────────────────
test("unitRuntimeToTaskState_null_returns_todo", () => {
assert.equal(unitRuntimeToTaskState(null), "todo");
});
test("unitRuntimeToTaskState_queued_returns_todo", () => {
assert.equal(unitRuntimeToTaskState({ status: "queued" }), "todo");
});
test("unitRuntimeToTaskState_claimed_returns_todo", () => {
assert.equal(unitRuntimeToTaskState({ status: "claimed" }), "todo");
});
test("unitRuntimeToTaskState_running_returns_in_progress", () => {
assert.equal(unitRuntimeToTaskState({ status: "running" }), "in_progress");
});
test("unitRuntimeToTaskState_progress_returns_in_progress", () => {
assert.equal(unitRuntimeToTaskState({ status: "progress" }), "in_progress");
});
test("unitRuntimeToTaskState_completed_returns_done", () => {
assert.equal(unitRuntimeToTaskState({ status: "completed" }), "done");
});
test("unitRuntimeToTaskState_failed_returns_failed", () => {
assert.equal(unitRuntimeToTaskState({ status: "failed" }), "failed");
});
test("unitRuntimeToTaskState_blocked_returns_review", () => {
assert.equal(unitRuntimeToTaskState({ status: "blocked" }), "review");
});
test("unitRuntimeToTaskState_cancelled_returns_cancelled", () => {
assert.equal(unitRuntimeToTaskState({ status: "cancelled" }), "cancelled");
});
test("unitRuntimeToTaskState_stale_returns_retrying", () => {
assert.equal(unitRuntimeToTaskState({ status: "stale" }), "retrying");
});
test("unitRuntimeToTaskState_runaway_recovered_returns_retrying", () => {
assert.equal(
unitRuntimeToTaskState({ status: "runaway-recovered" }),
"retrying",
);
});
// ─── State transitions ─────────────────────────────────────────────────────
test("canTransitionTaskState_valid_returns_true", () => {
assert.equal(canTransitionTaskState("todo", "in_progress"), true);
assert.equal(canTransitionTaskState("in_progress", "done"), true);
assert.equal(canTransitionTaskState("failed", "retrying"), true);
});
test("canTransitionTaskState_invalid_returns_false", () => {
assert.equal(canTransitionTaskState("todo", "done"), false);
assert.equal(canTransitionTaskState("done", "in_progress"), false);
assert.equal(canTransitionTaskState("cancelled", "todo"), false);
});
test("canTransitionTaskState_self_loop_returns_false", () => {
assert.equal(canTransitionTaskState("todo", "todo"), false);
});
// ─── Aggregate ─────────────────────────────────────────────────────────────
test("aggregateTaskStates_empty_returns_zero_progress", () => {
const agg = aggregateTaskStates([]);
assert.equal(agg.total, 0);
assert.equal(agg.terminal, 0);
assert.equal(agg.progress, 0);
assert.equal(agg.isComplete, false);
});
test("aggregateTaskStates_counts_correctly", () => {
const agg = aggregateTaskStates([
"todo",
"in_progress",
"done",
"done",
"failed",
]);
assert.equal(agg.total, 5);
assert.equal(agg.terminal, 3); // done(2) + failed(1)
assert.equal(agg.progress, 60);
assert.equal(agg.isComplete, false);
assert.equal(agg.counts.todo, 1);
assert.equal(agg.counts.in_progress, 1);
assert.equal(agg.counts.done, 2);
assert.equal(agg.counts.failed, 1);
});
test("aggregateTaskStates_complete_when_all_terminal", () => {
const agg = aggregateTaskStates(["done", "done", "cancelled"]);
assert.equal(agg.progress, 100);
assert.equal(agg.isComplete, true);
});
// ─── Build task record ─────────────────────────────────────────────────────
test("buildTaskRecord_with_gate_results_uses_gate_state", () => {
const task = buildTaskRecord({
nodeId: "execute-task:M001:S01:T01",
unitType: "execute-task",
unitId: "M001.S01.T01",
milestoneId: "M001",
sliceId: "S01",
taskId: "T01",
title: "Implement auth",
gateResults: [
{ outcome: "pass", gateId: "security" },
{ outcome: "pass", gateId: "cost" },
],
});
assert.equal(task.state, "done");
assert.equal(task.unitId, "M001.S01.T01");
assert.equal(task.title, "Implement auth");
assert.ok(task.updatedAt);
});
test("buildTaskRecord_with_runtime_uses_runtime_state", () => {
const task = buildTaskRecord({
nodeId: "execute-task:M001:S01:T01",
unitType: "execute-task",
unitId: "M001.S01.T01",
runtimeRecord: { status: "running" },
gateResults: [],
});
assert.equal(task.state, "in_progress");
});
test("buildTaskRecord_includes_worker_id", () => {
const task = buildTaskRecord({
nodeId: "n1",
unitType: "execute-task",
unitId: "M001.S01.T01",
gateResults: [{ outcome: "pass", gateId: "g1" }],
workerId: "w-3",
durationMs: 1500,
});
assert.equal(task.workerId, "w-3");
assert.equal(task.durationMs, 1500);
});

View file

@ -19,7 +19,7 @@ let loadAttempted = false;
function loadProvider() {
if (loadAttempted) return;
loadAttempted = true;
// node:sqlite is built-in in Node >= 24
// node:sqlite is built-in in Node >= 26
}
function normalizeRow(row) {
if (row == null) return undefined;

View file

@ -0,0 +1,334 @@
/**
* UOK Coordination Store
*
* Purpose: provide Redis-like coordination primitives on top of the project
* SQLite database so UOK can lease work, keep TTL state, append streams, and
* coordinate background tasks without adding a server process.
*
* Consumer: UOK scheduler, /tasks, parallel dispatch, and future background
* work controllers that need durable coordination inside `.sf/sf.db`.
*/
const DEFAULT_NOW = () => Date.now();
/**
* Ensure coordination tables exist in the shared SF database.
*
* Purpose: let callers opt into queue, stream, and TTL primitives without
* coupling them to the main SF schema migration cadence during greenfield UOK
* iteration.
*
* Consumer: UokCoordinationStore constructor and direct tests.
*/
export function ensureCoordinationSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS uok_kv (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
expires_at INTEGER DEFAULT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_uok_kv_expires ON uok_kv(expires_at);
CREATE TABLE IF NOT EXISTS uok_stream_entries (
stream TEXT NOT NULL,
id INTEGER NOT NULL,
type TEXT NOT NULL,
payload_json TEXT NOT NULL,
ts INTEGER NOT NULL,
PRIMARY KEY (stream, id)
);
CREATE INDEX IF NOT EXISTS idx_uok_stream_entries_ts
ON uok_stream_entries(stream, ts);
CREATE TABLE IF NOT EXISTS uok_queue_items (
queue TEXT NOT NULL,
id TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ready',
available_at INTEGER NOT NULL,
lease_owner TEXT DEFAULT NULL,
lease_until INTEGER DEFAULT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (queue, id)
);
CREATE INDEX IF NOT EXISTS idx_uok_queue_ready
ON uok_queue_items(queue, status, available_at, lease_until);
`);
}
function encode(value) {
return JSON.stringify(value ?? null);
}
function decode(text) {
return text ? JSON.parse(text) : null;
}
function normalizeRow(row) {
if (row == null) return null;
return Object.getPrototypeOf(row) === null ? { ...row } : row;
}
/**
* Durable Redis-like coordination primitives backed by SQLite.
*
* Purpose: keep UOK coordination local, inspectable, and serverless while
* preserving the useful parts of Redis: TTL values, append-only streams, and
* lease-based queues.
*
* Consumer: scheduler v2 and future UOK background workers.
*/
export class UokCoordinationStore {
constructor(db, { now = DEFAULT_NOW } = {}) {
this.db = db;
this.now = now;
ensureCoordinationSchema(db);
}
set(key, value, { ttlMs = null } = {}) {
const now = this.now();
const expiresAt = ttlMs == null ? null : now + ttlMs;
this.db
.prepare(
`INSERT INTO uok_kv (key, value_json, expires_at, updated_at)
VALUES (:key, :value, :expiresAt, :now)
ON CONFLICT(key) DO UPDATE SET
value_json = excluded.value_json,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at`,
)
.run({ key, value: encode(value), expiresAt, now });
return true;
}
get(key) {
const now = this.now();
const row = normalizeRow(
this.db
.prepare(
`SELECT value_json, expires_at FROM uok_kv
WHERE key = :key`,
)
.get({ key }),
);
if (!row) return null;
if (row.expires_at != null && row.expires_at <= now) {
this.delete(key);
return null;
}
return decode(row.value_json);
}
delete(key) {
this.db.prepare("DELETE FROM uok_kv WHERE key = :key").run({ key });
}
expire(key, ttlMs) {
const result = this.db
.prepare(
`UPDATE uok_kv
SET expires_at = :expiresAt, updated_at = :now
WHERE key = :key`,
)
.run({ key, expiresAt: this.now() + ttlMs, now: this.now() });
return (result?.changes ?? 0) > 0;
}
cleanupExpired() {
const result = this.db
.prepare(
`DELETE FROM uok_kv
WHERE expires_at IS NOT NULL AND expires_at <= :now`,
)
.run({ now: this.now() });
return result?.changes ?? 0;
}
xadd(stream, type, payload) {
const now = this.now();
const row = normalizeRow(
this.db
.prepare(
`SELECT COALESCE(MAX(id), 0) + 1 AS next_id
FROM uok_stream_entries
WHERE stream = :stream`,
)
.get({ stream }),
);
const id = row?.next_id ?? 1;
this.db
.prepare(
`INSERT INTO uok_stream_entries (stream, id, type, payload_json, ts)
VALUES (:stream, :id, :type, :payload, :ts)`,
)
.run({ stream, id, type, payload: encode(payload), ts: now });
return id;
}
xread(stream, { afterId = 0, limit = 100 } = {}) {
return this.db
.prepare(
`SELECT id, type, payload_json, ts
FROM uok_stream_entries
WHERE stream = :stream AND id > :afterId
ORDER BY id ASC
LIMIT :limit`,
)
.all({ stream, afterId, limit })
.map((row) => ({
id: row.id,
type: row.type,
payload: decode(row.payload_json),
ts: row.ts,
}));
}
enqueue(queue, id, payload, { delayMs = 0 } = {}) {
const now = this.now();
this.db
.prepare(
`INSERT INTO uok_queue_items (
queue, id, payload_json, status, available_at, created_at, updated_at
) VALUES (
:queue, :id, :payload, 'ready', :availableAt, :now, :now
)
ON CONFLICT(queue, id) DO UPDATE SET
payload_json = excluded.payload_json,
status = 'ready',
available_at = excluded.available_at,
lease_owner = NULL,
lease_until = NULL,
updated_at = excluded.updated_at`,
)
.run({
queue,
id,
payload: encode(payload),
availableAt: now + delayMs,
now,
});
return id;
}
claim(queue, owner, { leaseMs = 30_000 } = {}) {
const now = this.now();
const row = normalizeRow(
this.db
.prepare(
`SELECT id
FROM uok_queue_items
WHERE queue = :queue
AND status = 'ready'
AND available_at <= :now
AND (lease_until IS NULL OR lease_until <= :now)
ORDER BY available_at ASC, created_at ASC, id ASC
LIMIT 1`,
)
.get({ queue, now }),
);
if (!row) return null;
const result = this.db
.prepare(
`UPDATE uok_queue_items
SET status = 'leased',
lease_owner = :owner,
lease_until = :leaseUntil,
attempts = attempts + 1,
updated_at = :now
WHERE queue = :queue
AND id = :id
AND status = 'ready'
AND (lease_until IS NULL OR lease_until <= :now)`,
)
.run({
queue,
id: row.id,
owner,
leaseUntil: now + leaseMs,
now,
});
if ((result?.changes ?? 0) !== 1) return null;
return this.peekQueueItem(queue, row.id);
}
ack(queue, id, owner) {
const result = this.db
.prepare(
`UPDATE uok_queue_items
SET status = 'done', updated_at = :now
WHERE queue = :queue
AND id = :id
AND lease_owner = :owner
AND status = 'leased'`,
)
.run({ queue, id, owner, now: this.now() });
return (result?.changes ?? 0) === 1;
}
release(queue, id, owner, { delayMs = 0 } = {}) {
const now = this.now();
const result = this.db
.prepare(
`UPDATE uok_queue_items
SET status = 'ready',
available_at = :availableAt,
lease_owner = NULL,
lease_until = NULL,
updated_at = :now
WHERE queue = :queue
AND id = :id
AND lease_owner = :owner
AND status = 'leased'`,
)
.run({ queue, id, owner, availableAt: now + delayMs, now });
return (result?.changes ?? 0) === 1;
}
reapExpiredLeases(queue = null) {
const now = this.now();
const sql =
queue == null
? `UPDATE uok_queue_items
SET status = 'ready', lease_owner = NULL, lease_until = NULL, updated_at = :now
WHERE status = 'leased' AND lease_until <= :now`
: `UPDATE uok_queue_items
SET status = 'ready', lease_owner = NULL, lease_until = NULL, updated_at = :now
WHERE queue = :queue AND status = 'leased' AND lease_until <= :now`;
const params = queue == null ? { now } : { queue, now };
const result = this.db.prepare(sql).run(params);
return result?.changes ?? 0;
}
peekQueueItem(queue, id) {
const row = normalizeRow(
this.db
.prepare(
`SELECT *
FROM uok_queue_items
WHERE queue = :queue AND id = :id`,
)
.get({ queue, id }),
);
if (!row) return null;
return {
queue: row.queue,
id: row.id,
payload: decode(row.payload_json),
status: row.status,
availableAt: row.available_at,
leaseOwner: row.lease_owner,
leaseUntil: row.lease_until,
attempts: row.attempts,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View file

@ -0,0 +1,374 @@
/**
* UOK Execution Graph Persistence
*
* Purpose: persist execution graph snapshots and task state to SQLite so the
* /tasks surface can query background work without parsing JSON files. Graph
* state is the authority; JSON files under .sf/runtime are read-only
* projections for recovery and external tools.
*
* Consumer: ExecutionGraphSchedulerV2, /tasks command, and UOK kernel
* background-work tracking.
*/
/**
* Ensure the execution graph tables exist.
*
* Purpose: idempotent schema setup so the scheduler can write task state
* without knowing the full DB schema.
*/
export function ensureExecutionGraphSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS uok_execution_graphs (
id TEXT PRIMARY KEY,
milestone_id TEXT,
session_id TEXT,
phase TEXT,
node_count INTEGER,
created_at TEXT NOT NULL,
completed_at TEXT,
status TEXT DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS uok_graph_nodes (
id TEXT NOT NULL,
graph_id TEXT NOT NULL,
kind TEXT NOT NULL,
unit_type TEXT,
unit_id TEXT,
milestone_id TEXT,
slice_id TEXT,
task_id TEXT,
title TEXT,
depends_on TEXT, -- JSON array of node IDs
writes TEXT, -- JSON array of file paths
status TEXT DEFAULT 'todo',
worker_id TEXT,
started_at TEXT,
ended_at TEXT,
duration_ms INTEGER,
cost_usd REAL,
model_id TEXT,
gate_results TEXT, -- JSON array
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (graph_id, id)
);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_graph ON uok_graph_nodes(graph_id);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_status ON uok_graph_nodes(status);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_unit ON uok_graph_nodes(unit_type, unit_id);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_milestone ON uok_graph_nodes(milestone_id);
CREATE TABLE IF NOT EXISTS uok_graph_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
graph_id TEXT NOT NULL,
node_id TEXT,
type TEXT NOT NULL,
payload TEXT, -- JSON
ts TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_graph_progress_graph ON uok_graph_progress(graph_id);
`);
}
/**
* Insert or update a graph snapshot.
*/
export function persistGraphSnapshot(db, snapshot) {
ensureExecutionGraphSchema(db);
const {
id,
milestoneId,
sessionId,
phase,
nodeCount,
createdAt,
status = "active",
} = snapshot;
db.prepare(
`
INSERT INTO uok_execution_graphs (id, milestone_id, session_id, phase, node_count, created_at, status)
VALUES (:id, :milestoneId, :sessionId, :phase, :nodeCount, :createdAt, :status)
ON CONFLICT(id) DO UPDATE SET
milestone_id = COALESCE(excluded.milestone_id, milestone_id),
session_id = COALESCE(excluded.session_id, session_id),
phase = COALESCE(excluded.phase, phase),
node_count = COALESCE(excluded.node_count, node_count),
status = excluded.status,
completed_at = CASE
WHEN excluded.status IN ('completed','cancelled','failed') THEN COALESCE(completed_at, datetime('now'))
ELSE completed_at
END
`,
).run({
id,
milestoneId: milestoneId ?? null,
sessionId: sessionId ?? null,
phase: phase ?? null,
nodeCount: nodeCount ?? null,
createdAt,
status,
});
}
/**
* Insert or update a graph node (task record).
*/
export function persistGraphNode(db, graphId, node) {
ensureExecutionGraphSchema(db);
const {
id,
kind = "unit",
unitType,
unitId,
milestoneId,
sliceId,
taskId,
title,
dependsOn = [],
writes = [],
state = "todo",
workerId,
startedAt,
endedAt,
durationMs,
costUsd,
modelId,
gateResults = [],
error,
} = node;
db.prepare(
`
INSERT INTO uok_graph_nodes (
id, graph_id, kind, unit_type, unit_id, milestone_id, slice_id, task_id,
title, depends_on, writes, status, worker_id, started_at, ended_at,
duration_ms, cost_usd, model_id, gate_results, error, updated_at
) VALUES (
:id, :graphId, :kind, :unitType, :unitId, :milestoneId, :sliceId, :taskId,
:title, :dependsOn, :writes, :state, :workerId, :startedAt, :endedAt,
:durationMs, :costUsd, :modelId, :gateResults, :error, datetime('now')
)
ON CONFLICT(graph_id, id) DO UPDATE SET
kind = excluded.kind,
unit_type = COALESCE(excluded.unit_type, unit_type),
unit_id = COALESCE(excluded.unit_id, unit_id),
milestone_id = COALESCE(excluded.milestone_id, milestone_id),
slice_id = COALESCE(excluded.slice_id, slice_id),
task_id = COALESCE(excluded.task_id, task_id),
title = COALESCE(excluded.title, title),
depends_on = excluded.depends_on,
writes = excluded.writes,
status = excluded.status,
worker_id = excluded.worker_id,
started_at = COALESCE(excluded.started_at, started_at),
ended_at = excluded.ended_at,
duration_ms = excluded.duration_ms,
cost_usd = excluded.cost_usd,
model_id = excluded.model_id,
gate_results = excluded.gate_results,
error = excluded.error,
updated_at = excluded.updated_at
`,
).run({
id,
graphId,
kind,
unitType: unitType ?? null,
unitId: unitId ?? null,
milestoneId: milestoneId ?? null,
sliceId: sliceId ?? null,
taskId: taskId ?? null,
title: title ?? null,
dependsOn: JSON.stringify(dependsOn),
writes: JSON.stringify(writes),
state,
workerId: workerId ?? null,
startedAt: startedAt ?? null,
endedAt: endedAt ?? null,
durationMs: durationMs ?? null,
costUsd: costUsd ?? null,
modelId: modelId ?? null,
gateResults: JSON.stringify(gateResults),
error: error ?? null,
});
}
/**
* Record a progress event.
*/
export function persistProgressEvent(db, graphId, event) {
ensureExecutionGraphSchema(db);
let payload = event.payload ?? event;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch {
payload = { text: payload };
}
}
db.prepare(
`
INSERT INTO uok_graph_progress (graph_id, node_id, type, payload, ts)
VALUES (:graphId, :nodeId, :type, :payload, :ts)
`,
).run({
graphId,
nodeId: event.nodeId ?? null,
type: event.type,
payload: JSON.stringify(payload),
ts: event.ts ?? new Date().toISOString(),
});
}
/**
* Query tasks by state for /tasks display.
*/
export function queryTasksByState(db, filters = {}) {
const {
graphId,
milestoneId,
sliceId,
states = [],
limit = 100,
offset = 0,
} = filters;
const conditions = ["1=1"];
const params = {};
if (graphId) {
conditions.push("graph_id = :graphId");
params.graphId = graphId;
}
if (milestoneId) {
conditions.push("milestone_id = :milestoneId");
params.milestoneId = milestoneId;
}
if (sliceId) {
conditions.push("slice_id = :sliceId");
params.sliceId = sliceId;
}
if (states.length > 0) {
conditions.push(`status IN (${states.map((_, i) => `:s${i}`).join(", ")})`);
for (let i = 0; i < states.length; i++) {
params[`s${i}`] = states[i];
}
}
const where = conditions.join(" AND ");
const rows = db
.prepare(
`
SELECT * FROM uok_graph_nodes
WHERE ${where}
ORDER BY updated_at DESC
LIMIT :limit OFFSET :offset
`,
)
.all({ ...params, limit, offset });
return rows.map((r) => ({
...r,
dependsOn: r.depends_on ? JSON.parse(r.depends_on) : [],
writes: r.writes ? JSON.parse(r.writes) : [],
gateResults: r.gate_results ? JSON.parse(r.gate_results) : [],
}));
}
/**
* Get aggregate counts by state for a graph.
*/
export function getGraphStateSummary(db, graphId) {
const rows = db
.prepare(
`
SELECT status, COUNT(*) as count
FROM uok_graph_nodes
WHERE graph_id = :graphId
GROUP BY status
`,
)
.all({ graphId });
const counts = Object.fromEntries(
["todo", "in_progress", "review", "done", "retrying", "failed", "cancelled"].map(
(s) => [s, 0],
),
);
let total = 0;
for (const r of rows) {
counts[r.status] = r.count;
total += r.count;
}
const terminal = ["done", "failed", "cancelled"].reduce(
(sum, s) => sum + (counts[s] ?? 0),
0,
);
return {
graphId,
counts,
total,
terminal,
progress: total > 0 ? Math.round((terminal / total) * 100) : 0,
isComplete: terminal === total && total > 0,
};
}
/**
* Get recent progress events for a graph.
*/
export function getGraphProgressStream(db, graphId, limit = 50) {
return db
.prepare(
`
SELECT * FROM uok_graph_progress
WHERE graph_id = :graphId
ORDER BY ts DESC
LIMIT :limit
`,
)
.all({ graphId, limit })
.map((r) => ({
...r,
payload: r.payload ? JSON.parse(r.payload) : null,
}));
}
/**
* Convenience: persist an entire graph with all nodes in one transaction.
*/
export function persistFullGraph(db, graphId, nodes, meta = {}) {
db.exec("BEGIN IMMEDIATE");
try {
persistGraphSnapshot(db, {
id: graphId,
milestoneId: meta.milestoneId,
sessionId: meta.sessionId,
phase: meta.phase,
nodeCount: nodes.length,
createdAt: new Date().toISOString(),
status: meta.status ?? "active",
});
for (const node of nodes) {
persistGraphNode(db, graphId, node);
}
db.exec("COMMIT");
} catch (err) {
try {
db.exec("ROLLBACK");
} catch {
// Preserve the original persistence failure.
}
throw err;
}
}

View file

@ -0,0 +1,169 @@
/**
* UOK Unified Orchestration Kernel
*
* Purpose: export the complete UOK surface so consumers import from a single
* module instead of reaching into individual files. This is the public API
* contract for all UOK functionality.
*
* Consumer: auto-dispatch, auto-verification, commands, tests, and any
* extension that needs orchestration primitives.
*/
// ─── Contracts & Types ────────────────────────────────────────────────────
export { validateGate } from "./contracts.js";
// ─── Core Kernel ───────────────────────────────────────────────────────────
export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js";
// ─── Gate System ───────────────────────────────────────────────────────────
export { UokGateRunner, enrichGateResultWithMemory } from "./gate-runner.js";
// ─── Gates ─────────────────────────────────────────────────────────────────
export { ChaosMonkey, ChaosMonkeyGate } from "./chaos-monkey.js";
export { CostGuardGate } from "./cost-guard-gate.js";
export { MultiPackageGate } from "./multi-package-gate.js";
export { OutcomeLearningGate } from "./outcome-learning-gate.js";
export { SecurityGate } from "./security-gate.js";
// ─── Flags & Configuration ─────────────────────────────────────────────────
export { resolveUokFlags, loadUokFlags } from "./flags.js";
// ─── Execution Graph ───────────────────────────────────────────────────────
export {
selectConflictFreeBatch,
selectReactiveDispatchBatch,
buildSidecarQueueNodes,
buildExecutionGraphSnapshot,
scheduleSidecarQueue,
ExecutionGraphScheduler,
} from "./execution-graph.js";
// ─── Scheduler v2 (Background Work) ────────────────────────────────────────
export {
CancellationToken,
ProgressStream,
WorkerPool,
ExecutionGraphSchedulerV2,
} from "./scheduler-v2.js";
// ─── Task State Machine ────────────────────────────────────────────────────
export {
TASK_STATES,
TASK_TERMINAL_STATES,
TASK_STATE_TRANSITIONS,
gateOutcomesToTaskState,
unitRuntimeToTaskState,
canTransitionTaskState,
aggregateTaskStates,
buildTaskRecord,
} from "./task-state.js";
// ─── Execution Graph Persistence ───────────────────────────────────────────
export {
ensureExecutionGraphSchema,
persistGraphSnapshot,
persistGraphNode,
persistProgressEvent,
queryTasksByState,
getGraphStateSummary,
getGraphProgressStream,
persistFullGraph,
} from "./execution-graph-persist.js";
// ─── Coordination Store ───────────────────────────────────────────────────
export {
ensureCoordinationSchema,
UokCoordinationStore,
} from "./coordination-store.js";
// ─── Unit Runtime ──────────────────────────────────────────────────────────
export {
UNIT_RUNTIME_STATUSES,
UNIT_RUNTIME_TERMINAL_STATUSES,
UNIT_RUNTIME_TRANSITIONS,
isTerminalUnitRuntimeStatus,
getUnitRuntimeState,
isSyntheticUnitRuntime,
decideUnitRuntimeDispatch,
writeUnitRuntimeRecord,
readUnitRuntimeRecord,
clearUnitRuntimeRecord,
listUnitRuntimeRecords,
recordUnitOutcomeInMemory,
inspectExecuteTaskDurability,
formatExecuteTaskRecoveryStatus,
reconcileStaleCompleteSliceRecords,
reconcileDurableCompleteUnitRuntimeRecords,
} from "./unit-runtime.js";
// ─── Plan v2 ───────────────────────────────────────────────────────────────
export {
EXECUTION_ENTRY_PHASES,
isExecutionEntryPhase,
compileUnitGraphFromState,
hasFinalizedMilestoneContext,
isMissingFinalizedContextResult,
isEmptyPlanV2GraphResult,
ensurePlanV2Graph,
} from "./plan-v2.js";
// ─── Audit & Observability ─────────────────────────────────────────────────
export { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
export { setAuditEnvelopeEnabled, isAuditEnvelopeEnabled } from "./audit-toggle.js";
// ─── Diagnostics ───────────────────────────────────────────────────────────
export {
synthesizeUokDiagnostics,
writeUokDiagnostics,
readUokDiagnostics,
} from "./diagnostic-synthesis.js";
// ─── Parity & Ledger ───────────────────────────────────────────────────────
export {
writeParityReport,
readParityReport,
summarizeParityHealth,
writeParityHeartbeat,
parseParityEvents,
UNMATCHED_RUN_STALE_MS,
signalKernelEnter,
resetParityCommitBlock,
checkAndDrainMissingExit,
} from "./parity-report.js";
export {
signalKernelEnter as signalParityEnter,
} from "./parity-diff-capture.js";
// ─── Message Bus ───────────────────────────────────────────────────────────
export { AgentInbox, MessageBus } from "./message-bus.js";
// ─── Metrics ───────────────────────────────────────────────────────────────
export {
buildMetricsText,
invalidateMetricsCache,
metricsPath,
writeUokMetrics,
readUokMetrics,
} from "./metrics-exposition.js";
// ─── GitOps ────────────────────────────────────────────────────────────────
export {
writeTurnGitTransaction,
writeTurnCloseoutGitRecord,
} from "./gitops.js";
// ─── Writer Token ──────────────────────────────────────────────────────────
export {
acquireWriterToken,
releaseWriterToken,
nextWriteRecord,
} from "./writer.js";
// ─── Model Policy ──────────────────────────────────────────────────────────
export { applyModelPolicyFilter } from "./model-policy.js";
// ─── Loop Adapter ──────────────────────────────────────────────────────────
export { createTurnObserver } from "./loop-adapter.js";
// ─── Dispatch Envelope ─────────────────────────────────────────────────────
export { buildDispatchEnvelope, explainDispatch } from "./dispatch-envelope.js";

View file

@ -0,0 +1,620 @@
/**
* UOK Execution Graph Scheduler v2
*
* Purpose: extend the v1 scheduler with cancellation tokens, progress
* streaming, and worker pool enforcement for background /tasks work.
* Maintains backward compatibility with v1's serial/parallel modes.
*
* Consumer: /tasks background work, parallel unit dispatch, and UOK kernel
* when running multiple gates concurrently.
*/
import { debugLog } from "../debug-logger.js";
import { getUnitTimeout } from "../preferences.js";
import {
persistFullGraph,
persistGraphNode,
persistGraphSnapshot,
persistProgressEvent,
} from "./execution-graph-persist.js";
import { buildTaskRecord, gateOutcomesToTaskState } from "./task-state.js";
const DEFAULT_UNIT_TIMEOUT_MS = 300_000; // 5 minutes fallback
const DEFAULT_SCHEDULER_TIMEOUT_MS = 3_600_000; // 1 hour default for background work
/**
* Cancellation token for cooperative task abort.
*/
export class CancellationToken {
#cancelled = false;
#reason = null;
#listeners = new Set();
cancel(reason = "cancelled") {
if (this.#cancelled) return;
this.#cancelled = true;
this.#reason = reason;
for (const fn of this.#listeners) {
try { fn(reason); } catch { /* ignore */ }
}
this.#listeners.clear();
}
get isCancelled() {
return this.#cancelled;
}
get reason() {
return this.#reason;
}
onCancel(fn) {
if (this.#cancelled) {
try { fn(this.#reason); } catch { /* ignore */ }
return () => {};
}
this.#listeners.add(fn);
return () => this.#listeners.delete(fn);
}
throwIfCancelled() {
if (this.#cancelled) {
const err = new Error(this.#reason ?? "cancelled");
err.name = "CancellationError";
throw err;
}
}
}
/**
* Progress stream for reporting task state changes back to parent session.
*/
export class ProgressStream {
#listeners = new Set();
#history = [];
#maxHistory = 100;
emit(event) {
const enriched = {
...event,
ts: new Date().toISOString(),
};
this.#history.push(enriched);
if (this.#history.length > this.#maxHistory) {
this.#history = this.#history.slice(-this.#maxHistory);
}
for (const fn of this.#listeners) {
try { fn(enriched); } catch { /* ignore */ }
}
}
onProgress(fn) {
this.#listeners.add(fn);
// Replay recent history so new listener catches up
for (const ev of this.#history) {
try { fn(ev); } catch { /* ignore */ }
}
return () => this.#listeners.delete(fn);
}
getHistory() {
return [...this.#history];
}
}
/**
* Worker pool with limit enforcement and graceful drain.
*/
export class WorkerPool {
#maxWorkers;
#active = new Map(); // workerId -> { nodeId, startedAt, token }
#queue = [];
#counter = 0;
constructor(maxWorkers = 2) {
this.#maxWorkers = Math.max(1, Math.min(32, maxWorkers));
}
get maxWorkers() {
return this.#maxWorkers;
}
get activeCount() {
return this.#active.size;
}
get queuedCount() {
return this.#queue.length;
}
get isAtCapacity() {
return this.#active.size >= this.#maxWorkers;
}
async acquire(nodeId, token) {
return new Promise((resolve, reject) => {
const tryAcquire = () => {
if (token?.isCancelled) {
reject(new Error(token.reason ?? "cancelled"));
return;
}
if (this.#active.size < this.#maxWorkers) {
this.#counter++;
const workerId = `w-${this.#counter}`;
const startedAt = Date.now();
this.#active.set(workerId, { nodeId, startedAt, token });
resolve({
workerId,
release: () => this.#release(workerId),
});
return;
}
this.#queue.push({ nodeId, token, resolve, reject });
};
tryAcquire();
});
}
#release(workerId) {
this.#active.delete(workerId);
// Drain queue
while (this.#queue.length > 0 && this.#active.size < this.#maxWorkers) {
const next = this.#queue.shift();
if (next.token?.isCancelled) {
next.reject(new Error(next.token.reason ?? "cancelled"));
continue;
}
this.#counter++;
const newWorkerId = `w-${this.#counter}`;
const startedAt = Date.now();
this.#active.set(newWorkerId, {
nodeId: next.nodeId,
startedAt,
token: next.token,
});
next.resolve({
workerId: newWorkerId,
release: () => this.#release(newWorkerId),
});
}
}
cancelAll(reason = "pool-drained") {
// Reject all queued items first
for (const item of this.#queue) {
if (item.token && !item.token.isCancelled) item.token.cancel(reason);
item.reject(new Error(reason));
}
this.#queue = [];
// Then cancel active workers
for (const [, { token }] of this.#active) {
if (token && !token.isCancelled) token.cancel(reason);
}
}
getActive() {
return Array.from(this.#active.entries()).map(([workerId, info]) => ({
workerId,
nodeId: info.nodeId,
durationMs: Date.now() - info.startedAt,
}));
}
}
/**
* v2 Scheduler with cancellation, progress streaming, and worker pool.
*
* Extends v1 semantics:
* - serial mode: unchanged deterministic topological order
* - parallel mode: worker pool enforces max concurrency, cancellation
* stops new work, progress streams report state changes
*/
export class ExecutionGraphSchedulerV2 {
handlers = new Map();
#pool = null;
#progress = new ProgressStream();
#tasks = new Map(); // nodeId -> task record
#db = null;
#graphId = null;
registerHandler(kind, handler) {
this.handlers.set(kind, handler);
}
get progress() {
return this.#progress;
}
getTask(nodeId) {
return this.#tasks.get(nodeId);
}
getAllTasks() {
return Array.from(this.#tasks.values());
}
async run(nodes, options = {}) {
const {
parallel = false,
maxWorkers = 2,
globalToken = new CancellationToken(),
parentStream = null,
timeoutMs = DEFAULT_SCHEDULER_TIMEOUT_MS,
db = null,
graphId = `uok-graph-${Date.now()}`,
graphMeta = {},
} = options;
this.#tasks.clear();
this.#db = db;
this.#graphId = db ? graphId : null;
// Wire progress stream to parent if provided
if (parentStream) {
this.#progress.onProgress((ev) => parentStream.emit(ev));
}
if (this.#db && this.#graphId) {
this.#persistInitialGraph(nodes, graphMeta);
this.#progress.onProgress((ev) => this.#persistProgress(ev));
}
const sorted = this.#topologicalSort(nodes);
const conflicts = this.#detectFileConflicts(nodes);
// Global timeout: auto-cancel if scheduler runs too long
const timeoutToken = new CancellationToken();
const timeoutHandle = setTimeout(() => {
timeoutToken.cancel(`scheduler-timeout-${timeoutMs}ms`);
}, timeoutMs);
// Link timeout to global token
timeoutToken.onCancel((reason) => {
if (globalToken && !globalToken.isCancelled) {
globalToken.cancel(reason);
}
});
try {
if (!parallel) {
const result = await this.#runSerial(sorted, globalToken, conflicts);
this.#persistGraphStatus("completed", sorted.length);
return result;
}
this.#pool = new WorkerPool(maxWorkers);
const result = await this.#runParallel(
sorted,
globalToken,
conflicts,
maxWorkers,
);
this.#persistGraphStatus("completed", sorted.length);
return result;
} finally {
clearTimeout(timeoutHandle);
}
}
async #runSerial(sorted, token, conflicts) {
const order = [];
for (const node of sorted) {
token.throwIfCancelled();
const result = await this.#executeNode(node, token, null);
order.push(result.nodeId);
this.#progress.emit({
type: "node-complete",
nodeId: result.nodeId,
state: result.state,
durationMs: result.durationMs,
});
}
return { order, conflicts, tasks: this.getAllTasks() };
}
async #runParallel(sorted, token, conflicts, maxWorkers) {
const nodeMap = new Map(sorted.map((n) => [n.id, n]));
const done = new Set();
const order = [];
const promises = new Map(); // nodeId -> promise
this.#progress.emit({
type: "scheduler-start",
nodeCount: sorted.length,
maxWorkers,
});
while (done.size < sorted.length) {
token.throwIfCancelled();
const ready = sorted.filter(
(n) =>
!done.has(n.id) &&
!promises.has(n.id) &&
n.dependsOn.every((dep) => done.has(dep)),
);
if (ready.length === 0 && promises.size === 0) {
throw new Error(
"Execution graph deadlock detected: no ready nodes and graph not complete",
);
}
// Start as many ready nodes as pool allows
for (const node of ready) {
if (this.#pool.isAtCapacity) break;
if (promises.has(node.id)) continue;
const nodeToken = new CancellationToken();
// Link to global token
token.onCancel((reason) => nodeToken.cancel(reason));
const acquirePromise = this.#pool
.acquire(node.id, nodeToken)
.then(async ({ workerId, release }) => {
try {
const result = await this.#executeNode(
node,
nodeToken,
workerId,
);
done.add(node.id);
order.push(result.nodeId);
promises.delete(node.id);
this.#progress.emit({
type: "node-complete",
nodeId: result.nodeId,
state: result.state,
durationMs: result.durationMs,
workerId,
});
return result;
} finally {
release();
}
})
.catch((err) => {
if (err.name === "CancellationError") {
this.#progress.emit({
type: "node-cancelled",
nodeId: node.id,
reason: err.message,
});
} else {
this.#progress.emit({
type: "node-error",
nodeId: node.id,
error: err.message,
});
}
promises.delete(node.id);
throw err;
});
promises.set(node.id, acquirePromise);
}
// Wait for at least one node to complete before checking ready again
if (promises.size > 0) {
await Promise.race(promises.values());
}
}
this.#progress.emit({
type: "scheduler-complete",
nodeCount: sorted.length,
order,
});
return { order, conflicts, tasks: this.getAllTasks() };
}
async #executeNode(node, token, workerId) {
const startedAt = Date.now();
this.#progress.emit({
type: "node-start",
nodeId: node.id,
workerId,
});
const handler = this.handlers.get(node.kind);
let gateResults = [];
let error = null;
// Per-node timeout: use unit timeout from preferences or fallback
const unitTimeoutMs = getUnitTimeout() * 1000 || DEFAULT_UNIT_TIMEOUT_MS;
const nodeToken = new CancellationToken();
const nodeTimeoutHandle = setTimeout(() => {
nodeToken.cancel(`unit-timeout-${unitTimeoutMs}ms`);
}, unitTimeoutMs);
// Link node timeout to parent token
token.onCancel((reason) => {
if (!nodeToken.isCancelled) nodeToken.cancel(reason);
});
try {
// Check both tokens
token.throwIfCancelled();
nodeToken.throwIfCancelled();
if (handler) {
const result = await handler(node, nodeToken, this.#progress);
if (result && Array.isArray(result.gateResults)) {
gateResults = result.gateResults;
}
}
} catch (err) {
error = err;
if (err.name === "CancellationError") {
gateResults = [{
gateId: "scheduler",
outcome: "fail",
failureClass: "cancelled",
rationale: err.message,
}];
} else {
gateResults = [{
gateId: "scheduler",
outcome: "fail",
failureClass: "execution",
rationale: err.message,
}];
}
} finally {
clearTimeout(nodeTimeoutHandle);
}
const durationMs = Date.now() - startedAt;
const state = gateOutcomesToTaskState(gateResults);
const task = buildTaskRecord({
nodeId: node.id,
unitType: node.metadata?.unitType ?? node.kind,
unitId: node.metadata?.unitId ?? node.id,
milestoneId: node.metadata?.milestoneId,
sliceId: node.metadata?.sliceId,
taskId: node.metadata?.taskId,
title: node.metadata?.title,
gateResults,
startedAt: new Date(startedAt).toISOString(),
endedAt: new Date().toISOString(),
durationMs,
workerId,
});
this.#tasks.set(node.id, task);
this.#persistTask(node, task, gateResults, error);
if (error && error.name !== "CancellationError") {
this.#persistGraphStatus("failed", this.#tasks.size);
throw error;
}
return { nodeId: node.id, state, durationMs, gateResults };
}
#topologicalSort(nodes) {
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const inDegree = new Map(nodes.map((n) => [n.id, 0]));
for (const node of nodes) {
for (const dep of node.dependsOn) {
if (nodeMap.has(dep)) {
inDegree.set(node.id, (inDegree.get(node.id) ?? 0) + 1);
}
}
}
const queue = nodes
.filter((n) => (inDegree.get(n.id) ?? 0) === 0)
.sort((a, b) => a.id.localeCompare(b.id));
const ordered = [];
while (queue.length > 0) {
const current = queue.shift();
ordered.push(current);
for (const next of nodes) {
if (!next.dependsOn.includes(current.id)) continue;
const deg = (inDegree.get(next.id) ?? 0) - 1;
inDegree.set(next.id, deg);
if (deg === 0) {
queue.push(next);
queue.sort((a, b) => a.id.localeCompare(b.id));
}
}
}
if (ordered.length !== nodes.length) {
throw new Error("Execution graph has cyclic dependencies");
}
return ordered;
}
#detectFileConflicts(nodes) {
const conflicts = [];
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
const writesA = new Set(a.writes ?? []);
if (writesA.size === 0) continue;
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
for (const file of b.writes ?? []) {
if (writesA.has(file)) {
conflicts.push({ nodeA: a.id, nodeB: b.id, file });
}
}
}
}
return conflicts;
}
#persistInitialGraph(nodes, graphMeta) {
try {
persistFullGraph(this.#db, this.#graphId, nodes, {
...graphMeta,
status: "active",
});
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist initial graph: ${err?.message ?? String(err)}`,
);
}
}
#persistProgress(event) {
if (!this.#db || !this.#graphId) return;
try {
persistProgressEvent(this.#db, this.#graphId, event);
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist progress event: ${err?.message ?? String(err)}`,
);
}
}
#persistTask(node, task, gateResults, error) {
if (!this.#db || !this.#graphId) return;
try {
persistGraphNode(this.#db, this.#graphId, {
id: task.id,
kind: node.kind,
unitType: task.unitType,
unitId: task.unitId,
milestoneId: task.milestoneId,
sliceId: task.sliceId,
taskId: task.taskId,
title: task.title,
dependsOn: node.dependsOn ?? [],
writes: node.writes ?? [],
state: task.state,
workerId: task.workerId,
startedAt: task.startedAt,
endedAt: task.endedAt,
durationMs: task.durationMs,
costUsd: task.costUsd,
modelId: task.modelId,
gateResults,
error: error ? (error.message ?? String(error)) : null,
});
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist task ${task.id}: ${err?.message ?? String(err)}`,
);
}
}
#persistGraphStatus(status, nodeCount) {
if (!this.#db || !this.#graphId) return;
try {
persistGraphSnapshot(this.#db, {
id: this.#graphId,
nodeCount,
createdAt: new Date().toISOString(),
status,
});
} catch (err) {
debugLog(
"scheduler-v2",
`failed to persist graph status: ${err?.message ?? String(err)}`,
);
}
}
}

View file

@ -0,0 +1,189 @@
/**
* UOK Task State Machine
*
* Purpose: map ORCH-style task lifecycle states onto UOK gate outcomes so
* the /tasks surface can query and display background work without inventing
* a parallel state system. Task state is a VIEW over gate runs and unit
* runtime records, not a separate authority.
*
* Consumer: /tasks command, execution-graph scheduler, and UOK kernel
* background-work tracking.
*/
import { isDbAvailable } from "../sf-db.js";
import { isTerminalUnitRuntimeStatus, UNIT_RUNTIME_STATUSES } from "./unit-runtime.js";
export const TASK_STATES = [
"todo",
"in_progress",
"review",
"done",
"retrying",
"failed",
"cancelled",
];
export const TASK_TERMINAL_STATES = new Set(["done", "failed", "cancelled"]);
export const TASK_STATE_TRANSITIONS = {
todo: ["in_progress", "cancelled"],
in_progress: ["review", "done", "retrying", "failed", "cancelled"],
review: ["in_progress", "done", "failed", "cancelled"],
retrying: ["in_progress", "failed", "cancelled"],
done: [],
failed: ["retrying", "cancelled"],
cancelled: [],
};
/**
* Derive task state from a collection of gate results.
*
* Purpose: reconcile multiple gate outcomes (security, policy, verification,
* etc.) into a single task-level state for /tasks display.
*
* Consumer: ExecutionGraphScheduler after a node completes all its gates.
*/
export function gateOutcomesToTaskState(gateResults) {
if (!gateResults || gateResults.length === 0) return "todo";
const outcomes = gateResults.map((r) => r.outcome);
if (outcomes.some((o) => o === "manual-attention")) return "review";
if (outcomes.some((o) => o === "retry")) return "retrying";
if (outcomes.every((o) => o === "pass")) return "done";
if (outcomes.some((o) => o === "fail")) return "failed";
return "in_progress";
}
/**
* Derive task state from a unit runtime record.
*
* Purpose: bridge the legacy unit-runtime projection (queued/claimed/running/
* completed/failed/blocked/cancelled/stale) to the ORCH-style task state
* vocabulary so /tasks shows a unified view.
*
* Consumer: /tasks query when no active gate run exists for a unit.
*/
export function unitRuntimeToTaskState(record) {
if (!record) return "todo";
const status = record.status ?? record.phase ?? "queued";
switch (status) {
case "queued":
case "claimed":
return "todo";
case "running":
case "progress":
return "in_progress";
case "completed":
return "done";
case "failed":
return "failed";
case "blocked":
return "review";
case "cancelled":
return "cancelled";
case "stale":
case "runaway-recovered":
return "retrying";
case "notified":
return isTerminalUnitRuntimeStatus(status) ? "done" : "in_progress";
default:
return "in_progress";
}
}
/**
* Check if a task state transition is valid.
*
* Purpose: prevent illegal state jumps in /tasks UI and background scheduler.
*/
export function canTransitionTaskState(from, to) {
const allowed = TASK_STATE_TRANSITIONS[from];
if (!allowed) return false;
return allowed.includes(to);
}
/**
* Compute aggregate task state for a milestone or slice.
*
* Purpose: give /tasks a single-line summary: "3 done, 1 in_progress, 2 todo".
*
* Consumer: /tasks header line and footer summary chips.
*/
export function aggregateTaskStates(taskStates) {
const counts = Object.fromEntries(TASK_STATES.map((s) => [s, 0]));
for (const s of taskStates) {
if (s in counts) counts[s]++;
}
const total = taskStates.length;
const terminal = TASK_STATES.filter((s) => TASK_TERMINAL_STATES.has(s)).reduce(
(sum, s) => sum + counts[s],
0,
);
return {
counts,
total,
terminal,
progress:
total > 0 ? Math.round(((terminal / total) * 100)) : 0,
isComplete: terminal === total && total > 0,
};
}
/**
* Build a Task record from execution graph node + gate results.
*
* Purpose: standardize what /tasks stores and queries so the surface does
* not need to understand UOK internals.
*
* Consumer: ExecutionGraphScheduler after node completion, /tasks command.
*/
export function buildTaskRecord({
nodeId,
unitType,
unitId,
milestoneId,
sliceId,
taskId,
title,
gateResults = [],
runtimeRecord = null,
startedAt = null,
endedAt = null,
durationMs = null,
costUsd = null,
modelId = null,
workerId = null,
}) {
const state = gateResults.length > 0
? gateOutcomesToTaskState(gateResults)
: unitRuntimeToTaskState(runtimeRecord);
return {
id: nodeId,
unitType,
unitId,
milestoneId,
sliceId,
taskId,
title: title ?? unitId,
state,
gateResults: gateResults.map((r) => ({
gateId: r.gateId,
outcome: r.outcome,
failureClass: r.failureClass,
attempt: r.attempt,
durationMs: r.durationMs,
})),
startedAt,
endedAt,
durationMs,
costUsd,
modelId,
workerId,
updatedAt: new Date().toISOString(),
};
}

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"pi": {
"extensions": [

View file

@ -13,9 +13,9 @@ function readFile(relativePath: string): string {
// ── Dockerfile.sandbox ──
test("docker/Dockerfile.sandbox exists and uses Node 24 base", () => {
test("docker/Dockerfile.sandbox exists and uses Node 26 base", () => {
const content = readFile("docker/Dockerfile.sandbox");
assert.match(content, /FROM node:24/);
assert.match(content, /FROM node:26/);
});
test("docker/Dockerfile.sandbox installs singularity-forge globally", () => {

View file

@ -142,7 +142,7 @@ test("bridge-service workspace-index subprocess uses compiled JS when under node
bridgeSource,
/resolveSubprocessModule/,
"bridge-service.ts must use resolveSubprocessModule to resolve workspace-index path — " +
"hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v24 (see #2279)",
"hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v26 (see #2279)",
);
});
@ -166,7 +166,7 @@ test("all web service files use resolveSubprocessModule instead of hardcoded .ts
source,
/resolveSubprocessModule/,
`${file} uses resolveTypeStrippingFlag but does not use resolveSubprocessModule — ` +
"subprocess .ts paths will fail under node_modules/ on Node v24 (#2279)",
"subprocess .ts paths will fail under node_modules/ on Node v26 (#2279)",
);
}
}

View file

@ -127,9 +127,9 @@ describe("batch directory discovery", () => {
// ─── Node.js compile cache ──────────────────────────────────────────────────
describe("Node.js compile cache env setup", () => {
it("NODE_COMPILE_CACHE is settable on Node 24+", () => {
it("NODE_COMPILE_CACHE is settable on Node 26+", () => {
const nodeVersion = parseInt(process.versions.node, 10);
if (nodeVersion >= 24) {
if (nodeVersion >= 26) {
// Verify the env var mechanism works (does not throw)
const original = process.env.NODE_COMPILE_CACHE;
try {

View file

@ -4,14 +4,14 @@ import { join } from "node:path";
/**
* Returns the correct Node.js type-stripping flag for subprocess spawning.
*
* Node v24 enforces ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for files
* Node v26 enforces ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for files
* resolved under `node_modules/`. When SF is installed globally via npm,
* all source files live under `node_modules/sf-run/src/...`, so
* `--experimental-strip-types` fails deterministically.
*
* `--experimental-transform-types` applies a full TypeScript transform that
* works regardless of whether the file is under `node_modules/`. SF requires
* Node 24+, so this flag is always available.
* Node 26+, so this flag is always available.
*/
export function resolveTypeStrippingFlag(packageRoot: string): string {
return isUnderNodeModules(packageRoot)
@ -39,7 +39,7 @@ export interface SubprocessModuleResolution {
* Resolves a subprocess module path, preferring compiled `dist/*.js` when the
* package root is under `node_modules/`.
*
* Node v24 unconditionally refuses `.ts` files under `node_modules/` even
* Node v26 unconditionally refuses `.ts` files under `node_modules/` even
* with `--experimental-transform-types`. When SF is installed globally via
* npm, every subprocess that loads a `.ts` extension module crashes with
* `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.

View file

@ -1,4 +1,4 @@
FROM node:24-bookworm
FROM node:26-bookworm
WORKDIR /test

View file

@ -166,6 +166,7 @@ export default defineConfig({
"src/tests/**/*.test.mjs",
"src/resources/extensions/sf/tests/**/*.test.ts",
"src/resources/extensions/sf/tests/**/*.test.mjs",
"src/resources/extensions/sf/learning/*.test.mjs",
"src/resources/extensions/sf-permissions/tests/**/*.test.ts",
"src/resources/extensions/shared/tests/**/*.test.ts",
"src/resources/extensions/claude-code-cli/tests/**/*.test.ts",

View file

@ -33,7 +33,7 @@
"workspace"
],
"engines": {
"node": ">=24.15.0",
"node": ">=26.1.0",
"vscode": "^1.95.0"
},
"categories": [

18
web/package-lock.json generated
View file

@ -72,7 +72,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.2.0",
"@types/node": "^24",
"@types/node": "^25.6.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"esbuild": "^0.27.4",
@ -84,7 +84,7 @@
"typescript": "5.7.3"
},
"engines": {
"node": ">=24.0.0"
"node": ">=26.1.0"
}
},
"node_modules/@alloc/quick-lru": {
@ -4652,13 +4652,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"version": "25.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@types/react": {
@ -11705,9 +11705,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},

View file

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": ">=24.15.0"
"node": ">=26.1.0"
},
"scripts": {
"dev": "next dev",
@ -78,7 +78,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.2.0",
"@types/node": "^24",
"@types/node": "^25.6.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"esbuild": "^0.27.4",