feat(sf): align uok task state and steering
This commit is contained in:
parent
378ab702e1
commit
10694440e3
31 changed files with 2583 additions and 254 deletions
|
|
@ -54,6 +54,7 @@ review | manual | restricted | deep → user reviews with reasoning mo
|
|||
- `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.
|
||||
- `sandboxProfile` may become a sixth axis later. It is separate from `permissionProfile`: sandboxing controls process/filesystem/network containment, while permission profile controls what SF may approve.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -90,9 +91,10 @@ Inspect diffs, tests, risks, regressions, security issues, missing evidence. Req
|
|||
|
||||
### 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.
|
||||
Fix SF health, repo health, runtime drift, broken generated state, bad command surfaces, failing workflow infrastructure, stale locks, broken installed runtime copies, failed gates, generated/runtime drift, and broken state.
|
||||
|
||||
**Doctor is the diagnostic engine, not the mode.** `/doctor` inspects. `/repair` switches work mode.
|
||||
`repair` is a `workMode`, not a separate subsystem.
|
||||
|
||||
Commands:
|
||||
```text
|
||||
|
|
@ -191,10 +193,10 @@ Permission profile is enforced at three layers:
|
|||
### 5.2 Commands
|
||||
|
||||
```text
|
||||
/trust restricted
|
||||
/trust normal
|
||||
/trust trusted
|
||||
/trust unrestricted
|
||||
/permission-profile restricted
|
||||
/permission-profile normal
|
||||
/permission-profile trusted
|
||||
/permission-profile unrestricted
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -225,10 +227,10 @@ Permission profile is enforced at three layers:
|
|||
/control manual
|
||||
/control assisted
|
||||
/control autonomous
|
||||
/trust restricted
|
||||
/trust normal
|
||||
/trust trusted
|
||||
/trust unrestricted
|
||||
/permission-profile restricted
|
||||
/permission-profile normal
|
||||
/permission-profile trusted
|
||||
/permission-profile unrestricted
|
||||
/model-mode fast
|
||||
/model-mode smart
|
||||
/model-mode deep
|
||||
|
|
@ -237,9 +239,9 @@ Permission profile is enforced at three layers:
|
|||
### 7.2 Combined Forms
|
||||
|
||||
```text
|
||||
/mode repair --autonomous --trust normal
|
||||
/mode build --autonomous --trust trusted
|
||||
/mode research --autonomous --trust restricted --model-mode deep
|
||||
/mode repair --autonomous --permission-profile normal
|
||||
/mode build --autonomous --permission-profile trusted
|
||||
/mode research --autonomous --permission-profile restricted --model-mode deep
|
||||
```
|
||||
|
||||
### 7.3 Autonomous Steering
|
||||
|
|
@ -247,7 +249,7 @@ Permission profile is enforced at three layers:
|
|||
```text
|
||||
/steer mode repair
|
||||
/steer mode review after-current-unit
|
||||
/steer trust restricted now
|
||||
/steer permission-profile restricted now
|
||||
/steer model-mode deep for-next-unit
|
||||
```
|
||||
|
||||
|
|
@ -332,9 +334,9 @@ Unified view of all background work. Replaces scattered `/status`, `/queue`, `/p
|
|||
|
||||
### 9.1 What `/tasks` Shows
|
||||
|
||||
- autonomous units (current + queued)
|
||||
- autonomous task lifecycle rows
|
||||
- parallel workers
|
||||
- scheduled autonomous dispatches
|
||||
- scheduled autonomous dispatches and queued scheduler rows
|
||||
- background shell sessions
|
||||
- stuck or resumable sessions
|
||||
- remote questions waiting for answers
|
||||
|
|
@ -353,7 +355,7 @@ CREATE TABLE tasks (
|
|||
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
|
||||
status TEXT NOT NULL, -- todo | running | verifying | reviewing | done | blocked | paused | failed | cancelled | retrying
|
||||
dependency_blockers TEXT, -- JSON array of task IDs
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
max_retries INTEGER DEFAULT 3,
|
||||
|
|
@ -367,6 +369,17 @@ CREATE TABLE tasks (
|
|||
intent_claim TEXT -- for parallel workers: "I will edit src/foo.ts lines 10-50"
|
||||
);
|
||||
|
||||
-- Scheduler state is separate from task lifecycle state
|
||||
CREATE TABLE task_scheduler (
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(id),
|
||||
status TEXT NOT NULL, -- queued | due | claimed | dispatched | consumed | expired
|
||||
due_at TEXT,
|
||||
claimed_by TEXT,
|
||||
dispatched_at TEXT,
|
||||
consumed_at TEXT,
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
-- Ephemeral running state
|
||||
CREATE TABLE task_runtime (
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(id),
|
||||
|
|
@ -390,6 +403,9 @@ CREATE TABLE task_transitions (
|
|||
);
|
||||
```
|
||||
|
||||
Parallel workers must stay worktree-isolated and report heartbeat/status into
|
||||
`.sf` state. Scheduler rows may use `queued`; task lifecycle rows use `todo`.
|
||||
|
||||
### 9.3 Complementary Commands
|
||||
|
||||
`/tasks` does not replace:
|
||||
|
|
@ -451,8 +467,8 @@ Dangerous skills (`production-mutation`) are never model-invoked by default.
|
|||
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
|
||||
4. Record repeated source evidence in `.sf` state
|
||||
5. Keep narrow, linted, and evaled like code
|
||||
6. Commit with repo when accepted
|
||||
|
||||
### 10.5 Skill Eval Cases
|
||||
|
|
@ -493,6 +509,10 @@ SF registers direct command roots only:
|
|||
`/sf` is not a command root. TUI and browser command parity tests reject it so
|
||||
compatibility shims do not grow back.
|
||||
|
||||
`/remote` is a full-session steering surface. Remote answers may change
|
||||
`workMode`, `runControl`, `permissionProfile`, and `modelMode`; they are not
|
||||
limited to question delivery.
|
||||
|
||||
### 11.2 Shell Surface
|
||||
|
||||
Machine surface remains prefixed:
|
||||
|
|
@ -575,22 +595,20 @@ sf --print "ping"
|
|||
|
||||
| Priority | Item | Effort |
|
||||
|----------|------|--------|
|
||||
| P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium |
|
||||
| P2 | Audit subagent provider/model/permission inheritance | Medium |
|
||||
| P2 | Audit remote steering as full-session surface | Medium |
|
||||
| P2 | Decide whether `sandboxProfile` becomes a sixth persisted axis | Medium |
|
||||
|
||||
### 13.3 Completed
|
||||
|
||||
| Priority | Item | Status |
|
||||
|----------|------|--------|
|
||||
| P0 | Make mode state durable in SQLite | ✓ |
|
||||
| P0 | Add direct `/mode`, `/control`, `/trust`, `/model-mode` commands | ✓ |
|
||||
| P0 | Add direct `/mode`, `/control`, `/permission-profile`, `/model-mode` commands | ✓ |
|
||||
| P0 | Add visible mode badge to TUI header/status bar | ✓ |
|
||||
| P1 | Make `--autonomous` chain into direct `/autonomous` | ✓ |
|
||||
| P1 | Expose autonomous continuation limits in settings and status | ✓ |
|
||||
| P1 | Add `/tasks` backed by DB execution graph state | ✓ |
|
||||
| P1 | Make `repair` first-class workflow over `doctor` | ✓ |
|
||||
| P1 | Enhanced `/steer` with mode/trust/model-mode transitions | ✓ |
|
||||
| P1 | Enhanced `/steer` with mode/permission-profile/model-mode transitions | ✓ |
|
||||
| P1 | TUI keyboard shortcuts for mode cycling (Ctrl+Shift+M/R/A/S/P) | ✓ |
|
||||
| P1 | Minimal auto-mode header/footer (badge visible during autonomy) | ✓ |
|
||||
| P1 | Remove `/sf` namespace registration and parity-test against fallback | ✓ |
|
||||
|
|
@ -598,6 +616,9 @@ sf --print "ping"
|
|||
| P1 | Skill eval harness foundation | ✓ |
|
||||
| P1 | Terminal title mode indicator | ✓ |
|
||||
| P2 | Policy-aware project skill suggestion/generation with DB cooldown | ✓ |
|
||||
| P2 | Schema-backed task frontmatter (risk, mutation, verification, approval) | ✓ |
|
||||
| P2 | Subagent provider/model/permission inheritance audit and guard | ✓ |
|
||||
| P2 | Remote steering as full-session surface from remote answers | ✓ |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
- Guidance: `.sf/ANTI-GOALS.md` (present)
|
||||
- Optional knowledge: `.sf/KNOWLEDGE.md` (missing)
|
||||
- Optional preferences: `.sf/PREFERENCES.md` (present)
|
||||
- Database schema version: 42
|
||||
- Source schema version: 45
|
||||
- DB planning rows: milestones=1, slices=0, tasks=0
|
||||
- DB spec rows: milestone_specs=1, slice_specs=0, task_specs=0
|
||||
- Source roots analyzed as implementation evidence: `src/resources/extensions/sf/`, `src/headless*.ts`, `src/cli.ts`, `src/help-text.ts`, `web/`, `vscode-extension/`, `packages/`
|
||||
|
|
@ -92,6 +92,36 @@ Run control and permission profile are independent. For example, `autonomous + r
|
|||
|
||||
UOK kernel records and execution-policy decisions carry `permissionProfile` as the trust posture. Permission expansion never implies autonomous continuation.
|
||||
|
||||
## Work Mode
|
||||
|
||||
A work mode describes the kind of work SF is doing. `repair` is one work mode, not a separate subsystem. It owns self-healing, stale locks, installed-runtime drift, broken state, failed gates, generated/runtime drift, and other cases where SF must repair its own ability to continue safely.
|
||||
|
||||
`doctor` remains a diagnostic engine. It can inspect and report problems, but switching into repair work is a `workMode` transition.
|
||||
|
||||
## Task And Scheduler Status
|
||||
|
||||
Durable task lifecycle state uses the ORCH-style status machine:
|
||||
|
||||
```text
|
||||
todo -> running -> verifying -> reviewing -> done | blocked | paused | failed | cancelled | retrying
|
||||
```
|
||||
|
||||
Use `todo`, not `queued`, for work that exists but has not started. `queued` belongs to scheduler state only:
|
||||
|
||||
```text
|
||||
queued -> due -> claimed -> dispatched -> consumed | expired
|
||||
```
|
||||
|
||||
Parallel workers must stay worktree-isolated and report heartbeat/status into `.sf` state. Their lifecycle rows use `task_status`; timed dispatch and reminder rows use `task_scheduler.status`.
|
||||
|
||||
## Remote Steering
|
||||
|
||||
Remote is a full-session steering surface. It may change `workMode`, `runControl`, `permissionProfile`, and `modelMode`; it is not only a question delivery channel.
|
||||
|
||||
## Future Sandbox Profile
|
||||
|
||||
`sandboxProfile` may become a sixth independent axis later. Keep it separate from `permissionProfile`: sandbox profile controls containment, while permission profile controls what SF may approve.
|
||||
|
||||
## Naming Rules
|
||||
|
||||
- Say **flow** for the shared planning/execution engine.
|
||||
|
|
@ -100,6 +130,8 @@ UOK kernel records and execution-policy decisions carry `permissionProfile` as t
|
|||
- Say **output format** for `text`, `json`, and `stream-json`.
|
||||
- Say **run control** for `manual`, `assisted`, and `autonomous`.
|
||||
- Say **permission profile** for `restricted`, `normal`, `trusted`, and `unrestricted`.
|
||||
- Say **task status** for `todo`, `running`, `verifying`, `reviewing`, `done`, `blocked`, `paused`, `failed`, `cancelled`, and `retrying`.
|
||||
- Say **scheduler status** for `queued`, `due`, `claimed`, `dispatched`, `consumed`, and `expired`.
|
||||
- Use **headless** only for the current `sf headless` command and implementation path. Product docs should explain it as the machine surface.
|
||||
|
||||
## Working State Contract
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
* SF Parallel Worker Monitor
|
||||
*
|
||||
* Real-time TUI dashboard for monitoring parallel SF auto-mode workers.
|
||||
* Zero dependencies — uses raw ANSI escape codes, Node.js builtins only.
|
||||
* Zero external dependencies — uses raw ANSI escape codes, Node.js builtins,
|
||||
* and the shared SF monitor projection store.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/parallel-monitor.mjs # live dashboard, 5s refresh
|
||||
|
|
@ -44,7 +45,10 @@
|
|||
import { execSync, spawn, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
queryParallelRecentCompletionRows,
|
||||
queryParallelSliceProgress,
|
||||
} from "../src/resources/extensions/sf/parallel-monitor-store.js";
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -176,46 +180,6 @@ 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 {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
function readRecentEvents(mid, maxLines = 5) {
|
||||
const stdoutPath = path.resolve(
|
||||
PROJECT_ROOT,
|
||||
|
|
@ -642,34 +606,11 @@ function truncate(str, maxLen) {
|
|||
* Get recently completed tasks/slices from the worktree DB for the event feed.
|
||||
*/
|
||||
function queryRecentCompletions(mid) {
|
||||
const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`);
|
||||
if (!fs.existsSync(dbPath)) return [];
|
||||
|
||||
try {
|
||||
// Completed tasks with timestamps, most recent first
|
||||
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: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(),
|
||||
msg: `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
|
||||
mid,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return queryParallelRecentCompletionRows(PROJECT_ROOT, mid).map((row) => ({
|
||||
ts: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(),
|
||||
msg: `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
|
||||
mid,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Rendering ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -687,7 +628,7 @@ function collectWorkerData() {
|
|||
for (const mid of mids) {
|
||||
const status = readWorkerStatus(mid);
|
||||
const lock = readAutoLock(mid);
|
||||
const slices = querySliceProgress(mid);
|
||||
const slices = queryParallelSliceProgress(PROJECT_ROOT, mid);
|
||||
const { notifications, errors } = readRecentEvents(mid, 3);
|
||||
|
||||
// Prefer auto.lock PID (written by the running worker) over status.json PID
|
||||
|
|
|
|||
|
|
@ -1523,6 +1523,60 @@ export function registerDbTools(pi) {
|
|||
observabilityImpact: Type.Optional(
|
||||
Type.String({ description: "Task observability impact" }),
|
||||
),
|
||||
risk: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Task risk level: none, low, medium, high, or critical",
|
||||
}),
|
||||
),
|
||||
mutationScope: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Declared mutation scope: none, docs-only, config, test-only, isolated, bounded, cross-cutting, or systemic",
|
||||
}),
|
||||
),
|
||||
verification: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Verification requirement type: none, self-check, review, test, integration, or manual-qa",
|
||||
}),
|
||||
),
|
||||
planApproval: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Approval state: not-required, pending, approved, rejected, or auto-approved",
|
||||
}),
|
||||
),
|
||||
estimatedEffort: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Estimated effort in minutes when known",
|
||||
}),
|
||||
),
|
||||
dependencies: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Task IDs this task depends on",
|
||||
}),
|
||||
),
|
||||
blocksParallel: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "True when no other task should run concurrently",
|
||||
}),
|
||||
),
|
||||
requiresUserInput: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "True when execution is expected to need user input",
|
||||
}),
|
||||
),
|
||||
autoRetry: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Whether transient failures may retry automatically",
|
||||
}),
|
||||
),
|
||||
maxRetries: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Maximum automatic retry attempts, 0-10",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{ description: "Planned tasks for the slice" },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ import {
|
|||
resolveSlicePath,
|
||||
} from "../paths.js";
|
||||
import { cleanupQuickBranch } from "../quick.js";
|
||||
import {
|
||||
applyRemoteSteeringDirectives,
|
||||
formatRemoteSteeringResults,
|
||||
parseRemoteSteeringDirectives,
|
||||
} from "../remote-steering.js";
|
||||
import { classifyCommand } from "../safety/destructive-guard.js";
|
||||
import {
|
||||
recordToolCall as safetyRecordToolCall,
|
||||
|
|
@ -598,7 +603,7 @@ export function registerHooks(pi, ecosystemHandlers = []) {
|
|||
// ── Execution policy enforcement: block based on permission profile ──
|
||||
// When autonomous mode is active, enforce the session's permission profile
|
||||
// at the tool boundary. This is the enforcement layer that makes
|
||||
// /trust restricted|normal|trusted|unrestricted meaningful.
|
||||
// /permission-profile restricted|normal|trusted|unrestricted meaningful.
|
||||
if (isAutoActive()) {
|
||||
const { getAutoSession } = await import("../auto/session.js");
|
||||
const session = getAutoSession();
|
||||
|
|
@ -768,6 +773,27 @@ export function registerHooks(pi, ecosystemHandlers = []) {
|
|||
const questions = event.input?.questions ?? [];
|
||||
const currentPendingGate = getPendingGate();
|
||||
if (details?.cancelled || !details?.response) return;
|
||||
if (details.remote === true) {
|
||||
const steering = parseRemoteSteeringDirectives(details.response);
|
||||
if (steering.steering) {
|
||||
const results = applyRemoteSteeringDirectives(steering.directives);
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "sf-remote-steering",
|
||||
content: formatRemoteSteeringResults(results),
|
||||
display: false,
|
||||
details: {
|
||||
toolName: event.toolName,
|
||||
toolCallId: event.toolCallId,
|
||||
promptId: details.promptId,
|
||||
channel: details.channel,
|
||||
results,
|
||||
},
|
||||
},
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const question of questions) {
|
||||
if (typeof question.id !== "string") continue;
|
||||
// Check if this is a depth_verification question (either directly or via pending gate)
|
||||
|
|
|
|||
|
|
@ -439,16 +439,16 @@ export async function handleSteer(change, ctx, pi) {
|
|||
return;
|
||||
}
|
||||
|
||||
// ── Trust steering: /steer trust <profile> [scope] ─────────────────────
|
||||
const trustSteerRe = /^trust\s+(\S+)(?:\s+(\S+))?/;
|
||||
const trustMatch = trimmed.match(trustSteerRe);
|
||||
if (trustMatch) {
|
||||
const permissionProfile = trustMatch[1];
|
||||
const scope = trustMatch[2] ?? "now";
|
||||
// ── Permission-profile steering: /steer permission-profile <profile> [scope]
|
||||
const permissionProfileSteerRe = /^permission-profile\s+(\S+)(?:\s+(\S+))?/;
|
||||
const permissionProfileMatch = trimmed.match(permissionProfileSteerRe);
|
||||
if (permissionProfileMatch) {
|
||||
const permissionProfile = permissionProfileMatch[1];
|
||||
const scope = permissionProfileMatch[2] ?? "now";
|
||||
const s = getAutoSession();
|
||||
const transition = s.setMode({ permissionProfile, reason: "steer", scope });
|
||||
ctx.ui.notify(
|
||||
`Steer trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile} (${scope})`,
|
||||
`Steer permission profile: ${transition.from.permissionProfile} → ${transition.to.permissionProfile} (${scope})`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
|
|||
* Comprehensive description of all available SF commands for help text.
|
||||
*/
|
||||
export const SF_COMMAND_DESCRIPTION =
|
||||
"SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|control|trust|model-mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan";
|
||||
"SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|control|permission-profile|model-mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan";
|
||||
|
||||
export const BASE_RUNTIME_COMMANDS = new Set([
|
||||
"settings",
|
||||
|
|
@ -108,7 +108,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [
|
|||
},
|
||||
{ cmd: "control", desc: "Switch run control (manual/assisted/autonomous)" },
|
||||
{
|
||||
cmd: "trust",
|
||||
cmd: "permission-profile",
|
||||
desc: "Switch permission profile (restricted/normal/trusted/unrestricted)",
|
||||
},
|
||||
{ cmd: "model-mode", desc: "Switch model mode (fast/smart/deep)" },
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function showHelp(ctx, args = "") {
|
|||
"COURSE CORRECTION",
|
||||
" /steer <desc> Apply user override to active work",
|
||||
" /steer mode <m> [scope] Change work mode (now|after-current-unit|next-milestone)",
|
||||
" /steer trust <p> [scope] Change permission profile",
|
||||
" /steer permission-profile <p> [scope] Change permission profile",
|
||||
" /steer model-mode <m> Change model mode for next unit",
|
||||
" /capture <text> Quick-capture a thought to CAPTURES.md",
|
||||
" /triage Classify and route pending captures",
|
||||
|
|
@ -110,7 +110,7 @@ export function showHelp(ctx, args = "") {
|
|||
" /model Switch active session model [provider/model|model-id]",
|
||||
" /mode Switch work mode (chat/plan/build/review/repair/research)",
|
||||
" /control Switch run control (manual/assisted/autonomous)",
|
||||
" /trust Switch permission profile (restricted/normal/trusted/unrestricted)",
|
||||
" /permission-profile Switch permission profile (restricted/normal/trusted/unrestricted)",
|
||||
" /model-mode Switch model mode (fast/smart/deep)",
|
||||
" /prefs Manage preferences [global|project|status|wizard|setup|import-claude]",
|
||||
" /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]",
|
||||
|
|
@ -433,17 +433,17 @@ function handleControlCommand(args, ctx) {
|
|||
);
|
||||
return true;
|
||||
}
|
||||
function handleTrustCommand(args, ctx) {
|
||||
function handlePermissionProfileCommand(args, ctx) {
|
||||
const s = getAutoSession();
|
||||
const permissionProfile = args.trim();
|
||||
if (!permissionProfile) {
|
||||
const mode = s.getMode();
|
||||
ctx.ui.notify(`Trust: ${mode.permissionProfile}`, "info");
|
||||
ctx.ui.notify(`Permission profile: ${mode.permissionProfile}`, "info");
|
||||
return true;
|
||||
}
|
||||
const transition = s.setMode({ permissionProfile });
|
||||
ctx.ui.notify(
|
||||
`Trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`,
|
||||
`Permission profile: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`,
|
||||
"info",
|
||||
);
|
||||
return true;
|
||||
|
|
@ -520,8 +520,14 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
|
|||
handleControlCommand(trimmed.replace(/^control\s*/, "").trim(), ctx);
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "trust" || trimmed.startsWith("trust ")) {
|
||||
handleTrustCommand(trimmed.replace(/^trust\s*/, "").trim(), ctx);
|
||||
if (
|
||||
trimmed === "permission-profile" ||
|
||||
trimmed.startsWith("permission-profile ")
|
||||
) {
|
||||
handlePermissionProfileCommand(
|
||||
trimmed.replace(/^permission-profile\s*/, "").trim(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "model-mode" || trimmed.startsWith("model-mode ")) {
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
"templates",
|
||||
"todo",
|
||||
"triage",
|
||||
"trust",
|
||||
"permission-profile",
|
||||
"undo",
|
||||
"undo-task",
|
||||
"unpark",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@ async function collectTouchedFiles(_basePath, milestoneId) {
|
|||
// When DB unavailable, return empty file set — parallel eligibility cannot be determined
|
||||
return [...files];
|
||||
}
|
||||
function collectParallelMetadataBlockers(milestoneId) {
|
||||
const blockers = [];
|
||||
if (!isDbAvailable()) return blockers;
|
||||
const slices = getMilestoneSlices(milestoneId);
|
||||
for (const slice of slices) {
|
||||
const tasks = getSliceTasks(milestoneId, slice.id);
|
||||
for (const task of tasks) {
|
||||
const frontmatter = task.frontmatter;
|
||||
if (!frontmatter) continue;
|
||||
if (frontmatter.blocksParallel) {
|
||||
blockers.push(`${slice.id}/${task.id} blocks parallel execution`);
|
||||
}
|
||||
if (frontmatter.mutationScope === "systemic") {
|
||||
blockers.push(`${slice.id}/${task.id} has systemic mutation scope`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
// ─── Overlap Detection ──────────────────────────────────────────────────────
|
||||
/**
|
||||
* Compare file sets across milestones and return pairs with overlapping files.
|
||||
|
|
@ -118,6 +137,16 @@ export async function analyzeParallelEligibility(basePath) {
|
|||
});
|
||||
continue;
|
||||
}
|
||||
const metadataBlockers = collectParallelMetadataBlockers(mid);
|
||||
if (metadataBlockers.length > 0) {
|
||||
ineligible.push({
|
||||
milestoneId: mid,
|
||||
title,
|
||||
eligible: false,
|
||||
reason: `Task metadata blocks parallel execution: ${metadataBlockers.join("; ")}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
eligible.push({
|
||||
milestoneId: mid,
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -17,23 +17,14 @@ 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 {
|
||||
queryParallelRecentCompletionRows,
|
||||
queryParallelSliceProgress,
|
||||
} from "./parallel-monitor-store.js";
|
||||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||
|
||||
// ─── 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();
|
||||
}
|
||||
}
|
||||
// ─── Data Helpers ─────────────────────────────────────────────────────────
|
||||
function readJsonSafe(filePath) {
|
||||
try {
|
||||
|
|
@ -94,32 +85,6 @@ function discoverWorkers(basePath) {
|
|||
}
|
||||
return [...mids].sort();
|
||||
}
|
||||
function querySliceProgress(basePath, mid) {
|
||||
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
||||
if (!existsSync(dbPath)) return [];
|
||||
try {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
function extractCostFromNdjson(basePath, mid) {
|
||||
const stdoutPath = join(basePath, ".sf", "parallel", `${mid}.stdout.log`);
|
||||
if (!existsSync(stdoutPath)) return 0;
|
||||
|
|
@ -143,35 +108,14 @@ function extractCostFromNdjson(basePath, mid) {
|
|||
return 0;
|
||||
}
|
||||
}
|
||||
function queryRecentCompletions(basePath, mid) {
|
||||
const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db");
|
||||
if (!existsSync(dbPath)) return [];
|
||||
try {
|
||||
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 [];
|
||||
}
|
||||
function formatCompletionEvent(mid, row) {
|
||||
return `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`;
|
||||
}
|
||||
async function collectWorkerData(basePath) {
|
||||
const mids = discoverWorkers(basePath);
|
||||
const parallelDir = join(basePath, ".sf", "parallel");
|
||||
const allSlices = await Promise.all(
|
||||
mids.map((mid) => querySliceProgress(basePath, mid)),
|
||||
mids.map((mid) => queryParallelSliceProgress(basePath, mid)),
|
||||
);
|
||||
const workers = [];
|
||||
for (let i = 0; i < mids.length; i++) {
|
||||
|
|
@ -309,7 +253,11 @@ export class ParallelMonitorOverlay {
|
|||
this.workers = workers;
|
||||
// Collect completion events in parallel across workers
|
||||
const allCompletions = await Promise.all(
|
||||
this.workers.map((wk) => queryRecentCompletions(this.basePath, wk.mid)),
|
||||
this.workers.map((wk) =>
|
||||
queryParallelRecentCompletionRows(this.basePath, wk.mid).map((row) =>
|
||||
formatCompletionEvent(wk.mid, row),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (this.disposed) return;
|
||||
for (const completions of allCompletions) {
|
||||
|
|
|
|||
108
src/resources/extensions/sf/parallel-monitor-store.js
Normal file
108
src/resources/extensions/sf/parallel-monitor-store.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* parallel-monitor-store.js — read-only projections for parallel worker monitors.
|
||||
*
|
||||
* Purpose: centralize the Node SQLite reads used by the standalone monitor and
|
||||
* TUI overlay so parallel worker status is rendered from one DB-first contract.
|
||||
*
|
||||
* Consumer: `scripts/parallel-monitor.mjs` and `parallel-monitor-overlay.js`.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical DB path for a parallel worker worktree.
|
||||
*
|
||||
* Purpose: keep monitor-side DB reads pointed at the worker-owned `.sf/sf.db`
|
||||
* projection instead of ad hoc sidecar files.
|
||||
*
|
||||
* Consumer: monitor projection query helpers in this module.
|
||||
*/
|
||||
export function getParallelWorkerDbPath(basePath, milestoneId) {
|
||||
return join(basePath, ".sf", "worktrees", milestoneId, ".sf", "sf.db");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read slice/task progress for one parallel worker.
|
||||
*
|
||||
* Purpose: render worker progress from structured task rows so the script and
|
||||
* overlay do not drift into separate SQL contracts.
|
||||
*
|
||||
* Consumer: parallel monitor script and TUI overlay worker summaries.
|
||||
*/
|
||||
export function queryParallelSliceProgress(basePath, milestoneId) {
|
||||
const dbPath = getParallelWorkerDbPath(basePath, milestoneId);
|
||||
if (!existsSync(dbPath)) return [];
|
||||
|
||||
try {
|
||||
return queryRows(
|
||||
dbPath,
|
||||
`SELECT s.id AS id,
|
||||
s.status AS status,
|
||||
COUNT(t.id) AS total,
|
||||
SUM(CASE WHEN t.task_status='done' 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`,
|
||||
[milestoneId],
|
||||
).map((row) => ({
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
total: Number(row.total ?? 0),
|
||||
done: Number(row.done ?? 0),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read recently completed task rows for one parallel worker.
|
||||
*
|
||||
* Purpose: feed completion events from the same structured state used for
|
||||
* dispatch instead of scraping monitor logs for finished work.
|
||||
*
|
||||
* Consumer: parallel monitor script and TUI overlay event feeds.
|
||||
*/
|
||||
export function queryParallelRecentCompletionRows(
|
||||
basePath,
|
||||
milestoneId,
|
||||
limit = 5,
|
||||
) {
|
||||
const dbPath = getParallelWorkerDbPath(basePath, milestoneId);
|
||||
if (!existsSync(dbPath)) return [];
|
||||
|
||||
try {
|
||||
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 task_status='done'
|
||||
AND completed_at IS NOT NULL
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT ?`,
|
||||
[milestoneId, limit],
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
188
src/resources/extensions/sf/remote-steering.js
Normal file
188
src/resources/extensions/sf/remote-steering.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Remote Steering - full-session surface for remote mode changes
|
||||
*
|
||||
* Purpose: allow remote questions to steer session mode (workMode,
|
||||
* runControl, permissionProfile, modelMode) in addition to answering
|
||||
* questions. This makes remote channels a first-class steering surface,
|
||||
* not just a question delivery mechanism.
|
||||
*
|
||||
* Consumer: remote-questions manager when parsing answers, and
|
||||
* auto-dispatch when checking for remote steering directives.
|
||||
*/
|
||||
|
||||
import { getAutoSession } from "./auto/session.js";
|
||||
import {
|
||||
buildModeState,
|
||||
MODEL_MODES,
|
||||
PERMISSION_PROFILES,
|
||||
RUN_CONTROL_MODES,
|
||||
WORK_MODES,
|
||||
} from "./operating-model.js";
|
||||
|
||||
/**
|
||||
* Parse a remote answer for steering directives.
|
||||
* Looks for patterns like:
|
||||
* /mode build
|
||||
* /control autonomous
|
||||
* /permission-profile trusted
|
||||
* /model-mode deep
|
||||
*
|
||||
* @param {object} answer - the parsed remote answer object
|
||||
* @returns {{ steering: boolean, directives: Array<{cmd: string, value: string}> }}
|
||||
*/
|
||||
export function parseRemoteSteeringDirectives(answer) {
|
||||
if (!answer) {
|
||||
return { steering: false, directives: [] };
|
||||
}
|
||||
|
||||
const text = extractAnswerText(answer);
|
||||
if (!text) {
|
||||
return { steering: false, directives: [] };
|
||||
}
|
||||
|
||||
const directives = [];
|
||||
const patterns = [
|
||||
{
|
||||
regex: /\/(?:mode|work-mode)\s+([\w-]+)/g,
|
||||
cmd: "mode",
|
||||
valid: WORK_MODES,
|
||||
},
|
||||
{
|
||||
regex: /\/(?:control|run-control)\s+([\w-]+)/g,
|
||||
cmd: "control",
|
||||
valid: RUN_CONTROL_MODES,
|
||||
},
|
||||
{
|
||||
regex: /\/permission-profile\s+([\w-]+)/g,
|
||||
cmd: "permission-profile",
|
||||
valid: PERMISSION_PROFILES,
|
||||
},
|
||||
{
|
||||
regex: /\/model-mode\s+([\w-]+)/g,
|
||||
cmd: "model-mode",
|
||||
valid: MODEL_MODES,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { regex, cmd, valid } of patterns) {
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const value = match[1].toLowerCase();
|
||||
if (valid.includes(value)) {
|
||||
directives.push({ cmd, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
steering: directives.length > 0,
|
||||
directives,
|
||||
};
|
||||
}
|
||||
|
||||
function extractAnswerText(value) {
|
||||
const parts = [];
|
||||
const seen = new WeakSet();
|
||||
function visit(node) {
|
||||
if (typeof node === "string") {
|
||||
parts.push(node);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (seen.has(node)) return;
|
||||
seen.add(node);
|
||||
const preferredKeys = ["text", "selected", "notes", "user_note", "answers"];
|
||||
const visitedKeys = new Set();
|
||||
for (const key of preferredKeys) {
|
||||
if (node[key] !== undefined) {
|
||||
visitedKeys.add(key);
|
||||
visit(node[key]);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (!visitedKeys.has(key)) visit(child);
|
||||
}
|
||||
}
|
||||
visit(value);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply steering directives to the current session.
|
||||
*
|
||||
* @param {Array<{cmd: string, value: string}>} directives
|
||||
* @returns {Array<{cmd: string, value: string, applied: boolean, error?: string}>}
|
||||
*/
|
||||
export function applyRemoteSteeringDirectives(directives) {
|
||||
const session = getAutoSession();
|
||||
const results = [];
|
||||
|
||||
for (const { cmd, value } of directives) {
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "mode":
|
||||
session.setMode({ workMode: value, reason: "remote-steering" });
|
||||
break;
|
||||
case "control":
|
||||
session.setMode({ runControl: value, reason: "remote-steering" });
|
||||
break;
|
||||
case "permission-profile":
|
||||
session.setMode({
|
||||
permissionProfile: value,
|
||||
reason: "remote-steering",
|
||||
});
|
||||
break;
|
||||
case "model-mode":
|
||||
session.setMode({ modelMode: value, reason: "remote-steering" });
|
||||
break;
|
||||
default:
|
||||
results.push({
|
||||
cmd,
|
||||
value,
|
||||
applied: false,
|
||||
error: "unknown command",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push({ cmd, value, applied: true });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
cmd,
|
||||
value,
|
||||
applied: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format steering results for remote display.
|
||||
*
|
||||
* @param {Array} results - from applyRemoteSteeringDirectives
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRemoteSteeringResults(results) {
|
||||
const lines = ["SF Mode Steering"];
|
||||
for (const r of results) {
|
||||
const marker = r.applied ? "[ok]" : "[blocked]";
|
||||
lines.push(` ${marker} /${r.cmd} ${r.value}`);
|
||||
if (r.error) lines.push(` Error: ${r.error}`);
|
||||
}
|
||||
let mode = buildModeState();
|
||||
try {
|
||||
mode = getAutoSession().getMode();
|
||||
} catch {
|
||||
// Formatting still has value in tests and detached remote contexts.
|
||||
}
|
||||
lines.push(
|
||||
`\nCurrent: ${mode.workMode} | ${mode.runControl} | ${mode.permissionProfile} | ${mode.modelMode}`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
@ -23,6 +23,12 @@ import { dirname } from "node:path";
|
|||
import { DatabaseSync } from "node:sqlite";
|
||||
import { SF_STALE_STATE, SFError } from "./errors.js";
|
||||
import { getGateIdsForTurn } from "./gate-registry.js";
|
||||
import {
|
||||
normalizeSchedulerStatus,
|
||||
normalizeTaskStatus,
|
||||
taskFrontmatterFromRecord,
|
||||
withTaskFrontmatter,
|
||||
} from "./task-frontmatter.js";
|
||||
import { logError, logWarning } from "./workflow-logger.js";
|
||||
|
||||
let loadAttempted = false;
|
||||
|
|
@ -78,7 +84,7 @@ function openRawDb(path) {
|
|||
loadProvider();
|
||||
return new DatabaseSync(path);
|
||||
}
|
||||
const SCHEMA_VERSION = 43;
|
||||
const SCHEMA_VERSION = 45;
|
||||
function indexExists(db, name) {
|
||||
return !!db
|
||||
.prepare(
|
||||
|
|
@ -418,6 +424,16 @@ function ensureSpecSchemaTables(db) {
|
|||
verify TEXT NOT NULL DEFAULT '',
|
||||
inputs TEXT DEFAULT '',
|
||||
expected_output TEXT DEFAULT '',
|
||||
risk TEXT NOT NULL DEFAULT 'low',
|
||||
mutation_scope TEXT NOT NULL DEFAULT 'isolated',
|
||||
verification_type TEXT NOT NULL DEFAULT 'self-check',
|
||||
plan_approval TEXT NOT NULL DEFAULT 'not-required',
|
||||
estimated_effort INTEGER DEFAULT NULL,
|
||||
dependencies TEXT NOT NULL DEFAULT '[]',
|
||||
blocks_parallel INTEGER NOT NULL DEFAULT 0,
|
||||
requires_user_input INTEGER NOT NULL DEFAULT 0,
|
||||
auto_retry INTEGER NOT NULL DEFAULT 1,
|
||||
max_retries INTEGER NOT NULL DEFAULT 2,
|
||||
spec_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (milestone_id, slice_id, task_id),
|
||||
|
|
@ -722,6 +738,17 @@ function initSchema(db, fileBacked) {
|
|||
full_plan_md TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
verification_status TEXT NOT NULL DEFAULT '',
|
||||
risk TEXT NOT NULL DEFAULT 'low',
|
||||
mutation_scope TEXT NOT NULL DEFAULT 'isolated',
|
||||
verification_type TEXT NOT NULL DEFAULT 'self-check',
|
||||
plan_approval TEXT NOT NULL DEFAULT 'not-required',
|
||||
task_status TEXT NOT NULL DEFAULT 'todo',
|
||||
estimated_effort INTEGER DEFAULT NULL,
|
||||
dependencies TEXT NOT NULL DEFAULT '[]',
|
||||
blocks_parallel INTEGER NOT NULL DEFAULT 0,
|
||||
requires_user_input INTEGER NOT NULL DEFAULT 0,
|
||||
auto_retry INTEGER NOT NULL DEFAULT 1,
|
||||
max_retries INTEGER NOT NULL DEFAULT 2,
|
||||
sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order
|
||||
escalation_pending INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): pause-on-escalation flag
|
||||
escalation_awaiting_review INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): continueWithDefault=true marker (no pause)
|
||||
|
|
@ -731,6 +758,7 @@ function initSchema(db, fileBacked) {
|
|||
FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id)
|
||||
)
|
||||
`);
|
||||
ensureTaskSchedulerTable(db);
|
||||
if (columnExists(db, "tasks", "escalation_pending")) {
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_escalation_pending ON tasks(milestone_id, slice_id, escalation_pending)
|
||||
|
|
@ -982,6 +1010,7 @@ function initSchema(db, fileBacked) {
|
|||
ensureHeadlessRunTables(db);
|
||||
ensureUokMessageTables(db);
|
||||
ensureSpecSchemaTables(db);
|
||||
ensureTaskFrontmatterColumns(db);
|
||||
ensureRetrievalEvidenceTables(db);
|
||||
db.exec(
|
||||
`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`,
|
||||
|
|
@ -1068,6 +1097,158 @@ function ensureTaskCreatedAtColumn(db) {
|
|||
`ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
|
||||
);
|
||||
}
|
||||
function ensureTaskFrontmatterColumns(db) {
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"risk",
|
||||
`ALTER TABLE tasks ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"mutation_scope",
|
||||
`ALTER TABLE tasks ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"verification_type",
|
||||
`ALTER TABLE tasks ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"plan_approval",
|
||||
`ALTER TABLE tasks ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"task_status",
|
||||
`ALTER TABLE tasks ADD COLUMN task_status TEXT NOT NULL DEFAULT 'todo'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"estimated_effort",
|
||||
`ALTER TABLE tasks ADD COLUMN estimated_effort INTEGER DEFAULT NULL`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"dependencies",
|
||||
`ALTER TABLE tasks ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"blocks_parallel",
|
||||
`ALTER TABLE tasks ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"requires_user_input",
|
||||
`ALTER TABLE tasks ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"auto_retry",
|
||||
`ALTER TABLE tasks ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
"max_retries",
|
||||
`ALTER TABLE tasks ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`,
|
||||
);
|
||||
for (const table of ["task_specs"]) {
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"risk",
|
||||
`ALTER TABLE ${table} ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"mutation_scope",
|
||||
`ALTER TABLE ${table} ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"verification_type",
|
||||
`ALTER TABLE ${table} ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"plan_approval",
|
||||
`ALTER TABLE ${table} ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"estimated_effort",
|
||||
`ALTER TABLE ${table} ADD COLUMN estimated_effort INTEGER DEFAULT NULL`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"dependencies",
|
||||
`ALTER TABLE ${table} ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"blocks_parallel",
|
||||
`ALTER TABLE ${table} ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"requires_user_input",
|
||||
`ALTER TABLE ${table} ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"auto_retry",
|
||||
`ALTER TABLE ${table} ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
table,
|
||||
"max_retries",
|
||||
`ALTER TABLE ${table} ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`,
|
||||
);
|
||||
}
|
||||
}
|
||||
function ensureTaskSchedulerTable(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_scheduler (
|
||||
milestone_id TEXT NOT NULL,
|
||||
slice_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
due_at TEXT DEFAULT NULL,
|
||||
claimed_by TEXT DEFAULT NULL,
|
||||
dispatched_at TEXT DEFAULT NULL,
|
||||
consumed_at TEXT DEFAULT NULL,
|
||||
expires_at TEXT DEFAULT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (milestone_id, slice_id, task_id),
|
||||
FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_task_scheduler_status
|
||||
ON task_scheduler(status, due_at)
|
||||
`);
|
||||
}
|
||||
function migrateCostUsdToMicroUsd(db) {
|
||||
// Tier 2.7: Migrate cost_usd REAL to cost_micro_usd INTEGER
|
||||
// Converts floating-point USD values to integer micro-USD (multiply by 1,000,000)
|
||||
|
|
@ -2244,6 +2425,89 @@ function migrateSchema(db) {
|
|||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (currentVersion < 44) {
|
||||
ensureSpecSchemaTables(db);
|
||||
ensureTaskFrontmatterColumns(db);
|
||||
db.exec(`
|
||||
UPDATE tasks
|
||||
SET task_status = CASE status
|
||||
WHEN 'complete' THEN 'done'
|
||||
WHEN 'completed' THEN 'done'
|
||||
WHEN 'done' THEN 'done'
|
||||
WHEN 'running' THEN 'running'
|
||||
WHEN 'in_progress' THEN 'running'
|
||||
WHEN 'blocked' THEN 'blocked'
|
||||
WHEN 'failed' THEN 'failed'
|
||||
WHEN 'cancelled' THEN 'cancelled'
|
||||
ELSE COALESCE(NULLIF(task_status, ''), 'todo')
|
||||
END
|
||||
`);
|
||||
db.exec(`
|
||||
UPDATE task_specs
|
||||
SET risk = COALESCE((SELECT tasks.risk FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), risk),
|
||||
mutation_scope = COALESCE((SELECT tasks.mutation_scope FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), mutation_scope),
|
||||
verification_type = COALESCE((SELECT tasks.verification_type FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), verification_type),
|
||||
plan_approval = COALESCE((SELECT tasks.plan_approval FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), plan_approval),
|
||||
estimated_effort = COALESCE((SELECT tasks.estimated_effort FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), estimated_effort),
|
||||
dependencies = COALESCE((SELECT tasks.dependencies FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), dependencies),
|
||||
blocks_parallel = COALESCE((SELECT tasks.blocks_parallel FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), blocks_parallel),
|
||||
requires_user_input = COALESCE((SELECT tasks.requires_user_input FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), requires_user_input),
|
||||
auto_retry = COALESCE((SELECT tasks.auto_retry FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), auto_retry),
|
||||
max_retries = COALESCE((SELECT tasks.max_retries FROM tasks
|
||||
WHERE tasks.milestone_id = task_specs.milestone_id
|
||||
AND tasks.slice_id = task_specs.slice_id
|
||||
AND tasks.id = task_specs.task_id), max_retries)
|
||||
`);
|
||||
db.prepare(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
|
||||
).run({
|
||||
":version": 44,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (currentVersion < 45) {
|
||||
ensureTaskSchedulerTable(db);
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO task_scheduler (
|
||||
milestone_id, slice_id, task_id, status, updated_at
|
||||
)
|
||||
SELECT milestone_id, slice_id, id, 'queued', datetime('now')
|
||||
FROM tasks
|
||||
`);
|
||||
db.prepare(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
|
||||
).run({
|
||||
":version": 45,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK");
|
||||
|
|
@ -3070,12 +3334,12 @@ export function insertTask(t) {
|
|||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
currentDb
|
||||
.prepare(`INSERT INTO tasks (
|
||||
milestone_id, slice_id, id, title, status, one_liner, narrative,
|
||||
milestone_id, slice_id, id, title, status, task_status, one_liner, narrative,
|
||||
verification_result, verification_status, duration, completed_at, blocker_discovered,
|
||||
deviations, known_issues, key_files, key_decisions, full_summary_md,
|
||||
description, estimate, files, verify, inputs, expected_output, observability_impact, sequence
|
||||
) VALUES (
|
||||
:milestone_id, :slice_id, :id, :title, :status, :one_liner, :narrative,
|
||||
:milestone_id, :slice_id, :id, :title, :status, :task_status, :one_liner, :narrative,
|
||||
:verification_result, :verification_status, :duration, :completed_at, :blocker_discovered,
|
||||
:deviations, :known_issues, :key_files, :key_decisions, :full_summary_md,
|
||||
:description, :estimate, :files, :verify, :inputs, :expected_output, :observability_impact, :sequence
|
||||
|
|
@ -3083,6 +3347,7 @@ export function insertTask(t) {
|
|||
ON CONFLICT(milestone_id, slice_id, id) DO UPDATE SET
|
||||
title = CASE WHEN NULLIF(:title, '') IS NOT NULL THEN :title ELSE tasks.title END,
|
||||
status = :status,
|
||||
task_status = :task_status,
|
||||
one_liner = :one_liner,
|
||||
narrative = :narrative,
|
||||
verification_result = :verification_result,
|
||||
|
|
@ -3109,6 +3374,7 @@ export function insertTask(t) {
|
|||
":id": t.id,
|
||||
":title": t.title ?? "",
|
||||
":status": t.status ?? "pending",
|
||||
":task_status": normalizeTaskStatus(t.taskStatus ?? t.status) ?? "todo",
|
||||
":one_liner": t.oneLiner ?? "",
|
||||
":narrative": t.narrative ?? "",
|
||||
":verification_result": t.verificationResult ?? "",
|
||||
|
|
@ -3133,15 +3399,60 @@ export function insertTask(t) {
|
|||
":observability_impact": t.planning?.observabilityImpact ?? "",
|
||||
":sequence": t.sequence ?? 0,
|
||||
});
|
||||
insertTaskSpecIfAbsent(t.milestoneId, t.sliceId, t.id, t.planning ?? {});
|
||||
if (hasTaskSpecIntent(t.planning)) {
|
||||
insertTaskSpecIfAbsent(t.milestoneId, t.sliceId, t.id, t.planning ?? {});
|
||||
}
|
||||
insertTaskSchedulerIfAbsent(t.milestoneId, t.sliceId, t.id);
|
||||
}
|
||||
function hasTaskSpecIntent(planning = {}) {
|
||||
if (!planning || typeof planning !== "object") return false;
|
||||
if (typeof planning.verify === "string" && planning.verify.trim())
|
||||
return true;
|
||||
if (Array.isArray(planning.inputs) && planning.inputs.length > 0) return true;
|
||||
if (
|
||||
Array.isArray(planning.expectedOutput) &&
|
||||
planning.expectedOutput.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
for (const key of [
|
||||
"risk",
|
||||
"mutationScope",
|
||||
"mutation_scope",
|
||||
"verification",
|
||||
"verificationType",
|
||||
"verification_type",
|
||||
"planApproval",
|
||||
"plan_approval",
|
||||
"estimatedEffort",
|
||||
"estimated_effort",
|
||||
"dependencies",
|
||||
"blocksParallel",
|
||||
"blocks_parallel",
|
||||
"requiresUserInput",
|
||||
"requires_user_input",
|
||||
"autoRetry",
|
||||
"auto_retry",
|
||||
"maxRetries",
|
||||
"max_retries",
|
||||
]) {
|
||||
if (planning[key] !== undefined) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning = {}) {
|
||||
if (!hasTaskSpecIntent(planning)) return;
|
||||
const frontmatter = taskFrontmatterFromRecord(planning).normalized;
|
||||
currentDb
|
||||
.prepare(`INSERT OR IGNORE INTO task_specs (
|
||||
milestone_id, slice_id, task_id, verify, inputs, expected_output,
|
||||
risk, mutation_scope, verification_type, plan_approval, estimated_effort,
|
||||
dependencies, blocks_parallel, requires_user_input, auto_retry, max_retries,
|
||||
spec_version, created_at
|
||||
) VALUES (
|
||||
:milestone_id, :slice_id, :task_id, :verify, :inputs, :expected_output,
|
||||
:risk, :mutation_scope, :verification_type, :plan_approval, :estimated_effort,
|
||||
:dependencies, :blocks_parallel, :requires_user_input, :auto_retry, :max_retries,
|
||||
1, :created_at
|
||||
)`)
|
||||
.run({
|
||||
|
|
@ -3151,9 +3462,63 @@ function insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning = {}) {
|
|||
":verify": planning.verify ?? "",
|
||||
":inputs": JSON.stringify(planning.inputs ?? []),
|
||||
":expected_output": JSON.stringify(planning.expectedOutput ?? []),
|
||||
":risk": frontmatter.risk,
|
||||
":mutation_scope": frontmatter.mutationScope,
|
||||
":verification_type": frontmatter.verification,
|
||||
":plan_approval": frontmatter.planApproval,
|
||||
":estimated_effort": frontmatter.estimatedEffort,
|
||||
":dependencies": JSON.stringify(frontmatter.dependencies),
|
||||
":blocks_parallel": frontmatter.blocksParallel ? 1 : 0,
|
||||
":requires_user_input": frontmatter.requiresUserInput ? 1 : 0,
|
||||
":auto_retry": frontmatter.autoRetry ? 1 : 0,
|
||||
":max_retries": frontmatter.maxRetries,
|
||||
":created_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
function insertTaskSchedulerIfAbsent(milestoneId, sliceId, taskId) {
|
||||
upsertTaskSchedulerStatus(milestoneId, sliceId, taskId, "queued", {
|
||||
onlyIfAbsent: true,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Upsert a task scheduler row without changing the task lifecycle row.
|
||||
*
|
||||
* Purpose: keep due/claimed/dispatched/consumed scheduling separate from
|
||||
* task_status so automation level and timing do not overwrite work progress.
|
||||
*
|
||||
* Consumer: task scheduling/dispatch surfaces and task planning row creation.
|
||||
*/
|
||||
export function upsertTaskSchedulerStatus(
|
||||
milestoneId,
|
||||
sliceId,
|
||||
taskId,
|
||||
status = "queued",
|
||||
{ onlyIfAbsent = false } = {},
|
||||
) {
|
||||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
const schedulerStatus = normalizeSchedulerStatus(status) ?? "queued";
|
||||
const sql = onlyIfAbsent
|
||||
? `INSERT OR IGNORE INTO task_scheduler (
|
||||
milestone_id, slice_id, task_id, status, updated_at
|
||||
) VALUES (
|
||||
:milestone_id, :slice_id, :task_id, :status, :updated_at
|
||||
)`
|
||||
: `INSERT INTO task_scheduler (
|
||||
milestone_id, slice_id, task_id, status, updated_at
|
||||
) VALUES (
|
||||
:milestone_id, :slice_id, :task_id, :status, :updated_at
|
||||
)
|
||||
ON CONFLICT(milestone_id, slice_id, task_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at`;
|
||||
currentDb.prepare(sql).run({
|
||||
":milestone_id": milestoneId,
|
||||
":slice_id": sliceId,
|
||||
":task_id": taskId,
|
||||
":status": schedulerStatus,
|
||||
":updated_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
export function updateTaskStatus(
|
||||
milestoneId,
|
||||
sliceId,
|
||||
|
|
@ -3162,12 +3527,17 @@ export function updateTaskStatus(
|
|||
completedAt,
|
||||
) {
|
||||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
const taskStatus = normalizeTaskStatus(status) ?? "todo";
|
||||
currentDb
|
||||
.prepare(`UPDATE tasks SET status = :status, completed_at = :completed_at
|
||||
.prepare(`UPDATE tasks SET
|
||||
status = :status,
|
||||
completed_at = :completed_at,
|
||||
task_status = :task_status
|
||||
WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`)
|
||||
.run({
|
||||
":status": status,
|
||||
":completed_at": completedAt ?? null,
|
||||
":task_status": taskStatus,
|
||||
":milestone_id": milestoneId,
|
||||
":slice_id": sliceId,
|
||||
":id": taskId,
|
||||
|
|
@ -3293,6 +3663,11 @@ export function setTaskBlockerDiscovered(
|
|||
export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) {
|
||||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning);
|
||||
const frontmatter = taskFrontmatterFromRecord(planning).normalized;
|
||||
const hasTaskStatus =
|
||||
planning.taskStatus !== undefined ||
|
||||
planning.task_status !== undefined ||
|
||||
planning.status !== undefined;
|
||||
currentDb
|
||||
.prepare(`UPDATE tasks SET
|
||||
title = COALESCE(:title, title),
|
||||
|
|
@ -3303,7 +3678,18 @@ export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) {
|
|||
inputs = COALESCE(:inputs, inputs),
|
||||
expected_output = COALESCE(:expected_output, expected_output),
|
||||
observability_impact = COALESCE(:observability_impact, observability_impact),
|
||||
full_plan_md = COALESCE(:full_plan_md, full_plan_md)
|
||||
full_plan_md = COALESCE(:full_plan_md, full_plan_md),
|
||||
risk = :risk,
|
||||
mutation_scope = :mutation_scope,
|
||||
verification_type = :verification_type,
|
||||
plan_approval = :plan_approval,
|
||||
task_status = CASE WHEN :has_task_status = 1 THEN :task_status ELSE task_status END,
|
||||
estimated_effort = :estimated_effort,
|
||||
dependencies = :dependencies,
|
||||
blocks_parallel = :blocks_parallel,
|
||||
requires_user_input = :requires_user_input,
|
||||
auto_retry = :auto_retry,
|
||||
max_retries = :max_retries
|
||||
WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`)
|
||||
.run({
|
||||
":milestone_id": milestoneId,
|
||||
|
|
@ -3320,7 +3706,32 @@ export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) {
|
|||
: null,
|
||||
":observability_impact": planning.observabilityImpact ?? null,
|
||||
":full_plan_md": planning.fullPlanMd ?? null,
|
||||
":risk": frontmatter.risk,
|
||||
":mutation_scope": frontmatter.mutationScope,
|
||||
":verification_type": frontmatter.verification,
|
||||
":plan_approval": frontmatter.planApproval,
|
||||
":task_status": frontmatter.taskStatus,
|
||||
":has_task_status": hasTaskStatus ? 1 : 0,
|
||||
":estimated_effort": frontmatter.estimatedEffort,
|
||||
":dependencies": JSON.stringify(frontmatter.dependencies),
|
||||
":blocks_parallel": frontmatter.blocksParallel ? 1 : 0,
|
||||
":requires_user_input": frontmatter.requiresUserInput ? 1 : 0,
|
||||
":auto_retry": frontmatter.autoRetry ? 1 : 0,
|
||||
":max_retries": frontmatter.maxRetries,
|
||||
});
|
||||
if (
|
||||
planning.schedulerStatus !== undefined ||
|
||||
planning.scheduler_status !== undefined
|
||||
) {
|
||||
upsertTaskSchedulerStatus(
|
||||
milestoneId,
|
||||
sliceId,
|
||||
taskId,
|
||||
frontmatter.schedulerStatus,
|
||||
);
|
||||
} else {
|
||||
insertTaskSchedulerIfAbsent(milestoneId, sliceId, taskId);
|
||||
}
|
||||
}
|
||||
function parsePlanningMeeting(raw) {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
|
|
@ -3447,7 +3858,7 @@ function rowToTask(row) {
|
|||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
return {
|
||||
return withTaskFrontmatter({
|
||||
milestone_id: row["milestone_id"],
|
||||
slice_id: row["slice_id"],
|
||||
id: row["id"],
|
||||
|
|
@ -3474,17 +3885,35 @@ function rowToTask(row) {
|
|||
full_plan_md: row["full_plan_md"] ?? "",
|
||||
sequence: row["sequence"] ?? 0,
|
||||
verification_status: row["verification_status"] ?? "",
|
||||
risk: row["risk"] ?? "low",
|
||||
mutation_scope: row["mutation_scope"] ?? "isolated",
|
||||
verification_type: row["verification_type"] ?? "self-check",
|
||||
plan_approval: row["plan_approval"] ?? "not-required",
|
||||
task_status: row["task_status"] ?? row["status"] ?? "todo",
|
||||
scheduler_status: row["scheduler_status"] ?? "queued",
|
||||
estimated_effort: row["estimated_effort"] ?? null,
|
||||
dependencies: parseTaskArray(row["dependencies"]),
|
||||
blocks_parallel: row["blocks_parallel"] ?? 0,
|
||||
requires_user_input: row["requires_user_input"] ?? 0,
|
||||
auto_retry: row["auto_retry"] ?? 1,
|
||||
max_retries: row["max_retries"] ?? 2,
|
||||
escalation_pending: row["escalation_pending"] ?? 0,
|
||||
escalation_awaiting_review: row["escalation_awaiting_review"] ?? 0,
|
||||
escalation_override_applied: row["escalation_override_applied"] ?? 0,
|
||||
escalation_artifact_path: row["escalation_artifact_path"] ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
export function getTask(milestoneId, sliceId, taskId) {
|
||||
if (!currentDb) return null;
|
||||
const row = currentDb
|
||||
.prepare(
|
||||
"SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid AND id = :tid",
|
||||
`SELECT t.*, ts.status AS scheduler_status
|
||||
FROM tasks t
|
||||
LEFT JOIN task_scheduler ts
|
||||
ON t.milestone_id = ts.milestone_id
|
||||
AND t.slice_id = ts.slice_id
|
||||
AND t.id = ts.task_id
|
||||
WHERE t.milestone_id = :mid AND t.slice_id = :sid AND t.id = :tid`,
|
||||
)
|
||||
.get({ ":mid": milestoneId, ":sid": sliceId, ":tid": taskId });
|
||||
if (!row) return null;
|
||||
|
|
@ -3494,7 +3923,14 @@ export function getSliceTasks(milestoneId, sliceId) {
|
|||
if (!currentDb) return [];
|
||||
const rows = currentDb
|
||||
.prepare(
|
||||
"SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid ORDER BY sequence, id",
|
||||
`SELECT t.*, ts.status AS scheduler_status
|
||||
FROM tasks t
|
||||
LEFT JOIN task_scheduler ts
|
||||
ON t.milestone_id = ts.milestone_id
|
||||
AND t.slice_id = ts.slice_id
|
||||
AND t.id = ts.task_id
|
||||
WHERE t.milestone_id = :mid AND t.slice_id = :sid
|
||||
ORDER BY t.sequence, t.id`,
|
||||
)
|
||||
.all({ ":mid": milestoneId, ":sid": sliceId });
|
||||
return rows.map(rowToTask);
|
||||
|
|
|
|||
170
src/resources/extensions/sf/subagent-inheritance.js
Normal file
170
src/resources/extensions/sf/subagent-inheritance.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* subagent-inheritance.js — parent mode envelope for subagent dispatch.
|
||||
*
|
||||
* Purpose: keep delegated agents inside the parent's provider, model-mode, and
|
||||
* permission-profile constraints so swarms cannot silently widen authority.
|
||||
*
|
||||
* Consumer: `subagent` extension before it starts worker processes.
|
||||
*/
|
||||
|
||||
import { getAutoSession } from "./auto/session.js";
|
||||
import {
|
||||
resolveModelMode,
|
||||
resolvePermissionProfile,
|
||||
resolveRunControlMode,
|
||||
resolveWorkMode,
|
||||
} from "./operating-model.js";
|
||||
import { isProviderAllowedByLists } from "./preferences-models.js";
|
||||
|
||||
function providerFromModelId(modelId) {
|
||||
if (!modelId || typeof modelId !== "string") return null;
|
||||
const [provider] = modelId.split("/", 1);
|
||||
return provider && provider !== modelId ? provider : null;
|
||||
}
|
||||
|
||||
function isHeavyModelId(modelId) {
|
||||
if (!modelId || typeof modelId !== "string") return false;
|
||||
const normalized = modelId.toLowerCase();
|
||||
return [
|
||||
"opus",
|
||||
"o1-",
|
||||
"gpt-4-turbo",
|
||||
"gpt-5",
|
||||
"claude-3-opus",
|
||||
"deepseek-reasoner",
|
||||
].some((indicator) => normalized.includes(indicator));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an inheritance envelope from the current parent session.
|
||||
*
|
||||
* Purpose: capture orthogonal mode axes and provider policy once, then pass the
|
||||
* same envelope through validation and child-process environment.
|
||||
*
|
||||
* Consumer: `subagent` tool execution.
|
||||
*/
|
||||
export function buildSubagentInheritanceEnvelope({
|
||||
mode,
|
||||
preferences = {},
|
||||
surface,
|
||||
} = {}) {
|
||||
const session = getAutoSession();
|
||||
const sessionMode = mode ?? session.getMode?.() ?? {};
|
||||
|
||||
return {
|
||||
workMode: resolveWorkMode(sessionMode.workMode),
|
||||
modelMode: resolveModelMode(sessionMode.modelMode),
|
||||
permissionProfile: resolvePermissionProfile(sessionMode.permissionProfile),
|
||||
runControl: resolveRunControlMode(sessionMode.runControl),
|
||||
surface: surface ?? sessionMode.surface ?? "tui",
|
||||
allowedProviders: preferences.allowed_providers ?? null,
|
||||
blockedProviders: preferences.blocked_providers ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a proposed subagent dispatch against the parent envelope.
|
||||
*
|
||||
* Purpose: reject delegated work that would bypass parent provider allowlists,
|
||||
* fast-mode routing posture, or restricted mutation rules.
|
||||
*
|
||||
* Consumer: `subagent` tool before single, chain, parallel, debate, or
|
||||
* background dispatch.
|
||||
*/
|
||||
export function validateSubagentDispatch(envelope, proposal) {
|
||||
const modelId = proposal.model ?? null;
|
||||
const provider = proposal.provider ?? providerFromModelId(modelId);
|
||||
|
||||
if (
|
||||
provider &&
|
||||
!isProviderAllowedByLists(
|
||||
provider,
|
||||
envelope.allowedProviders,
|
||||
envelope.blockedProviders,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Provider "${provider}" is blocked by parent provider policy`,
|
||||
};
|
||||
}
|
||||
|
||||
if (envelope.modelMode === "fast" && isHeavyModelId(modelId)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Model mode "fast" blocks heavy subagent model "${modelId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
if (envelope.permissionProfile === "restricted") {
|
||||
const proposedTools = proposal.tools ?? [];
|
||||
const blocked = proposedTools.filter((toolName) =>
|
||||
["write", "edit", "bash", "mac_launch_app"].some((restrictedTool) =>
|
||||
toolName.toLowerCase().includes(restrictedTool),
|
||||
),
|
||||
);
|
||||
if (blocked.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Permission profile "restricted" blocks subagent tools: ${blocked.join(", ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the environment keys that carry parent inheritance.
|
||||
*
|
||||
* Purpose: let direct spawn and generated cmux launchers propagate the same
|
||||
* constraint envelope without copying the full parent environment.
|
||||
*
|
||||
* Consumer: `resolveSubagentLaunchSpec`.
|
||||
*/
|
||||
function buildSubagentInheritanceEnvPatch(envelope) {
|
||||
if (!envelope) return {};
|
||||
return {
|
||||
SF_PARENT_WORK_MODE: envelope.workMode,
|
||||
SF_PARENT_MODEL_MODE: envelope.modelMode,
|
||||
SF_PARENT_PERMISSION_PROFILE: envelope.permissionProfile,
|
||||
SF_PARENT_RUN_CONTROL: envelope.runControl,
|
||||
SF_PARENT_SURFACE: envelope.surface,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply parent inheritance keys to a process environment.
|
||||
*
|
||||
* Purpose: make spawned subagents aware of the parent posture for downstream
|
||||
* enforcement and diagnostics.
|
||||
*
|
||||
* Consumer: direct subagent process launch.
|
||||
*/
|
||||
export function applyInheritanceToEnv(envelope, env = process.env) {
|
||||
return {
|
||||
...env,
|
||||
...buildSubagentInheritanceEnvPatch(envelope),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read parent inheritance from a child-process environment.
|
||||
*
|
||||
* Purpose: let subagent startup and diagnostics recover the parent posture
|
||||
* without reading parent-only session state.
|
||||
*
|
||||
* Consumer: subagent child process startup paths.
|
||||
*/
|
||||
export function readParentInheritanceFromEnv(env = process.env) {
|
||||
if (!env.SF_PARENT_WORK_MODE) return null;
|
||||
return {
|
||||
workMode: resolveWorkMode(env.SF_PARENT_WORK_MODE),
|
||||
modelMode: resolveModelMode(env.SF_PARENT_MODEL_MODE),
|
||||
permissionProfile: resolvePermissionProfile(
|
||||
env.SF_PARENT_PERMISSION_PROFILE,
|
||||
),
|
||||
runControl: resolveRunControlMode(env.SF_PARENT_RUN_CONTROL),
|
||||
surface: env.SF_PARENT_SURFACE ?? "tui",
|
||||
};
|
||||
}
|
||||
356
src/resources/extensions/sf/task-frontmatter.js
Normal file
356
src/resources/extensions/sf/task-frontmatter.js
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
/**
|
||||
* Task Frontmatter - schema-backed task metadata
|
||||
*
|
||||
* Purpose: add structured fields to task records for risk assessment,
|
||||
* mutation scope declaration, verification requirements, plan approval, and
|
||||
* task lifecycle status while keeping scheduler status as a separate view field.
|
||||
*
|
||||
* Consumer: plan-v2 task creation, UOK gate runner, parallel orchestrator,
|
||||
* sf-db row mapping, and task state machine.
|
||||
*/
|
||||
|
||||
export const RISK_LEVELS = ["none", "low", "medium", "high", "critical"];
|
||||
|
||||
export const MUTATION_SCOPES = [
|
||||
"none",
|
||||
"docs-only",
|
||||
"config",
|
||||
"test-only",
|
||||
"isolated",
|
||||
"bounded",
|
||||
"cross-cutting",
|
||||
"systemic",
|
||||
];
|
||||
|
||||
export const VERIFICATION_TYPES = [
|
||||
"none",
|
||||
"self-check",
|
||||
"review",
|
||||
"test",
|
||||
"integration",
|
||||
"manual-qa",
|
||||
];
|
||||
|
||||
export const PLAN_APPROVAL_STATES = [
|
||||
"not-required",
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"auto-approved",
|
||||
];
|
||||
|
||||
export const TASK_STATUSES = [
|
||||
"todo",
|
||||
"running",
|
||||
"verifying",
|
||||
"reviewing",
|
||||
"done",
|
||||
"blocked",
|
||||
"paused",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"retrying",
|
||||
];
|
||||
|
||||
export const SCHEDULER_STATUSES = [
|
||||
"queued",
|
||||
"due",
|
||||
"claimed",
|
||||
"dispatched",
|
||||
"consumed",
|
||||
"expired",
|
||||
];
|
||||
|
||||
const TASK_STATUS_ALIASES = {
|
||||
complete: "done",
|
||||
completed: "done",
|
||||
in_progress: "running",
|
||||
"manual-attention": "reviewing",
|
||||
manual_attention: "reviewing",
|
||||
pending: "todo",
|
||||
review: "reviewing",
|
||||
};
|
||||
|
||||
const SCHEDULER_STATUS_ALIASES = {
|
||||
completed: "consumed",
|
||||
done: "consumed",
|
||||
pending: "queued",
|
||||
};
|
||||
|
||||
export const DEFAULT_TASK_FRONTMATTER = {
|
||||
risk: "low",
|
||||
mutationScope: "isolated",
|
||||
verification: "self-check",
|
||||
planApproval: "not-required",
|
||||
taskStatus: "todo",
|
||||
schedulerStatus: "queued",
|
||||
estimatedEffort: null,
|
||||
keyFiles: [],
|
||||
dependencies: [],
|
||||
blocksParallel: false,
|
||||
requiresUserInput: false,
|
||||
autoRetry: true,
|
||||
maxRetries: 2,
|
||||
};
|
||||
|
||||
export function normalizeTaskStatus(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") return "todo";
|
||||
const status = value.trim().toLowerCase();
|
||||
if (TASK_STATUSES.includes(status)) return status;
|
||||
return TASK_STATUS_ALIASES[status] ?? null;
|
||||
}
|
||||
|
||||
export function normalizeSchedulerStatus(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") return "queued";
|
||||
const status = value.trim().toLowerCase();
|
||||
if (SCHEDULER_STATUSES.includes(status)) return status;
|
||||
return SCHEDULER_STATUS_ALIASES[status] ?? null;
|
||||
}
|
||||
|
||||
function normalizeArray(value) {
|
||||
if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
|
||||
if (typeof value !== "string" || value.trim() === "") return [];
|
||||
try {
|
||||
return normalizeArray(JSON.parse(value));
|
||||
} catch {
|
||||
return value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBoolean(value) {
|
||||
if (value === true || value === 1) return true;
|
||||
if (value === false || value === 0 || value == null) return false;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "y"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "n", ""].includes(normalized)) return false;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function validateChoice(field, value, allowed, normalized, errors) {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
if (allowed.includes(value)) {
|
||||
normalized[field] = value;
|
||||
return;
|
||||
}
|
||||
errors.push(
|
||||
`Invalid ${field} "${value}". Must be one of: ${allowed.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function validateTaskFrontmatter(frontmatter = {}) {
|
||||
const errors = [];
|
||||
const normalized = {
|
||||
...DEFAULT_TASK_FRONTMATTER,
|
||||
keyFiles: [],
|
||||
dependencies: [],
|
||||
};
|
||||
|
||||
validateChoice("risk", frontmatter.risk, RISK_LEVELS, normalized, errors);
|
||||
validateChoice(
|
||||
"mutationScope",
|
||||
frontmatter.mutationScope,
|
||||
MUTATION_SCOPES,
|
||||
normalized,
|
||||
errors,
|
||||
);
|
||||
validateChoice(
|
||||
"verification",
|
||||
frontmatter.verification,
|
||||
VERIFICATION_TYPES,
|
||||
normalized,
|
||||
errors,
|
||||
);
|
||||
validateChoice(
|
||||
"planApproval",
|
||||
frontmatter.planApproval,
|
||||
PLAN_APPROVAL_STATES,
|
||||
normalized,
|
||||
errors,
|
||||
);
|
||||
|
||||
if (frontmatter.taskStatus !== undefined) {
|
||||
const status = normalizeTaskStatus(frontmatter.taskStatus);
|
||||
if (status) {
|
||||
normalized.taskStatus = status;
|
||||
} else {
|
||||
errors.push(
|
||||
`Invalid taskStatus "${frontmatter.taskStatus}". Must be one of: ${TASK_STATUSES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter.schedulerStatus !== undefined) {
|
||||
const status = normalizeSchedulerStatus(frontmatter.schedulerStatus);
|
||||
if (status) {
|
||||
normalized.schedulerStatus = status;
|
||||
} else {
|
||||
errors.push(
|
||||
`Invalid schedulerStatus "${frontmatter.schedulerStatus}". Must be one of: ${SCHEDULER_STATUSES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter.estimatedEffort !== undefined) {
|
||||
const effort = Number(frontmatter.estimatedEffort);
|
||||
if (!Number.isNaN(effort) && effort >= 0) {
|
||||
normalized.estimatedEffort = effort;
|
||||
} else if (frontmatter.estimatedEffort !== null) {
|
||||
errors.push(
|
||||
`Invalid estimatedEffort "${frontmatter.estimatedEffort}". Must be a non-negative number or null.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter.keyFiles !== undefined) {
|
||||
normalized.keyFiles = normalizeArray(frontmatter.keyFiles);
|
||||
}
|
||||
if (frontmatter.dependencies !== undefined) {
|
||||
normalized.dependencies = normalizeArray(frontmatter.dependencies);
|
||||
}
|
||||
|
||||
for (const field of ["blocksParallel", "requiresUserInput", "autoRetry"]) {
|
||||
if (frontmatter[field] !== undefined) {
|
||||
normalized[field] = normalizeBoolean(frontmatter[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter.maxRetries !== undefined) {
|
||||
const retries = Number(frontmatter.maxRetries);
|
||||
if (Number.isInteger(retries) && retries >= 0 && retries <= 10) {
|
||||
normalized.maxRetries = retries;
|
||||
} else {
|
||||
errors.push(
|
||||
`Invalid maxRetries "${frontmatter.maxRetries}". Must be an integer 0-10.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function taskFrontmatterFromRecord(task = {}, overrides = {}) {
|
||||
const rawFrontmatter = {
|
||||
risk: task.risk,
|
||||
mutationScope: task.mutation_scope ?? task.mutationScope,
|
||||
verification:
|
||||
task.verification_type ?? task.verificationType ?? task.verification,
|
||||
planApproval: task.plan_approval ?? task.planApproval,
|
||||
taskStatus: task.task_status ?? task.taskStatus ?? task.status,
|
||||
schedulerStatus: task.scheduler_status ?? task.schedulerStatus,
|
||||
estimatedEffort: task.estimated_effort ?? task.estimatedEffort,
|
||||
keyFiles:
|
||||
task.frontmatter_key_files ??
|
||||
task.frontmatterKeyFiles ??
|
||||
task.files ??
|
||||
task.key_files ??
|
||||
task.keyFiles ??
|
||||
[],
|
||||
dependencies:
|
||||
task.dependencies ??
|
||||
task.depends_on ??
|
||||
task.dependsOn ??
|
||||
task.depends ??
|
||||
[],
|
||||
blocksParallel: task.blocks_parallel ?? task.blocksParallel,
|
||||
requiresUserInput: task.requires_user_input ?? task.requiresUserInput,
|
||||
autoRetry: task.auto_retry ?? task.autoRetry,
|
||||
maxRetries: task.max_retries ?? task.maxRetries,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return validateTaskFrontmatter(rawFrontmatter);
|
||||
}
|
||||
|
||||
export function buildTaskRecord(task = {}, overrides = {}) {
|
||||
const validation = taskFrontmatterFromRecord(task, overrides);
|
||||
return {
|
||||
...task,
|
||||
frontmatter: validation.normalized,
|
||||
frontmatterValid: validation.valid,
|
||||
frontmatterErrors: validation.errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function withTaskFrontmatter(task = {}, overrides = {}) {
|
||||
return buildTaskRecord(task, overrides);
|
||||
}
|
||||
|
||||
export function canRunInParallel(taskA, taskB) {
|
||||
const fmA = taskA.frontmatter ?? buildTaskRecord(taskA).frontmatter;
|
||||
const fmB = taskB.frontmatter ?? buildTaskRecord(taskB).frontmatter;
|
||||
|
||||
if (fmA.blocksParallel || fmB.blocksParallel) {
|
||||
return {
|
||||
canParallel: false,
|
||||
reason: "One or both tasks block parallel execution",
|
||||
};
|
||||
}
|
||||
|
||||
if (fmA.mutationScope === "systemic" || fmB.mutationScope === "systemic") {
|
||||
return {
|
||||
canParallel: false,
|
||||
reason: "One or both tasks have systemic mutation scope",
|
||||
};
|
||||
}
|
||||
|
||||
const highRisk = ["high", "critical"];
|
||||
if (highRisk.includes(fmA.risk) && highRisk.includes(fmB.risk)) {
|
||||
return { canParallel: false, reason: "Both tasks are high/critical risk" };
|
||||
}
|
||||
|
||||
if (fmA.keyFiles.length > 0 && fmB.keyFiles.length > 0) {
|
||||
const filesB = new Set(fmB.keyFiles);
|
||||
const overlap = fmA.keyFiles.filter((file) => filesB.has(file));
|
||||
if (overlap.length > 0) {
|
||||
return {
|
||||
canParallel: false,
|
||||
reason: `File overlap: ${overlap.join(", ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { canParallel: true };
|
||||
}
|
||||
|
||||
export function canTasksRunInParallel(taskA, taskB) {
|
||||
return canRunInParallel(taskA, taskB);
|
||||
}
|
||||
|
||||
export function computeTaskPriority(task) {
|
||||
const fm = task.frontmatter ?? buildTaskRecord(task).frontmatter;
|
||||
let score = 50;
|
||||
|
||||
const riskScores = { none: 0, low: 5, medium: 15, high: 30, critical: 50 };
|
||||
score += riskScores[fm.risk] ?? 0;
|
||||
|
||||
const scopeScores = {
|
||||
none: 0,
|
||||
"docs-only": 2,
|
||||
config: 5,
|
||||
"test-only": 3,
|
||||
isolated: 5,
|
||||
bounded: 10,
|
||||
"cross-cutting": 25,
|
||||
systemic: 40,
|
||||
};
|
||||
score += scopeScores[fm.mutationScope] ?? 0;
|
||||
|
||||
if (fm.blocksParallel) score += 20;
|
||||
if (fm.requiresUserInput) score += 10;
|
||||
if (fm.planApproval === "pending") score += 10;
|
||||
|
||||
return Math.min(100, score);
|
||||
}
|
||||
|
||||
export function scoreTaskFrontmatterPriority(task) {
|
||||
return computeTaskPriority(task);
|
||||
}
|
||||
|
|
@ -45,6 +45,21 @@ test("direct command completions strip the already typed command name", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test("extension_manifest_uses_permission_profile_command_name", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/resources/extensions/sf/extension-manifest.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const commands = manifest.provides?.commands ?? [];
|
||||
assert.equal(commands.includes("permission-profile"), true);
|
||||
assert.equal(commands.includes("trust"), false);
|
||||
});
|
||||
|
||||
test("human_facing_cli_help_when_describing_sf_surfaces_uses_direct_commands", () => {
|
||||
const sourceFiles = ["src/help-text.ts", "src/cli.ts", "src/loader.ts"];
|
||||
for (const sourceFile of sourceFiles) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, test } from "vitest";
|
||||
|
||||
import {
|
||||
getParallelWorkerDbPath,
|
||||
queryParallelRecentCompletionRows,
|
||||
queryParallelSliceProgress,
|
||||
} from "../parallel-monitor-store.js";
|
||||
|
||||
let project;
|
||||
|
||||
function createWorkerDb(milestoneId) {
|
||||
const dbPath = getParallelWorkerDbPath(project, milestoneId);
|
||||
mkdirSync(join(project, ".sf", "worktrees", milestoneId, ".sf"), {
|
||||
recursive: true,
|
||||
});
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE slices (
|
||||
milestone_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE tasks (
|
||||
milestone_id TEXT NOT NULL,
|
||||
slice_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
task_status TEXT NOT NULL,
|
||||
one_liner TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
project = mkdtempSync(join(tmpdir(), "sf-parallel-monitor-store-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("queryParallelSliceProgress_when_worker_db_missing_returns_empty_projection", () => {
|
||||
assert.deepEqual(queryParallelSliceProgress(project, "M404"), []);
|
||||
});
|
||||
|
||||
test("queryParallelSliceProgress_reads_task_progress_from_worker_db", () => {
|
||||
const db = createWorkerDb("M001");
|
||||
try {
|
||||
db.prepare(
|
||||
"INSERT INTO slices (milestone_id, id, status) VALUES (?, ?, ?)",
|
||||
).run("M001", "S01", "running");
|
||||
db.prepare(
|
||||
"INSERT INTO slices (milestone_id, id, status) VALUES (?, ?, ?)",
|
||||
).run("M001", "S02", "complete");
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run("M001", "S01", "T01", "done", "done", "2026-05-08T01:00:00.000Z");
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run("M001", "S01", "T02", "todo", "todo", null);
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run("M001", "S02", "T03", "done", "done", "2026-05-08T02:00:00.000Z");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
assert.deepEqual(queryParallelSliceProgress(project, "M001"), [
|
||||
{ id: "S01", status: "running", total: 2, done: 1 },
|
||||
{ id: "S02", status: "complete", total: 1, done: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("queryParallelRecentCompletionRows_returns_latest_completed_tasks", () => {
|
||||
const db = createWorkerDb("M002");
|
||||
try {
|
||||
db.prepare(
|
||||
"INSERT INTO slices (milestone_id, id, status) VALUES (?, ?, ?)",
|
||||
).run("M002", "S01", "running");
|
||||
for (let i = 0; i < 6; i++) {
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(
|
||||
"M002",
|
||||
"S01",
|
||||
`T0${i}`,
|
||||
"done",
|
||||
`task ${i}`,
|
||||
`2026-05-08T0${i}:00:00.000Z`,
|
||||
);
|
||||
}
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run("M002", "S01", "T99", "todo", "not done", null);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const rows = queryParallelRecentCompletionRows(project, "M002", 3);
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.taskId),
|
||||
["T05", "T04", "T03"],
|
||||
);
|
||||
assert.deepEqual(rows[0], {
|
||||
taskId: "T05",
|
||||
sliceId: "S01",
|
||||
oneLiner: "task 5",
|
||||
completedAt: "2026-05-08T05:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import { afterEach, test } from "vitest";
|
|||
import {
|
||||
closeDatabase,
|
||||
getSliceAuditTrail,
|
||||
getTask,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
openDatabase,
|
||||
|
|
@ -78,6 +79,11 @@ test("handlePlanSlice_when_successful_records_plan_slice_evidence", async () =>
|
|||
verify: "npm test -- plan-slice",
|
||||
inputs: ["DB-backed slice"],
|
||||
expectedOutput: ["Evidence row"],
|
||||
risk: "high",
|
||||
mutationScope: "cross-cutting",
|
||||
verification: "integration",
|
||||
planApproval: "pending",
|
||||
blocksParallel: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -90,4 +96,10 @@ test("handlePlanSlice_when_successful_records_plan_slice_evidence", async () =>
|
|||
assert.equal(trail[0].evidence_type, "plan_slice");
|
||||
assert.match(trail[0].content, /Plan with evidence/);
|
||||
assert.match(trail[0].content, /Implement evidence/);
|
||||
const task = getTask("M001", "S01", "T01");
|
||||
assert.equal(task.frontmatter.risk, "high");
|
||||
assert.equal(task.frontmatter.mutationScope, "cross-cutting");
|
||||
assert.equal(task.frontmatter.verification, "integration");
|
||||
assert.equal(task.frontmatter.planApproval, "pending");
|
||||
assert.equal(task.frontmatter.blocksParallel, true);
|
||||
});
|
||||
|
|
|
|||
151
src/resources/extensions/sf/tests/remote-steering.test.mjs
Normal file
151
src/resources/extensions/sf/tests/remote-steering.test.mjs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Tests for remote steering surface.
|
||||
*/
|
||||
import assert from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
applyRemoteSteeringDirectives,
|
||||
formatRemoteSteeringResults,
|
||||
parseRemoteSteeringDirectives,
|
||||
} from "../remote-steering.js";
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
const testParseNoDirectives = () => {
|
||||
const result = parseRemoteSteeringDirectives({ answers: ["yes", "no"] });
|
||||
assert.strictEqual(result.steering, false);
|
||||
assert.strictEqual(result.directives.length, 0);
|
||||
};
|
||||
|
||||
const testParseModeDirective = () => {
|
||||
const result = parseRemoteSteeringDirectives({
|
||||
answers: ["/mode build", "proceed"],
|
||||
});
|
||||
assert.strictEqual(result.steering, true);
|
||||
assert.strictEqual(result.directives.length, 1);
|
||||
assert.strictEqual(result.directives[0].cmd, "mode");
|
||||
assert.strictEqual(result.directives[0].value, "build");
|
||||
};
|
||||
|
||||
const testParseMultipleDirectives = () => {
|
||||
const result = parseRemoteSteeringDirectives({
|
||||
text: "/mode review /permission-profile trusted /model-mode deep",
|
||||
});
|
||||
assert.strictEqual(result.steering, true);
|
||||
assert.strictEqual(result.directives.length, 3);
|
||||
assert.deepStrictEqual(result.directives[0], {
|
||||
cmd: "mode",
|
||||
value: "review",
|
||||
});
|
||||
assert.deepStrictEqual(result.directives[1], {
|
||||
cmd: "permission-profile",
|
||||
value: "trusted",
|
||||
});
|
||||
assert.deepStrictEqual(result.directives[2], {
|
||||
cmd: "model-mode",
|
||||
value: "deep",
|
||||
});
|
||||
};
|
||||
|
||||
const testParseStringAndFullAxisAliases = () => {
|
||||
const result = parseRemoteSteeringDirectives(
|
||||
"/work-mode repair /run-control autonomous /permission-profile trusted",
|
||||
);
|
||||
assert.strictEqual(result.steering, true);
|
||||
assert.deepStrictEqual(result.directives, [
|
||||
{ cmd: "mode", value: "repair" },
|
||||
{ cmd: "control", value: "autonomous" },
|
||||
{ cmd: "permission-profile", value: "trusted" },
|
||||
]);
|
||||
};
|
||||
|
||||
const testParseRoundResultNotes = () => {
|
||||
const result = parseRemoteSteeringDirectives({
|
||||
answers: {
|
||||
mode_note: {
|
||||
selected: "Other",
|
||||
notes: "/mode build /permission-profile normal",
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.steering, true);
|
||||
assert.deepStrictEqual(result.directives, [
|
||||
{ cmd: "mode", value: "build" },
|
||||
{ cmd: "permission-profile", value: "normal" },
|
||||
]);
|
||||
};
|
||||
|
||||
const testParseInvalidDirectiveIgnored = () => {
|
||||
const result = parseRemoteSteeringDirectives({
|
||||
text: "/mode invalidmode /control autonomous",
|
||||
});
|
||||
assert.strictEqual(result.steering, true);
|
||||
assert.strictEqual(result.directives.length, 1);
|
||||
assert.strictEqual(result.directives[0].cmd, "control");
|
||||
};
|
||||
|
||||
const testApplyDirectives = () => {
|
||||
// This will fail if getAutoSession returns null (no session)
|
||||
// In test environment without a full session, we just verify it doesn't throw
|
||||
try {
|
||||
const results = applyRemoteSteeringDirectives([
|
||||
{ cmd: "mode", value: "build" },
|
||||
]);
|
||||
// If session exists, should apply
|
||||
assert.ok(Array.isArray(results));
|
||||
} catch (err) {
|
||||
// Expected in test environment without session
|
||||
assert.ok(
|
||||
err.message.includes("getAutoSession") || err.message.includes("null"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const testFormatResults = () => {
|
||||
const results = [
|
||||
{ cmd: "mode", value: "build", applied: true },
|
||||
{
|
||||
cmd: "permission-profile",
|
||||
value: "trusted",
|
||||
applied: false,
|
||||
error: "test error",
|
||||
},
|
||||
];
|
||||
const formatted = formatRemoteSteeringResults(results);
|
||||
assert.ok(formatted.includes("SF Mode Steering"));
|
||||
assert.ok(formatted.includes("[ok] /mode build"));
|
||||
assert.ok(formatted.includes("[blocked] /permission-profile trusted"));
|
||||
};
|
||||
|
||||
test(
|
||||
"parseRemoteSteeringDirectives_without_directives_returns_false",
|
||||
testParseNoDirectives,
|
||||
);
|
||||
test(
|
||||
"parseRemoteSteeringDirectives_accepts_mode_directive",
|
||||
testParseModeDirective,
|
||||
);
|
||||
test(
|
||||
"parseRemoteSteeringDirectives_accepts_multiple_axes",
|
||||
testParseMultipleDirectives,
|
||||
);
|
||||
test(
|
||||
"parseRemoteSteeringDirectives_accepts_string_and_full_axis_aliases",
|
||||
testParseStringAndFullAxisAliases,
|
||||
);
|
||||
test(
|
||||
"parseRemoteSteeringDirectives_accepts_round_result_notes",
|
||||
testParseRoundResultNotes,
|
||||
);
|
||||
test(
|
||||
"parseRemoteSteeringDirectives_ignores_invalid_values",
|
||||
testParseInvalidDirectiveIgnored,
|
||||
);
|
||||
test(
|
||||
"applyRemoteSteeringDirectives_returns_results_or_session_error",
|
||||
testApplyDirectives,
|
||||
);
|
||||
test(
|
||||
"formatRemoteSteeringResults_renders_current_mode_summary",
|
||||
testFormatResults,
|
||||
);
|
||||
|
|
@ -217,7 +217,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
|
|||
const version = db
|
||||
.prepare("SELECT MAX(version) AS version FROM schema_version")
|
||||
.get();
|
||||
assert.equal(version.version, 43);
|
||||
assert.equal(version.version, 45);
|
||||
const taskSpec = db
|
||||
.prepare(
|
||||
"SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",
|
||||
|
|
@ -229,6 +229,12 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
|
|||
task_id: "T01",
|
||||
verify: "go test ./portal",
|
||||
});
|
||||
const schedulerRow = db
|
||||
.prepare(
|
||||
"SELECT status FROM task_scheduler WHERE milestone_id = 'M010' AND slice_id = 'S03' AND task_id = 'T01'",
|
||||
)
|
||||
.get();
|
||||
assert.deepEqual(schedulerRow, { status: "queued" });
|
||||
});
|
||||
|
||||
test("openDatabase_when_fresh_db_supports_schedule_entries", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* sf-db-task-frontmatter.test.mjs - DB task metadata integration contracts.
|
||||
*
|
||||
* Purpose: prove the SQLite task and task_specs tables persist normalized
|
||||
* task metadata and expose it through getTask without a sidecar state store.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
closeDatabase,
|
||||
getTask,
|
||||
getTaskSpec,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
insertTask,
|
||||
openDatabase,
|
||||
updateTaskStatus,
|
||||
upsertTaskPlanning,
|
||||
} from "../sf-db.js";
|
||||
|
||||
test("sfDb_task_frontmatter_round_trips_through_task_and_spec_rows", () => {
|
||||
closeDatabase();
|
||||
assert.equal(openDatabase(":memory:"), true);
|
||||
try {
|
||||
insertMilestone({ id: "M001", title: "Milestone", status: "active" });
|
||||
insertSlice({
|
||||
milestoneId: "M001",
|
||||
id: "S01",
|
||||
title: "Slice",
|
||||
status: "pending",
|
||||
});
|
||||
insertTask({
|
||||
milestoneId: "M001",
|
||||
sliceId: "S01",
|
||||
id: "T01",
|
||||
title: "Task",
|
||||
status: "pending",
|
||||
});
|
||||
upsertTaskPlanning("M001", "S01", "T01", {
|
||||
title: "Task",
|
||||
description: "Purpose: prove metadata persistence.",
|
||||
estimate: "45m",
|
||||
files: ["src/planned.ts"],
|
||||
verify: "npm test",
|
||||
inputs: ["src/input.ts"],
|
||||
expectedOutput: ["src/output.ts"],
|
||||
risk: "high",
|
||||
mutationScope: "systemic",
|
||||
verification: "integration",
|
||||
planApproval: "pending",
|
||||
schedulerStatus: "dispatched",
|
||||
estimatedEffort: 45,
|
||||
dependencies: ["T00"],
|
||||
blocksParallel: true,
|
||||
requiresUserInput: true,
|
||||
autoRetry: false,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
const task = getTask("M001", "S01", "T01");
|
||||
assert.equal(task.frontmatter.risk, "high");
|
||||
assert.equal(task.frontmatter.mutationScope, "systemic");
|
||||
assert.equal(task.frontmatter.verification, "integration");
|
||||
assert.equal(task.frontmatter.planApproval, "pending");
|
||||
assert.equal(task.frontmatter.schedulerStatus, "dispatched");
|
||||
assert.equal(task.frontmatter.estimatedEffort, 45);
|
||||
assert.deepEqual(task.frontmatter.dependencies, ["T00"]);
|
||||
assert.equal(task.frontmatter.blocksParallel, true);
|
||||
assert.equal(task.frontmatter.autoRetry, false);
|
||||
assert.deepEqual(task.frontmatter.keyFiles, ["src/planned.ts"]);
|
||||
|
||||
const spec = getTaskSpec("M001", "S01", "T01");
|
||||
assert.equal(spec.risk, "high");
|
||||
assert.equal(spec.mutation_scope, "systemic");
|
||||
assert.equal(spec.verification_type, "integration");
|
||||
assert.equal(spec.plan_approval, "pending");
|
||||
assert.equal(spec.estimated_effort, 45);
|
||||
assert.equal(spec.dependencies, JSON.stringify(["T00"]));
|
||||
assert.equal(spec.blocks_parallel, 1);
|
||||
assert.equal(spec.requires_user_input, 1);
|
||||
assert.equal(spec.auto_retry, 0);
|
||||
assert.equal(spec.max_retries, 0);
|
||||
assert.equal("scheduler_status" in spec, false);
|
||||
|
||||
updateTaskStatus("M001", "S01", "T01", "done", "2026-05-08T00:00:00.000Z");
|
||||
const completed = getTask("M001", "S01", "T01");
|
||||
assert.equal(completed.frontmatter.taskStatus, "done");
|
||||
assert.equal(completed.frontmatter.schedulerStatus, "dispatched");
|
||||
} finally {
|
||||
closeDatabase();
|
||||
}
|
||||
});
|
||||
|
|
@ -163,3 +163,53 @@ test("specTables_when_shell_milestone_created_before_planning_captures_first_rea
|
|||
"Capture first real product research.",
|
||||
);
|
||||
});
|
||||
|
||||
test("specTables_when_shell_task_created_before_planning_captures_first_real_plan", () => {
|
||||
openDatabase(":memory:");
|
||||
|
||||
insertMilestone({
|
||||
id: "M003",
|
||||
title: "Task shell milestone",
|
||||
status: "active",
|
||||
});
|
||||
insertSlice({
|
||||
milestoneId: "M003",
|
||||
id: "S01",
|
||||
title: "Task shell slice",
|
||||
status: "pending",
|
||||
});
|
||||
insertTask({
|
||||
milestoneId: "M003",
|
||||
sliceId: "S01",
|
||||
id: "T01",
|
||||
title: "Shell task",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
assert.equal(getTaskSpec("M003", "S01", "T01"), undefined);
|
||||
|
||||
upsertTaskPlanning("M003", "S01", "T01", {
|
||||
verify: "npm test -- task-shell",
|
||||
inputs: ["src/task-input.ts"],
|
||||
expectedOutput: ["src/task-output.ts"],
|
||||
risk: "high",
|
||||
mutationScope: "bounded",
|
||||
});
|
||||
upsertTaskPlanning("M003", "S01", "T01", {
|
||||
verify: "npm test -- changed",
|
||||
inputs: ["src/changed-input.ts"],
|
||||
expectedOutput: ["src/changed-output.ts"],
|
||||
risk: "low",
|
||||
mutationScope: "isolated",
|
||||
});
|
||||
|
||||
const taskSpec = getTaskSpec("M003", "S01", "T01");
|
||||
assert.equal(taskSpec.verify, "npm test -- task-shell");
|
||||
assert.deepEqual(JSON.parse(taskSpec.inputs), ["src/task-input.ts"]);
|
||||
assert.deepEqual(JSON.parse(taskSpec.expected_output), [
|
||||
"src/task-output.ts",
|
||||
]);
|
||||
assert.equal(taskSpec.risk, "high");
|
||||
assert.equal(taskSpec.mutation_scope, "bounded");
|
||||
assert.equal(taskSpec.spec_version, 1);
|
||||
});
|
||||
|
|
|
|||
183
src/resources/extensions/sf/tests/subagent-inheritance.test.mjs
Normal file
183
src/resources/extensions/sf/tests/subagent-inheritance.test.mjs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Tests for subagent inheritance audit and enforcement.
|
||||
*/
|
||||
import assert from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
applyInheritanceToEnv,
|
||||
buildSubagentInheritanceEnvelope,
|
||||
readParentInheritanceFromEnv,
|
||||
validateSubagentDispatch,
|
||||
} from "../subagent-inheritance.js";
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
const testBuildEnvelopeDefaults = () => {
|
||||
// Without a session, should still return defaults
|
||||
const envelope = buildSubagentInheritanceEnvelope({});
|
||||
assert.ok(envelope.workMode);
|
||||
assert.ok(envelope.modelMode);
|
||||
assert.ok(envelope.permissionProfile);
|
||||
assert.ok(envelope.runControl);
|
||||
assert.ok(envelope.surface);
|
||||
};
|
||||
|
||||
const testBuildEnvelopeWithMode = () => {
|
||||
const envelope = buildSubagentInheritanceEnvelope({
|
||||
mode: {
|
||||
workMode: "build",
|
||||
modelMode: "fast",
|
||||
permissionProfile: "restricted",
|
||||
runControl: "assisted",
|
||||
surface: "headless",
|
||||
},
|
||||
});
|
||||
assert.strictEqual(envelope.workMode, "build");
|
||||
assert.strictEqual(envelope.modelMode, "fast");
|
||||
assert.strictEqual(envelope.permissionProfile, "restricted");
|
||||
assert.strictEqual(envelope.runControl, "assisted");
|
||||
assert.strictEqual(envelope.surface, "headless");
|
||||
};
|
||||
|
||||
const testValidateAllowedProvider = () => {
|
||||
const envelope = {
|
||||
workMode: "build",
|
||||
modelMode: "smart",
|
||||
permissionProfile: "normal",
|
||||
runControl: "assisted",
|
||||
surface: "tui",
|
||||
allowedProviders: ["anthropic"],
|
||||
blockedProviders: null,
|
||||
};
|
||||
const result = validateSubagentDispatch(envelope, {
|
||||
model: "anthropic/claude-sonnet",
|
||||
provider: "anthropic",
|
||||
tools: ["read"],
|
||||
});
|
||||
assert.strictEqual(result.ok, true);
|
||||
};
|
||||
|
||||
const testValidateBlockedProvider = () => {
|
||||
const envelope = {
|
||||
workMode: "build",
|
||||
modelMode: "smart",
|
||||
permissionProfile: "normal",
|
||||
runControl: "assisted",
|
||||
surface: "tui",
|
||||
allowedProviders: null,
|
||||
blockedProviders: ["openai"],
|
||||
};
|
||||
const result = validateSubagentDispatch(envelope, {
|
||||
model: "openai/gpt-4",
|
||||
provider: "openai",
|
||||
tools: ["read"],
|
||||
});
|
||||
assert.strictEqual(result.ok, false);
|
||||
assert.ok(result.reason.includes("blocked"));
|
||||
};
|
||||
|
||||
const testValidateFastBlocksHeavy = () => {
|
||||
const envelope = {
|
||||
workMode: "build",
|
||||
modelMode: "fast",
|
||||
permissionProfile: "trusted",
|
||||
runControl: "assisted",
|
||||
surface: "tui",
|
||||
allowedProviders: null,
|
||||
blockedProviders: null,
|
||||
};
|
||||
const result = validateSubagentDispatch(envelope, {
|
||||
model: "claude-3-opus",
|
||||
provider: "anthropic",
|
||||
tools: ["read"],
|
||||
});
|
||||
assert.strictEqual(result.ok, false);
|
||||
assert.ok(result.reason.includes("fast"));
|
||||
};
|
||||
|
||||
const testValidateRestrictedBlocksDestructive = () => {
|
||||
const envelope = {
|
||||
workMode: "build",
|
||||
modelMode: "smart",
|
||||
permissionProfile: "restricted",
|
||||
runControl: "assisted",
|
||||
surface: "tui",
|
||||
allowedProviders: null,
|
||||
blockedProviders: null,
|
||||
};
|
||||
const result = validateSubagentDispatch(envelope, {
|
||||
model: "claude-sonnet",
|
||||
provider: "anthropic",
|
||||
tools: ["write", "read"],
|
||||
});
|
||||
assert.strictEqual(result.ok, false);
|
||||
assert.ok(result.reason.includes("write"));
|
||||
};
|
||||
|
||||
const testApplyInheritanceToEnv = () => {
|
||||
const envelope = {
|
||||
workMode: "review",
|
||||
modelMode: "deep",
|
||||
permissionProfile: "trusted",
|
||||
runControl: "autonomous",
|
||||
surface: "headless",
|
||||
};
|
||||
const env = applyInheritanceToEnv(envelope, {});
|
||||
assert.strictEqual(env.SF_PARENT_WORK_MODE, "review");
|
||||
assert.strictEqual(env.SF_PARENT_MODEL_MODE, "deep");
|
||||
assert.strictEqual(env.SF_PARENT_PERMISSION_PROFILE, "trusted");
|
||||
assert.strictEqual(env.SF_PARENT_RUN_CONTROL, "autonomous");
|
||||
assert.strictEqual(env.SF_PARENT_SURFACE, "headless");
|
||||
};
|
||||
|
||||
const testReadParentFromEnv = () => {
|
||||
const env = {
|
||||
SF_PARENT_WORK_MODE: "repair",
|
||||
SF_PARENT_MODEL_MODE: "smart",
|
||||
SF_PARENT_PERMISSION_PROFILE: "normal",
|
||||
SF_PARENT_RUN_CONTROL: "assisted",
|
||||
SF_PARENT_SURFACE: "tui",
|
||||
};
|
||||
const result = readParentInheritanceFromEnv(env);
|
||||
assert.strictEqual(result.workMode, "repair");
|
||||
assert.strictEqual(result.modelMode, "smart");
|
||||
assert.strictEqual(result.permissionProfile, "normal");
|
||||
assert.strictEqual(result.runControl, "assisted");
|
||||
assert.strictEqual(result.surface, "tui");
|
||||
};
|
||||
|
||||
const testReadParentFromEnvMissing = () => {
|
||||
const result = readParentInheritanceFromEnv({});
|
||||
assert.strictEqual(result, null);
|
||||
};
|
||||
|
||||
test(
|
||||
"buildSubagentInheritanceEnvelope_defaults_are_resolved",
|
||||
testBuildEnvelopeDefaults,
|
||||
);
|
||||
test(
|
||||
"buildSubagentInheritanceEnvelope_uses_explicit_mode_axes",
|
||||
testBuildEnvelopeWithMode,
|
||||
);
|
||||
test(
|
||||
"validateSubagentDispatch_allows_allowed_provider",
|
||||
testValidateAllowedProvider,
|
||||
);
|
||||
test(
|
||||
"validateSubagentDispatch_blocks_blocked_provider",
|
||||
testValidateBlockedProvider,
|
||||
);
|
||||
test(
|
||||
"validateSubagentDispatch_fast_blocks_heavy_model",
|
||||
testValidateFastBlocksHeavy,
|
||||
);
|
||||
test(
|
||||
"validateSubagentDispatch_restricted_blocks_destructive_tools",
|
||||
testValidateRestrictedBlocksDestructive,
|
||||
);
|
||||
test("applyInheritanceToEnv_writes_parent_axes", testApplyInheritanceToEnv);
|
||||
test("readParentInheritanceFromEnv_reads_parent_axes", testReadParentFromEnv);
|
||||
test(
|
||||
"readParentInheritanceFromEnv_returns_null_without_parent",
|
||||
testReadParentFromEnvMissing,
|
||||
);
|
||||
144
src/resources/extensions/sf/tests/task-frontmatter.test.mjs
Normal file
144
src/resources/extensions/sf/tests/task-frontmatter.test.mjs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* task-frontmatter.test.mjs — Task metadata contract tests.
|
||||
*
|
||||
* Purpose: verify the DB-backed task frontmatter helper keeps task lifecycle
|
||||
* status separate from scheduler lifecycle status.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
buildTaskRecord,
|
||||
canRunInParallel,
|
||||
computeTaskPriority,
|
||||
DEFAULT_TASK_FRONTMATTER,
|
||||
normalizeSchedulerStatus,
|
||||
normalizeTaskStatus,
|
||||
validateTaskFrontmatter,
|
||||
} from "../task-frontmatter.js";
|
||||
|
||||
test("validateTaskFrontmatter_defaults_are_canonical", () => {
|
||||
const result = validateTaskFrontmatter({});
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.equal(result.normalized.taskStatus, "todo");
|
||||
assert.equal(result.normalized.schedulerStatus, "queued");
|
||||
});
|
||||
|
||||
test("validateTaskFrontmatter_valid_fields_normalize", () => {
|
||||
const result = validateTaskFrontmatter({
|
||||
risk: "high",
|
||||
mutationScope: "cross-cutting",
|
||||
verification: "test",
|
||||
planApproval: "approved",
|
||||
taskStatus: "verifying",
|
||||
schedulerStatus: "dispatched",
|
||||
estimatedEffort: 120,
|
||||
keyFiles: ["src/foo.ts", "src/bar.ts"],
|
||||
dependencies: ["T01"],
|
||||
blocksParallel: true,
|
||||
maxRetries: 3,
|
||||
});
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.normalized.taskStatus, "verifying");
|
||||
assert.equal(result.normalized.schedulerStatus, "dispatched");
|
||||
assert.deepEqual(result.normalized.keyFiles, ["src/foo.ts", "src/bar.ts"]);
|
||||
assert.equal(result.normalized.maxRetries, 3);
|
||||
});
|
||||
|
||||
test("validateTaskFrontmatter_rejects_invalid_choices", () => {
|
||||
assert.equal(validateTaskFrontmatter({ risk: "extreme" }).valid, false);
|
||||
assert.equal(
|
||||
validateTaskFrontmatter({ mutationScope: "everything" }).valid,
|
||||
false,
|
||||
);
|
||||
assert.equal(validateTaskFrontmatter({ taskStatus: "queued" }).valid, false);
|
||||
assert.equal(
|
||||
validateTaskFrontmatter({ schedulerStatus: "todo" }).valid,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("task_and_scheduler_status_normalizers_keep_lifecycles_separate", () => {
|
||||
assert.equal(normalizeTaskStatus("in_progress"), "running");
|
||||
assert.equal(normalizeTaskStatus("manual-attention"), "reviewing");
|
||||
assert.equal(normalizeTaskStatus("completed"), "done");
|
||||
assert.equal(normalizeSchedulerStatus("pending"), "queued");
|
||||
assert.equal(normalizeSchedulerStatus("done"), "consumed");
|
||||
});
|
||||
|
||||
test("buildTaskRecord_attaches_frontmatter", () => {
|
||||
const record = buildTaskRecord({
|
||||
id: "T01",
|
||||
title: "Test task",
|
||||
risk: "medium",
|
||||
mutation_scope: "bounded",
|
||||
status: "in_progress",
|
||||
});
|
||||
assert.equal(record.frontmatter.risk, "medium");
|
||||
assert.equal(record.frontmatter.mutationScope, "bounded");
|
||||
assert.equal(record.frontmatter.taskStatus, "running");
|
||||
assert.equal(record.frontmatterValid, true);
|
||||
});
|
||||
|
||||
test("canRunInParallel_low_risk_disjoint_files_passes", () => {
|
||||
const taskA = {
|
||||
frontmatter: {
|
||||
...DEFAULT_TASK_FRONTMATTER,
|
||||
risk: "low",
|
||||
keyFiles: ["src/a.ts"],
|
||||
},
|
||||
};
|
||||
const taskB = {
|
||||
frontmatter: {
|
||||
...DEFAULT_TASK_FRONTMATTER,
|
||||
risk: "low",
|
||||
keyFiles: ["src/b.ts"],
|
||||
},
|
||||
};
|
||||
assert.equal(canRunInParallel(taskA, taskB).canParallel, true);
|
||||
});
|
||||
|
||||
test("canRunInParallel_file_overlap_blocks", () => {
|
||||
const taskA = {
|
||||
frontmatter: { ...DEFAULT_TASK_FRONTMATTER, keyFiles: ["src/shared.ts"] },
|
||||
};
|
||||
const taskB = {
|
||||
frontmatter: { ...DEFAULT_TASK_FRONTMATTER, keyFiles: ["src/shared.ts"] },
|
||||
};
|
||||
const result = canRunInParallel(taskA, taskB);
|
||||
assert.equal(result.canParallel, false);
|
||||
assert.match(result.reason, /overlap/i);
|
||||
});
|
||||
|
||||
test("canRunInParallel_blocks_parallel_and_high_risk_pairs_block", () => {
|
||||
assert.equal(
|
||||
canRunInParallel(
|
||||
{ frontmatter: { ...DEFAULT_TASK_FRONTMATTER, blocksParallel: true } },
|
||||
{ frontmatter: { ...DEFAULT_TASK_FRONTMATTER } },
|
||||
).canParallel,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canRunInParallel(
|
||||
{ frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "high" } },
|
||||
{ frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "high" } },
|
||||
).canParallel,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("computeTaskPriority_prioritizes_critical_and_systemic_work", () => {
|
||||
const lowRisk = { frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "low" } };
|
||||
const critical = {
|
||||
frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "critical" },
|
||||
};
|
||||
const isolated = {
|
||||
frontmatter: { ...DEFAULT_TASK_FRONTMATTER, mutationScope: "isolated" },
|
||||
};
|
||||
const systemic = {
|
||||
frontmatter: { ...DEFAULT_TASK_FRONTMATTER, mutationScope: "systemic" },
|
||||
};
|
||||
assert.ok(computeTaskPriority(critical) > computeTaskPriority(lowRisk));
|
||||
assert.ok(computeTaskPriority(systemic) > computeTaskPriority(isolated));
|
||||
});
|
||||
|
|
@ -220,7 +220,7 @@ test("queryTasksByState_filters_by_state", () => {
|
|||
id: "n3",
|
||||
kind: "unit",
|
||||
unitId: "U3",
|
||||
state: "in_progress",
|
||||
state: "running",
|
||||
});
|
||||
|
||||
const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] });
|
||||
|
|
@ -230,6 +230,14 @@ test("queryTasksByState_filters_by_state", () => {
|
|||
const done = queryTasksByState(db, { graphId: "g1", states: ["done"] });
|
||||
assert.equal(done.length, 1);
|
||||
assert.equal(done[0].id, "n2");
|
||||
|
||||
const running = queryTasksByState(db, {
|
||||
graphId: "g1",
|
||||
states: ["in_progress"],
|
||||
});
|
||||
assert.equal(running.length, 1);
|
||||
assert.equal(running[0].id, "n3");
|
||||
assert.equal(running[0].status, "running");
|
||||
});
|
||||
|
||||
test("queryTasksByState_filters_by_milestone", () => {
|
||||
|
|
@ -313,7 +321,7 @@ test("getGraphStateSummary_computes_counts_and_progress", () => {
|
|||
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.running, 1);
|
||||
assert.equal(summary.counts.done, 2);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
buildTaskRecord,
|
||||
canTransitionTaskState,
|
||||
gateOutcomesToTaskState,
|
||||
normalizeTaskState,
|
||||
TASK_STATES,
|
||||
TASK_TERMINAL_STATES,
|
||||
unitRuntimeToTaskState,
|
||||
|
|
@ -22,9 +23,12 @@ import {
|
|||
test("TASK_STATES_has_all_orch_states", () => {
|
||||
assert.deepEqual(TASK_STATES, [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"review",
|
||||
"running",
|
||||
"verifying",
|
||||
"reviewing",
|
||||
"done",
|
||||
"blocked",
|
||||
"paused",
|
||||
"retrying",
|
||||
"failed",
|
||||
"cancelled",
|
||||
|
|
@ -36,7 +40,8 @@ test("TASK_TERMINAL_STATES_includes_done_failed_cancelled", () => {
|
|||
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);
|
||||
assert.equal(TASK_TERMINAL_STATES.has("running"), false);
|
||||
assert.equal(TASK_TERMINAL_STATES.has("blocked"), false);
|
||||
});
|
||||
|
||||
// ─── Gate outcomes → task state ────────────────────────────────────────────
|
||||
|
|
@ -63,12 +68,12 @@ test("gateOutcomesToTaskState_any_fail_returns_failed", () => {
|
|||
assert.equal(gateOutcomesToTaskState(results), "failed");
|
||||
});
|
||||
|
||||
test("gateOutcomesToTaskState_any_manual_attention_returns_review", () => {
|
||||
test("gateOutcomesToTaskState_any_manual_attention_returns_reviewing", () => {
|
||||
const results = [
|
||||
{ outcome: "pass", gateId: "security" },
|
||||
{ outcome: "manual-attention", gateId: "verification" },
|
||||
];
|
||||
assert.equal(gateOutcomesToTaskState(results), "review");
|
||||
assert.equal(gateOutcomesToTaskState(results), "reviewing");
|
||||
});
|
||||
|
||||
test("gateOutcomesToTaskState_any_retry_returns_retrying", () => {
|
||||
|
|
@ -79,7 +84,15 @@ test("gateOutcomesToTaskState_any_retry_returns_retrying", () => {
|
|||
assert.equal(gateOutcomesToTaskState(results), "retrying");
|
||||
});
|
||||
|
||||
test("gateOutcomesToTaskState_mixed_nonterminal_returns_in_progress", () => {
|
||||
test("gateOutcomesToTaskState_any_blocked_returns_blocked", () => {
|
||||
const results = [
|
||||
{ outcome: "pass", gateId: "security" },
|
||||
{ outcome: "blocked", gateId: "verification" },
|
||||
];
|
||||
assert.equal(gateOutcomesToTaskState(results), "blocked");
|
||||
});
|
||||
|
||||
test("gateOutcomesToTaskState_mixed_nonterminal_returns_running", () => {
|
||||
const results = [
|
||||
{ outcome: "pass", gateId: "security" },
|
||||
{ outcome: "pass", gateId: "cost" },
|
||||
|
|
@ -103,12 +116,16 @@ 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_running_returns_running", () => {
|
||||
assert.equal(unitRuntimeToTaskState({ status: "running" }), "running");
|
||||
});
|
||||
|
||||
test("unitRuntimeToTaskState_progress_returns_in_progress", () => {
|
||||
assert.equal(unitRuntimeToTaskState({ status: "progress" }), "in_progress");
|
||||
test("unitRuntimeToTaskState_progress_returns_running", () => {
|
||||
assert.equal(unitRuntimeToTaskState({ status: "progress" }), "running");
|
||||
});
|
||||
|
||||
test("unitRuntimeToTaskState_validating_returns_verifying", () => {
|
||||
assert.equal(unitRuntimeToTaskState({ status: "validating" }), "verifying");
|
||||
});
|
||||
|
||||
test("unitRuntimeToTaskState_completed_returns_done", () => {
|
||||
|
|
@ -119,8 +136,12 @@ test("unitRuntimeToTaskState_failed_returns_failed", () => {
|
|||
assert.equal(unitRuntimeToTaskState({ status: "failed" }), "failed");
|
||||
});
|
||||
|
||||
test("unitRuntimeToTaskState_blocked_returns_review", () => {
|
||||
assert.equal(unitRuntimeToTaskState({ status: "blocked" }), "review");
|
||||
test("unitRuntimeToTaskState_blocked_returns_blocked", () => {
|
||||
assert.equal(unitRuntimeToTaskState({ status: "blocked" }), "blocked");
|
||||
});
|
||||
|
||||
test("unitRuntimeToTaskState_review_returns_reviewing", () => {
|
||||
assert.equal(unitRuntimeToTaskState({ status: "review" }), "reviewing");
|
||||
});
|
||||
|
||||
test("unitRuntimeToTaskState_cancelled_returns_cancelled", () => {
|
||||
|
|
@ -141,14 +162,16 @@ test("unitRuntimeToTaskState_runaway_recovered_returns_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("todo", "running"), true);
|
||||
assert.equal(canTransitionTaskState("running", "verifying"), true);
|
||||
assert.equal(canTransitionTaskState("verifying", "reviewing"), true);
|
||||
assert.equal(canTransitionTaskState("running", "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("done", "running"), false);
|
||||
assert.equal(canTransitionTaskState("cancelled", "todo"), false);
|
||||
});
|
||||
|
||||
|
|
@ -170,20 +193,29 @@ test("aggregateTaskStates_counts_correctly", () => {
|
|||
const agg = aggregateTaskStates([
|
||||
"todo",
|
||||
"in_progress",
|
||||
"review",
|
||||
"done",
|
||||
"done",
|
||||
"failed",
|
||||
]);
|
||||
assert.equal(agg.total, 5);
|
||||
assert.equal(agg.total, 6);
|
||||
assert.equal(agg.terminal, 3); // done(2) + failed(1)
|
||||
assert.equal(agg.progress, 60);
|
||||
assert.equal(agg.progress, 50);
|
||||
assert.equal(agg.isComplete, false);
|
||||
assert.equal(agg.counts.todo, 1);
|
||||
assert.equal(agg.counts.in_progress, 1);
|
||||
assert.equal(agg.counts.running, 1);
|
||||
assert.equal(agg.counts.reviewing, 1);
|
||||
assert.equal(agg.counts.done, 2);
|
||||
assert.equal(agg.counts.failed, 1);
|
||||
});
|
||||
|
||||
test("normalizeTaskState_maps_source_labels_to_canonical_names", () => {
|
||||
assert.equal(normalizeTaskState("in_progress"), "running");
|
||||
assert.equal(normalizeTaskState("review"), "reviewing");
|
||||
assert.equal(normalizeTaskState("queued"), "todo");
|
||||
assert.equal(normalizeTaskState("completed"), "done");
|
||||
});
|
||||
|
||||
test("aggregateTaskStates_complete_when_all_terminal", () => {
|
||||
const agg = aggregateTaskStates(["done", "done", "cancelled"]);
|
||||
assert.equal(agg.progress, 100);
|
||||
|
|
@ -220,7 +252,7 @@ test("buildTaskRecord_with_runtime_uses_runtime_state", () => {
|
|||
runtimeRecord: { status: "running" },
|
||||
gateResults: [],
|
||||
});
|
||||
assert.equal(task.state, "in_progress");
|
||||
assert.equal(task.state, "running");
|
||||
});
|
||||
|
||||
test("buildTaskRecord_includes_worker_id", () => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "../sf-db.js";
|
||||
import { invalidateStateCache } from "../state.js";
|
||||
import { isClosedStatus } from "../status-guards.js";
|
||||
import { taskFrontmatterFromRecord } from "../task-frontmatter.js";
|
||||
import { isNonEmptyString, normalizePlanningText } from "../validation.js";
|
||||
import { appendEvent } from "../workflow-events.js";
|
||||
import { logWarning } from "../workflow-logger.js";
|
||||
|
|
@ -89,6 +90,15 @@ function validateTasks(value) {
|
|||
`tasks[${index}].observabilityImpact must be a non-empty string when provided`,
|
||||
);
|
||||
}
|
||||
const frontmatter = taskFrontmatterFromRecord({
|
||||
...obj,
|
||||
keyFiles: files,
|
||||
});
|
||||
if (!frontmatter.valid) {
|
||||
throw new Error(
|
||||
`tasks[${index}] metadata invalid: ${frontmatter.errors.join("; ")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
taskId: normalizePlanningText(taskId, `tasks[${index}].taskId`),
|
||||
title: normalizePlanningText(title, `tasks[${index}].title`),
|
||||
|
|
@ -108,6 +118,16 @@ function validateTasks(value) {
|
|||
`tasks[${index}].observabilityImpact`,
|
||||
)
|
||||
: "",
|
||||
risk: frontmatter.normalized.risk,
|
||||
mutationScope: frontmatter.normalized.mutationScope,
|
||||
verification: frontmatter.normalized.verification,
|
||||
planApproval: frontmatter.normalized.planApproval,
|
||||
estimatedEffort: frontmatter.normalized.estimatedEffort,
|
||||
dependencies: frontmatter.normalized.dependencies,
|
||||
blocksParallel: frontmatter.normalized.blocksParallel,
|
||||
requiresUserInput: frontmatter.normalized.requiresUserInput,
|
||||
autoRetry: frontmatter.normalized.autoRetry,
|
||||
maxRetries: frontmatter.normalized.maxRetries,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -290,6 +310,16 @@ export async function handlePlanSlice(rawParams, basePath) {
|
|||
expectedOutput: task.expectedOutput,
|
||||
observabilityImpact: task.observabilityImpact ?? "",
|
||||
fullPlanMd: task.fullPlanMd,
|
||||
risk: task.risk,
|
||||
mutationScope: task.mutationScope,
|
||||
verification: task.verification,
|
||||
planApproval: task.planApproval,
|
||||
estimatedEffort: task.estimatedEffort,
|
||||
dependencies: task.dependencies,
|
||||
blocksParallel: task.blocksParallel,
|
||||
requiresUserInput: task.requiresUserInput,
|
||||
autoRetry: task.autoRetry,
|
||||
maxRetries: task.maxRetries,
|
||||
});
|
||||
}
|
||||
// Seed quality gate rows inside the transaction — all-or-nothing with
|
||||
|
|
|
|||
|
|
@ -10,6 +10,33 @@
|
|||
* background-work tracking.
|
||||
*/
|
||||
|
||||
import {
|
||||
normalizeTaskState,
|
||||
TASK_STATES,
|
||||
TASK_TERMINAL_STATES,
|
||||
} from "./task-state.js";
|
||||
|
||||
const TASK_STATE_QUERY_ALIASES = {
|
||||
done: ["done", "completed", "complete"],
|
||||
reviewing: ["reviewing", "review", "manual-attention", "manual_attention"],
|
||||
running: ["running", "in_progress", "progress"],
|
||||
todo: ["todo", "queued", "claimed", "pending"],
|
||||
verifying: ["verifying", "validating"],
|
||||
};
|
||||
|
||||
function expandTaskStateFilters(states) {
|
||||
const expanded = new Set();
|
||||
for (const state of states) {
|
||||
const normalized = normalizeTaskState(state);
|
||||
expanded.add(normalized);
|
||||
expanded.add(state);
|
||||
for (const alias of TASK_STATE_QUERY_ALIASES[normalized] ?? []) {
|
||||
expanded.add(alias);
|
||||
}
|
||||
}
|
||||
return [...expanded].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the execution graph tables exist.
|
||||
*
|
||||
|
|
@ -141,6 +168,7 @@ export function persistGraphNode(db, graphId, node) {
|
|||
gateResults = [],
|
||||
error,
|
||||
} = node;
|
||||
const normalizedState = normalizeTaskState(state);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
|
|
@ -186,7 +214,7 @@ export function persistGraphNode(db, graphId, node) {
|
|||
title: title ?? null,
|
||||
dependsOn: JSON.stringify(dependsOn),
|
||||
writes: JSON.stringify(writes),
|
||||
state,
|
||||
state: normalizedState,
|
||||
workerId: workerId ?? null,
|
||||
startedAt: startedAt ?? null,
|
||||
endedAt: endedAt ?? null,
|
||||
|
|
@ -255,9 +283,12 @@ export function queryTasksByState(db, filters = {}) {
|
|||
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 stateFilters = expandTaskStateFilters(states);
|
||||
conditions.push(
|
||||
`status IN (${stateFilters.map((_, i) => `:s${i}`).join(", ")})`,
|
||||
);
|
||||
for (let i = 0; i < stateFilters.length; i++) {
|
||||
params[`s${i}`] = stateFilters[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +307,7 @@ export function queryTasksByState(db, filters = {}) {
|
|||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
status: normalizeTaskState(r.status),
|
||||
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) : [],
|
||||
|
|
@ -297,27 +329,17 @@ export function getGraphStateSummary(db, graphId) {
|
|||
)
|
||||
.all({ graphId });
|
||||
|
||||
const counts = Object.fromEntries(
|
||||
[
|
||||
"todo",
|
||||
"in_progress",
|
||||
"review",
|
||||
"done",
|
||||
"retrying",
|
||||
"failed",
|
||||
"cancelled",
|
||||
].map((s) => [s, 0]),
|
||||
);
|
||||
const counts = Object.fromEntries(TASK_STATES.map((s) => [s, 0]));
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
counts[r.status] = r.count;
|
||||
const status = normalizeTaskState(r.status);
|
||||
counts[status] = (counts[status] ?? 0) + r.count;
|
||||
total += r.count;
|
||||
}
|
||||
|
||||
const terminal = ["done", "failed", "cancelled"].reduce(
|
||||
(sum, s) => sum + (counts[s] ?? 0),
|
||||
0,
|
||||
);
|
||||
const terminal = TASK_STATES.filter((s) =>
|
||||
TASK_TERMINAL_STATES.has(s),
|
||||
).reduce((sum, s) => sum + (counts[s] ?? 0), 0);
|
||||
|
||||
return {
|
||||
graphId,
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@ import { isTerminalUnitRuntimeStatus } from "./unit-runtime.js";
|
|||
|
||||
export const TASK_STATES = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"review",
|
||||
"running",
|
||||
"verifying",
|
||||
"reviewing",
|
||||
"done",
|
||||
"blocked",
|
||||
"paused",
|
||||
"retrying",
|
||||
"failed",
|
||||
"cancelled",
|
||||
|
|
@ -25,15 +28,72 @@ export const TASK_STATES = [
|
|||
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"],
|
||||
todo: ["running", "cancelled"],
|
||||
running: [
|
||||
"verifying",
|
||||
"reviewing",
|
||||
"done",
|
||||
"blocked",
|
||||
"paused",
|
||||
"retrying",
|
||||
"failed",
|
||||
"cancelled",
|
||||
],
|
||||
verifying: [
|
||||
"reviewing",
|
||||
"done",
|
||||
"blocked",
|
||||
"paused",
|
||||
"retrying",
|
||||
"failed",
|
||||
"cancelled",
|
||||
],
|
||||
reviewing: [
|
||||
"running",
|
||||
"verifying",
|
||||
"done",
|
||||
"blocked",
|
||||
"paused",
|
||||
"failed",
|
||||
"cancelled",
|
||||
],
|
||||
done: [],
|
||||
blocked: ["todo", "running", "retrying", "cancelled"],
|
||||
paused: ["running", "retrying", "cancelled"],
|
||||
retrying: ["running", "failed", "cancelled"],
|
||||
failed: ["retrying", "cancelled"],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export const TASK_STATE_SOURCE_LABELS = {
|
||||
claimed: "todo",
|
||||
completed: "done",
|
||||
complete: "done",
|
||||
in_progress: "running",
|
||||
"manual-attention": "reviewing",
|
||||
manual_attention: "reviewing",
|
||||
pending: "todo",
|
||||
progress: "running",
|
||||
queued: "todo",
|
||||
review: "reviewing",
|
||||
validating: "verifying",
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize runtime and scheduler source labels into task lifecycle labels.
|
||||
*
|
||||
* Purpose: store one canonical task.status vocabulary even when inputs arrive
|
||||
* from unit runtime, scheduler, graph, or gate outcome sources.
|
||||
*
|
||||
* Consumer: task-state derivation, SQLite graph persistence, and /tasks filters.
|
||||
*/
|
||||
export function normalizeTaskState(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") return "todo";
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (TASK_STATES.includes(normalized)) return normalized;
|
||||
return TASK_STATE_SOURCE_LABELS[normalized] ?? "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive task state from a collection of gate results.
|
||||
*
|
||||
|
|
@ -47,20 +107,21 @@ export function gateOutcomesToTaskState(gateResults) {
|
|||
|
||||
const outcomes = gateResults.map((r) => r.outcome);
|
||||
|
||||
if (outcomes.some((o) => o === "manual-attention")) return "review";
|
||||
if (outcomes.some((o) => o === "manual-attention")) return "reviewing";
|
||||
if (outcomes.some((o) => o === "blocked")) return "blocked";
|
||||
if (outcomes.some((o) => o === "paused")) return "paused";
|
||||
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";
|
||||
return "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Purpose: bridge the unit-runtime projection to the ORCH-style task state
|
||||
* vocabulary so /tasks shows one unified lifecycle view.
|
||||
*
|
||||
* Consumer: /tasks query when no active gate run exists for a unit.
|
||||
*/
|
||||
|
|
@ -75,22 +136,31 @@ export function unitRuntimeToTaskState(record) {
|
|||
return "todo";
|
||||
case "running":
|
||||
case "progress":
|
||||
return "in_progress";
|
||||
return "running";
|
||||
case "verifying":
|
||||
case "validating":
|
||||
return "verifying";
|
||||
case "review":
|
||||
case "reviewing":
|
||||
case "manual-attention":
|
||||
return "reviewing";
|
||||
case "completed":
|
||||
return "done";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "blocked":
|
||||
return "review";
|
||||
return "blocked";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
case "stale":
|
||||
case "runaway-recovered":
|
||||
return "retrying";
|
||||
case "notified":
|
||||
return isTerminalUnitRuntimeStatus(status) ? "done" : "in_progress";
|
||||
return isTerminalUnitRuntimeStatus(status) ? "done" : "running";
|
||||
default:
|
||||
return "in_progress";
|
||||
return normalizeTaskState(status);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,9 +170,9 @@ export function unitRuntimeToTaskState(record) {
|
|||
* Purpose: prevent illegal state jumps in /tasks UI and background scheduler.
|
||||
*/
|
||||
export function canTransitionTaskState(from, to) {
|
||||
const allowed = TASK_STATE_TRANSITIONS[from];
|
||||
const allowed = TASK_STATE_TRANSITIONS[normalizeTaskState(from)];
|
||||
if (!allowed) return false;
|
||||
return allowed.includes(to);
|
||||
return allowed.includes(normalizeTaskState(to));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -115,7 +185,8 @@ export function canTransitionTaskState(from, to) {
|
|||
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 state = normalizeTaskState(s);
|
||||
if (state in counts) counts[state]++;
|
||||
}
|
||||
const total = taskStates.length;
|
||||
const terminal = TASK_STATES.filter((s) =>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ import {
|
|||
} from "../sf/code-intelligence.js";
|
||||
import { loadEffectiveSFPreferences } from "../sf/preferences.js";
|
||||
import { recordRetrievalEvidence } from "../sf/retrieval-evidence.js";
|
||||
import {
|
||||
applyInheritanceToEnv,
|
||||
buildSubagentInheritanceEnvelope,
|
||||
validateSubagentDispatch,
|
||||
} from "../sf/subagent-inheritance.js";
|
||||
import { formatTokenCount } from "../shared/mod.js";
|
||||
import { getCurrentPhase } from "../shared/sf-phase-state.js";
|
||||
import { discoverAgents } from "./agents.js";
|
||||
|
|
@ -247,6 +252,41 @@ function summarizeBackgroundInvocation(params) {
|
|||
if (params.agent) return `single:${params.agent}`;
|
||||
return "subagent";
|
||||
}
|
||||
function collectSubagentDispatchItems(params) {
|
||||
if (params.chain?.length) {
|
||||
return params.chain.map((step) => ({
|
||||
agentName: step.agent,
|
||||
model: step.model ?? params.model,
|
||||
}));
|
||||
}
|
||||
if (params.tasks?.length) {
|
||||
return params.tasks.map((task) => ({
|
||||
agentName: task.agent,
|
||||
model: task.model ?? params.model,
|
||||
}));
|
||||
}
|
||||
if (params.agent) {
|
||||
return [{ agentName: params.agent, model: params.model }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function validateSubagentInvocationAgainstInheritance(
|
||||
params,
|
||||
agents,
|
||||
inheritanceEnvelope,
|
||||
) {
|
||||
for (const item of collectSubagentDispatchItems(params)) {
|
||||
const { agent } = resolveAgentByName(agents, item.agentName);
|
||||
if (!agent) continue;
|
||||
const result = validateSubagentDispatch(inheritanceEnvelope, {
|
||||
agentName: item.agentName,
|
||||
model: item.model ?? agent.model,
|
||||
tools: agent.tools ?? [],
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
async function executeSubagentInvocation({
|
||||
defaultCwd,
|
||||
agents,
|
||||
|
|
@ -258,6 +298,7 @@ async function executeSubagentInvocation({
|
|||
cmuxClient,
|
||||
cmuxSplitsEnabled,
|
||||
useIsolation,
|
||||
inheritanceEnvelope,
|
||||
}) {
|
||||
const makeDetails = (mode) => (results) => ({
|
||||
mode,
|
||||
|
|
@ -306,6 +347,7 @@ async function executeSubagentInvocation({
|
|||
chainUpdate,
|
||||
makeDetails("chain"),
|
||||
step.model ?? params.model,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
results.push(result);
|
||||
const isError =
|
||||
|
|
@ -478,6 +520,7 @@ async function executeSubagentInvocation({
|
|||
},
|
||||
makeDetails("debate"),
|
||||
taskModelOverride,
|
||||
inheritanceEnvelope,
|
||||
)
|
||||
: await runSingleAgent(
|
||||
defaultCwd,
|
||||
|
|
@ -497,6 +540,7 @@ async function executeSubagentInvocation({
|
|||
},
|
||||
makeDetails("debate"),
|
||||
taskModelOverride,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
result.task = t.task;
|
||||
result.step = round;
|
||||
|
|
@ -646,6 +690,7 @@ async function executeSubagentInvocation({
|
|||
},
|
||||
makeDetails("parallel"),
|
||||
taskModelOverride,
|
||||
inheritanceEnvelope,
|
||||
)
|
||||
: runSingleAgent(
|
||||
defaultCwd,
|
||||
|
|
@ -663,6 +708,7 @@ async function executeSubagentInvocation({
|
|||
},
|
||||
makeDetails("parallel"),
|
||||
taskModelOverride,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
let result = await runTask();
|
||||
const isFailed =
|
||||
|
|
@ -732,6 +778,7 @@ async function executeSubagentInvocation({
|
|||
onUpdate,
|
||||
makeDetails("single"),
|
||||
params.model,
|
||||
inheritanceEnvelope,
|
||||
)
|
||||
: await runSingleAgent(
|
||||
defaultCwd,
|
||||
|
|
@ -744,6 +791,7 @@ async function executeSubagentInvocation({
|
|||
onUpdate,
|
||||
makeDetails("single"),
|
||||
params.model,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
if (isolation) {
|
||||
const patches = await isolation.captureDelta();
|
||||
|
|
@ -908,10 +956,11 @@ function getBundledExtensionCliArgs() {
|
|||
.filter(Boolean)
|
||||
.flatMap((p) => ["--extension", p]);
|
||||
}
|
||||
function resolveSubagentLaunchSpec(args) {
|
||||
function resolveSubagentLaunchSpec(args, inheritanceEnvelope) {
|
||||
const sfBinPath = process.env.SF_BIN_PATH || process.argv[1];
|
||||
const env = { ...process.env };
|
||||
const envPatch = {};
|
||||
const inheritanceEnvPatch = applyInheritanceToEnv(inheritanceEnvelope, {});
|
||||
const env = { ...process.env, ...inheritanceEnvPatch };
|
||||
const envPatch = { ...inheritanceEnvPatch };
|
||||
const command = process.env.SF_NODE_BIN || process.execPath;
|
||||
if (sfBinPath && path.basename(sfBinPath) === "sf-from-source") {
|
||||
const sourceRoot = path.resolve(path.dirname(sfBinPath), "..");
|
||||
|
|
@ -1075,6 +1124,7 @@ async function runSingleAgent(
|
|||
onUpdate,
|
||||
makeDetails,
|
||||
modelOverride,
|
||||
inheritanceEnvelope,
|
||||
) {
|
||||
const { agent, effectiveName } = resolveAgentByName(agents, agentName);
|
||||
if (!agent) {
|
||||
|
|
@ -1168,7 +1218,7 @@ async function runSingleAgent(
|
|||
tmpPromptPath,
|
||||
modelOverride,
|
||||
);
|
||||
const launchSpec = resolveSubagentLaunchSpec(args);
|
||||
const launchSpec = resolveSubagentLaunchSpec(args, inheritanceEnvelope);
|
||||
let wasAborted = false;
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
const proc = spawn(launchSpec.command, launchSpec.args, {
|
||||
|
|
@ -1248,6 +1298,7 @@ async function runSingleAgentInCmuxSplit(
|
|||
onUpdate,
|
||||
makeDetails,
|
||||
modelOverride,
|
||||
inheritanceEnvelope,
|
||||
) {
|
||||
const { agent, effectiveName } = resolveAgentByName(agents, agentName);
|
||||
if (!agent) {
|
||||
|
|
@ -1262,6 +1313,7 @@ async function runSingleAgentInCmuxSplit(
|
|||
onUpdate,
|
||||
makeDetails,
|
||||
modelOverride,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
}
|
||||
let tmpPromptDir = null;
|
||||
|
|
@ -1330,10 +1382,12 @@ async function runSingleAgentInCmuxSplit(
|
|||
onUpdate,
|
||||
makeDetails,
|
||||
modelOverride,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
}
|
||||
const launchSpec = resolveSubagentLaunchSpec(
|
||||
buildSubagentProcessArgs(agent, task, tmpPromptPath, modelOverride),
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
const launcherPath = writeNodeSubagentLauncher(
|
||||
launchSpec,
|
||||
|
|
@ -1358,6 +1412,7 @@ async function runSingleAgentInCmuxSplit(
|
|||
onUpdate,
|
||||
makeDetails,
|
||||
modelOverride,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
}
|
||||
const finished = await waitForFile(exitPath, signal);
|
||||
|
|
@ -1618,9 +1673,13 @@ export default function (pi) {
|
|||
const discovery = discoverAgents(ctx.cwd, agentScope);
|
||||
const agents = discovery.agents;
|
||||
const confirmProjectAgents = params.confirmProjectAgents ?? false;
|
||||
const cmuxClient = CmuxClient.fromPreferences(
|
||||
loadEffectiveSFPreferences()?.preferences,
|
||||
);
|
||||
const effectivePreferences =
|
||||
loadEffectiveSFPreferences()?.preferences ?? {};
|
||||
const inheritanceEnvelope = buildSubagentInheritanceEnvelope({
|
||||
preferences: effectivePreferences,
|
||||
surface: ctx.hasUI ? "tui" : "headless",
|
||||
});
|
||||
const cmuxClient = CmuxClient.fromPreferences(effectivePreferences);
|
||||
const cmuxSplitsEnabled = cmuxClient.getConfig().splits;
|
||||
// Resolve isolation mode
|
||||
const isolationMode = readIsolationMode();
|
||||
|
|
@ -1662,6 +1721,25 @@ export default function (pi) {
|
|||
isError: true,
|
||||
};
|
||||
}
|
||||
const inheritanceCheck = validateSubagentInvocationAgainstInheritance(
|
||||
params,
|
||||
agents,
|
||||
inheritanceEnvelope,
|
||||
);
|
||||
if (!inheritanceCheck.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Subagent dispatch blocked by parent mode policy: ${inheritanceCheck.reason}`,
|
||||
},
|
||||
],
|
||||
details: makeDetails(
|
||||
hasChain ? "chain" : hasTasks ? "parallel" : "single",
|
||||
)([]),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (
|
||||
(agentScope === "project" || agentScope === "both") &&
|
||||
confirmProjectAgents &&
|
||||
|
|
@ -1717,6 +1795,7 @@ export default function (pi) {
|
|||
cmuxClient,
|
||||
cmuxSplitsEnabled,
|
||||
useIsolation,
|
||||
inheritanceEnvelope,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
|
|
@ -1743,6 +1822,7 @@ export default function (pi) {
|
|||
cmuxClient,
|
||||
cmuxSplitsEnabled,
|
||||
useIsolation,
|
||||
inheritanceEnvelope,
|
||||
});
|
||||
},
|
||||
renderCall(args, theme) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue