feat: add gsd headless query for instant state inspection (#951)
* feat: add `gsd headless query` for structured state inspection Add read-only query commands that return parseable JSON without spawning an LLM session. Decouples orchestrators from .gsd/ internals. Targets: phase, cost, progress, next * simplify: single `query` command returning full snapshot Replace 4 query targets (phase/cost/progress/next) with one command that returns everything in a single JSON object. Caller uses jq. Also document query in README.md and docs/commands.md. * docs: update gsd-headless skill and references - SKILL.md: add missing flags (--supervised, --max-restarts, --response-timeout) - references/commands.md: add query, discuss, remote, inspect, forensics - references/multi-session.md: fix spawning syntax, use query for budget * fix: remove integration tests that entered via merge These files belong to the feat/headless-orchestration-skill branch and were accidentally included during the upstream/main merge. They contain TS errors (sessionTerminated scope issue) that break CI. * fix: restore headless-command.ts deleted by accident
This commit is contained in:
parent
0e0f47ef9f
commit
99c3375f18
9 changed files with 362 additions and 34 deletions
|
|
@ -249,14 +249,14 @@ gsd headless new-milestone --context spec.md --auto
|
|||
# One unit at a time (cron-friendly)
|
||||
gsd headless next
|
||||
|
||||
# Machine-readable JSONL event stream
|
||||
gsd headless --json status
|
||||
# Instant JSON snapshot (no LLM, ~50ms)
|
||||
gsd headless query
|
||||
|
||||
# Force a specific pipeline phase
|
||||
gsd headless dispatch plan
|
||||
```
|
||||
|
||||
Headless auto-responds to interactive prompts, detects completion, and exits with structured codes: `0` complete, `1` error/timeout, `2` blocked. Auto-restarts on crash with exponential backoff. Pair with [remote questions](./docs/remote-questions.md) to route decisions to Slack or Discord when human input is needed.
|
||||
Headless auto-responds to interactive prompts, detects completion, and exits with structured codes: `0` complete, `1` error/timeout, `2` blocked. Auto-restarts on crash with exponential backoff. Use `gsd headless query` for instant, machine-readable state inspection — returns phase, next dispatch preview, and parallel worker costs as a single JSON object without spawning an LLM session. Pair with [remote questions](./docs/remote-questions.md) to route decisions to Slack or Discord when human input is needed.
|
||||
|
||||
**Multi-session orchestration** — headless mode supports file-based IPC in `.gsd/parallel/` for coordinating multiple GSD workers across milestones. Build orchestrators that spawn, monitor, and budget-cap a fleet of GSD workers.
|
||||
|
||||
|
|
@ -297,6 +297,7 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro
|
|||
| `gsd config` | Re-run the setup wizard (LLM provider + tool keys) |
|
||||
| `gsd update` | Update GSD to the latest version |
|
||||
| `gsd headless [cmd]` | Run `/gsd` commands without TUI (CI, cron, scripts) |
|
||||
| `gsd headless query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) |
|
||||
| `gsd --continue` (`-c`) | Resume the most recent session for the current directory |
|
||||
| `gsd sessions` | Interactive session picker — browse and resume any saved session |
|
||||
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ gsd headless
|
|||
# Run a single unit
|
||||
gsd headless next
|
||||
|
||||
# Machine-readable output
|
||||
gsd headless --json status
|
||||
# Instant JSON snapshot — no LLM, ~50ms
|
||||
gsd headless query
|
||||
|
||||
# With timeout for CI
|
||||
gsd headless --timeout 600000 auto
|
||||
|
|
@ -142,6 +142,46 @@ echo "Build a CLI tool" | gsd headless new-milestone --context -
|
|||
|
||||
Any `/gsd` subcommand works as a positional argument — `gsd headless status`, `gsd headless doctor`, `gsd headless dispatch execute`, etc.
|
||||
|
||||
### `gsd headless query`
|
||||
|
||||
Returns a single JSON object with the full project snapshot — no LLM session, no RPC child, instant response (~50ms). This is the recommended way for orchestrators and scripts to inspect GSD state.
|
||||
|
||||
```bash
|
||||
gsd headless query | jq '.state.phase'
|
||||
# "executing"
|
||||
|
||||
gsd headless query | jq '.next'
|
||||
# {"action":"dispatch","unitType":"execute-task","unitId":"M001/S01/T03"}
|
||||
|
||||
gsd headless query | jq '.cost.total'
|
||||
# 4.25
|
||||
```
|
||||
|
||||
**Output schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"state": {
|
||||
"phase": "executing",
|
||||
"activeMilestone": { "id": "M001", "title": "..." },
|
||||
"activeSlice": { "id": "S01", "title": "..." },
|
||||
"activeTask": { "id": "T01", "title": "..." },
|
||||
"registry": [{ "id": "M001", "status": "active" }, ...],
|
||||
"progress": { "milestones": { "done": 0, "total": 2 }, "slices": { "done": 1, "total": 3 } },
|
||||
"blockers": []
|
||||
},
|
||||
"next": {
|
||||
"action": "dispatch",
|
||||
"unitType": "execute-task",
|
||||
"unitId": "M001/S01/T01"
|
||||
},
|
||||
"cost": {
|
||||
"workers": [{ "milestoneId": "M001", "cost": 1.50, "state": "running", ... }],
|
||||
"total": 1.50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## MCP Server Mode
|
||||
|
||||
`gsd --mode mcp` runs GSD as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdin/stdout. This exposes all GSD tools (read, write, edit, bash, etc.) to external AI clients — Claude Desktop, VS Code Copilot, and any MCP-compatible host.
|
||||
|
|
|
|||
93
src/headless-query.ts
Normal file
93
src/headless-query.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Headless Query — `gsd headless query`
|
||||
*
|
||||
* Single read-only command that returns the full project snapshot as JSON
|
||||
* to stdout, without spawning an LLM session. Instant (~50ms).
|
||||
*
|
||||
* Output: { state, next, cost }
|
||||
* state — deriveState() output (phase, milestones, progress, blockers)
|
||||
* next — dry-run dispatch preview (what auto-mode would do next)
|
||||
* cost — aggregated parallel worker costs
|
||||
*/
|
||||
|
||||
import { deriveState } from './resources/extensions/gsd/state.js'
|
||||
import { resolveDispatch } from './resources/extensions/gsd/auto-dispatch.js'
|
||||
import { readAllSessionStatuses } from './resources/extensions/gsd/session-status-io.js'
|
||||
import { loadEffectiveGSDPreferences } from './resources/extensions/gsd/preferences.js'
|
||||
import type { GSDState } from './resources/extensions/gsd/types.js'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface QuerySnapshot {
|
||||
state: GSDState
|
||||
next: {
|
||||
action: 'dispatch' | 'stop' | 'skip'
|
||||
unitType?: string
|
||||
unitId?: string
|
||||
reason?: string
|
||||
}
|
||||
cost: {
|
||||
workers: Array<{
|
||||
milestoneId: string
|
||||
pid: number
|
||||
state: string
|
||||
cost: number
|
||||
lastHeartbeat: number
|
||||
}>
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
exitCode: number
|
||||
data?: QuerySnapshot
|
||||
}
|
||||
|
||||
// ─── Implementation ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleQuery(basePath: string): Promise<QueryResult> {
|
||||
const state = await deriveState(basePath)
|
||||
|
||||
// Derive next dispatch action
|
||||
let next: QuerySnapshot['next']
|
||||
if (!state.activeMilestone) {
|
||||
next = {
|
||||
action: 'stop',
|
||||
reason: state.phase === 'complete' ? 'All milestones complete.' : state.nextAction,
|
||||
}
|
||||
} else {
|
||||
const loaded = loadEffectiveGSDPreferences()
|
||||
const dispatch = await resolveDispatch({
|
||||
basePath,
|
||||
mid: state.activeMilestone.id,
|
||||
midTitle: state.activeMilestone.title,
|
||||
state,
|
||||
prefs: loaded?.preferences,
|
||||
})
|
||||
next = {
|
||||
action: dispatch.action,
|
||||
unitType: dispatch.action === 'dispatch' ? dispatch.unitType : undefined,
|
||||
unitId: dispatch.action === 'dispatch' ? dispatch.unitId : undefined,
|
||||
reason: dispatch.action === 'stop' ? dispatch.reason : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate parallel worker costs
|
||||
const statuses = readAllSessionStatuses(basePath)
|
||||
const workers = statuses.map((s) => ({
|
||||
milestoneId: s.milestoneId,
|
||||
pid: s.pid,
|
||||
state: s.state,
|
||||
cost: s.cost,
|
||||
lastHeartbeat: s.lastHeartbeat,
|
||||
}))
|
||||
|
||||
const snapshot: QuerySnapshot = {
|
||||
state,
|
||||
next,
|
||||
cost: { workers, total: workers.reduce((sum, w) => sum + w.cost, 0) },
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(snapshot) + '\n')
|
||||
return { exitCode: 0, data: snapshot }
|
||||
}
|
||||
|
|
@ -413,6 +413,13 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
// Query: read-only state snapshot, no RPC child needed
|
||||
if (options.command === 'query') {
|
||||
const { handleQuery } = await import('./headless-query.js')
|
||||
const result = await handleQuery(process.cwd())
|
||||
return { exitCode: result.exitCode, interrupted: false }
|
||||
}
|
||||
|
||||
// Resolve CLI path for the child process
|
||||
const cliPath = process.env.GSD_BIN_PATH || process.argv[1]
|
||||
if (!cliPath) {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
' next Run one unit',
|
||||
' status Show progress dashboard',
|
||||
' new-milestone Create a milestone from a specification document',
|
||||
' query JSON snapshot: state + next dispatch + costs (no LLM)',
|
||||
'',
|
||||
'new-milestone flags:',
|
||||
' --context <path> Path to spec/PRD file (use \'-\' for stdin)',
|
||||
|
|
@ -65,6 +66,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
' cat spec.md | gsd headless new-milestone --context - From stdin',
|
||||
' gsd headless new-milestone --context spec.md --auto Create + auto-execute',
|
||||
' gsd headless --supervised auto Supervised orchestrator mode',
|
||||
' gsd headless query Instant JSON state snapshot',
|
||||
'',
|
||||
'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked',
|
||||
].join('\n'),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@ Run GSD commands without TUI via `gsd headless`. Spawns an RPC child process, au
|
|||
gsd headless [flags] [command] [args...]
|
||||
```
|
||||
|
||||
**Flags:** `--timeout N` (ms, default 300000), `--json` (JSONL to stdout), `--model ID`, `--verbose`
|
||||
**Flags:**
|
||||
- `--timeout N` — overall timeout in ms (default 300000)
|
||||
- `--json` — JSONL event stream to stdout
|
||||
- `--model ID` — override LLM model
|
||||
- `--verbose` — show tool calls in progress output
|
||||
- `--supervised` — forward interactive UI requests to orchestrator via stdout/stdin
|
||||
- `--response-timeout N` — timeout for orchestrator response in supervised mode (default 30000)
|
||||
- `--max-restarts N` — auto-restart on crash with backoff (default 3, 0 to disable)
|
||||
|
||||
**Exit codes:** 0=complete, 1=error/timeout, 2=blocked
|
||||
|
||||
## Core Workflows
|
||||
|
|
@ -44,13 +52,32 @@ gsd headless next
|
|||
|
||||
Execute exactly one unit (task/slice/milestone step), then exit. Ideal for step-by-step orchestration with external decision logic between steps.
|
||||
|
||||
### 4. Check Status
|
||||
### 4. Instant State Snapshot (no LLM)
|
||||
|
||||
```bash
|
||||
gsd headless --json status
|
||||
gsd headless query
|
||||
```
|
||||
|
||||
Returns project state: active milestone/slice/task, phase, progress counts, blockers. Parse the JSONL output for machine-readable state.
|
||||
Returns a single JSON object with the full project snapshot — no LLM session, instant (~50ms). **This is the recommended way for orchestrators to inspect state.**
|
||||
|
||||
```json
|
||||
{
|
||||
"state": { "phase": "executing", "activeMilestone": {...}, "activeSlice": {...}, "progress": {...}, "registry": [...] },
|
||||
"next": { "action": "dispatch", "unitType": "execute-task", "unitId": "M001/S01/T01" },
|
||||
"cost": { "workers": [{ "milestoneId": "M001", "cost": 1.50, ... }], "total": 1.50 }
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# What phase is the project in?
|
||||
gsd headless query | jq '.state.phase'
|
||||
|
||||
# What would auto-mode do next?
|
||||
gsd headless query | jq '.next'
|
||||
|
||||
# Total spend across parallel workers
|
||||
gsd headless query | jq '.cost.total'
|
||||
```
|
||||
|
||||
### 5. Dispatch Specific Phase
|
||||
|
||||
|
|
@ -65,14 +92,14 @@ Force-route to a specific phase, bypassing normal state-machine routing.
|
|||
### Poll-and-React Loop
|
||||
|
||||
```bash
|
||||
# Check status, decide what to do
|
||||
STATUS=$(gsd headless --json status 2>/dev/null)
|
||||
EXIT=$?
|
||||
# Instant state check — no LLM cost
|
||||
PHASE=$(gsd headless query | jq -r '.state.phase')
|
||||
NEXT_ACTION=$(gsd headless query | jq -r '.next.action')
|
||||
|
||||
case $EXIT in
|
||||
0) echo "Complete" ;;
|
||||
2) echo "Blocked — needs intervention" ;;
|
||||
*) echo "Error" ;;
|
||||
case "$PHASE" in
|
||||
complete) echo "Done" ;;
|
||||
blocked) echo "Needs intervention" ;;
|
||||
*) [ "$NEXT_ACTION" = "dispatch" ] && gsd headless next ;;
|
||||
esac
|
||||
```
|
||||
|
||||
|
|
@ -83,8 +110,8 @@ while true; do
|
|||
gsd headless next
|
||||
EXIT=$?
|
||||
[ $EXIT -ne 0 ] && break
|
||||
# Check progress, log, decide whether to continue
|
||||
gsd headless --json status
|
||||
# Instant progress check between steps
|
||||
gsd headless query | jq '{phase: .state.phase, progress: .state.progress}'
|
||||
done
|
||||
```
|
||||
|
||||
|
|
@ -167,7 +194,7 @@ Quick reference — see [references/commands.md](references/commands.md) for the
|
|||
|---------|---------|
|
||||
| `auto` | Run all queued units (default) |
|
||||
| `next` | Run one unit |
|
||||
| `status` | Progress dashboard |
|
||||
| `query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) |
|
||||
| `new-milestone` | Create milestone from spec |
|
||||
| `queue` | Queue/reorder milestones |
|
||||
| `history` | View execution history |
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ All commands can be run via `gsd headless [command]`.
|
|||
| `pause` | Pause auto-mode (preserves state, resumable) |
|
||||
| `new-milestone` | Create milestone from specification (requires `--context`) |
|
||||
| `dispatch <phase>` | Force-dispatch: research, plan, execute, complete, reassess, uat, replan |
|
||||
| `discuss` | Start guided milestone/slice discussion |
|
||||
|
||||
## Status & Monitoring
|
||||
## State Inspection
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Progress dashboard (active unit, phase, blockers) |
|
||||
| `query` | **Instant JSON snapshot** — state, next dispatch, parallel costs. No LLM, ~50ms. Recommended for orchestrators. |
|
||||
| `status` | Progress dashboard (TUI overlay — useful interactively, not for parsing) |
|
||||
| `visualize` | Workflow visualizer (deps, metrics, timeline) |
|
||||
| `history` | Execution history (supports --cost, --phase, --model, limit) |
|
||||
|
||||
|
|
@ -44,6 +46,9 @@ All commands can be run via `gsd headless [command]`.
|
|||
| `cleanup` | Remove merged branches or snapshots |
|
||||
| `export` | Export results (--json, --markdown) |
|
||||
| `migrate` | Migrate v1 .planning directory to .gsd format |
|
||||
| `remote` | Control remote auto-mode (slack, discord, status, disconnect) |
|
||||
| `inspect` | Show SQLite DB diagnostics (schema, row counts) |
|
||||
| `forensics` | Post-mortem investigation of auto-mode failures |
|
||||
|
||||
## Phases
|
||||
|
||||
|
|
|
|||
|
|
@ -68,20 +68,11 @@ Coordinator writes to `.gsd/parallel/<milestoneId>.signal.json`. Worker consumes
|
|||
# Spawn worker in its worktree
|
||||
GSD_MILESTONE_LOCK=M001 \
|
||||
GSD_PARALLEL_WORKER=1 \
|
||||
GSD_BIN_PATH=$(which gsd) \
|
||||
gsd --mode json --print "/gsd auto" \
|
||||
2>logs/M001.log &
|
||||
gsd headless --json auto 2>logs/M001.log &
|
||||
WORKER_PID=$!
|
||||
```
|
||||
|
||||
Workers emit NDJSON on stdout. Parse `message_end` events for cost tracking:
|
||||
```bash
|
||||
# Extract cost from worker output
|
||||
gsd --mode json --print "/gsd auto" | while read -r line; do
|
||||
COST=$(echo "$line" | jq -r 'select(.type=="message_end") | .message.usage.cost.total // empty')
|
||||
[ -n "$COST" ] && echo "Cost update: $COST"
|
||||
done
|
||||
```
|
||||
Workers emit JSONL events on stdout when `--json` is set.
|
||||
|
||||
## Monitoring All Workers
|
||||
|
||||
|
|
@ -122,9 +113,9 @@ send_signal M003 resume
|
|||
|
||||
## Budget Enforcement
|
||||
|
||||
Track aggregate cost across all workers:
|
||||
Use `gsd headless query` for instant aggregate cost:
|
||||
```bash
|
||||
TOTAL=$(jq -s 'map(.cost) | add // 0' .gsd/parallel/*.status.json)
|
||||
TOTAL=$(gsd headless query | jq -r '.cost.total')
|
||||
CEILING=50.00
|
||||
if (( $(echo "$TOTAL > $CEILING" | bc -l) )); then
|
||||
echo "Budget exceeded ($TOTAL > $CEILING) — stopping all"
|
||||
|
|
|
|||
162
src/resources/extensions/gsd/tests/headless-query.test.ts
Normal file
162
src/resources/extensions/gsd/tests/headless-query.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Tests for `gsd headless query` — single JSON snapshot command.
|
||||
*
|
||||
* Validates that the snapshot contains state, next dispatch preview,
|
||||
* and parallel worker costs in one response.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
import { handleQuery } from '../../../../headless-query.ts'
|
||||
import type { QuerySnapshot } from '../../../../headless-query.ts'
|
||||
import { invalidateStateCache } from '../state.ts'
|
||||
|
||||
// ─── Fixture Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function createFixture(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-query-test-'))
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true })
|
||||
return base
|
||||
}
|
||||
|
||||
function writeRoadmap(base: string, mid: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, `${mid}-ROADMAP.md`), content)
|
||||
}
|
||||
|
||||
function writeContext(base: string, mid: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, `${mid}-CONTEXT.md`), `---\ntitle: Test Milestone\n---\n\n# Context\nTest.`)
|
||||
}
|
||||
|
||||
function writeSlicePlan(base: string, mid: string, sid: string, content: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid)
|
||||
mkdirSync(join(dir, 'tasks'), { recursive: true })
|
||||
writeFileSync(join(dir, `${sid}-PLAN.md`), content)
|
||||
}
|
||||
|
||||
function writeTaskPlan(base: string, mid: string, sid: string, tid: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid, 'tasks')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, `${tid}-PLAN.md`), `---\nestimated_steps: 3\nestimated_files: 2\n---\n\n# ${tid}: Test Task\nDo something.`)
|
||||
}
|
||||
|
||||
function writeParallelStatus(base: string, mid: string, cost: number): void {
|
||||
const dir = join(base, '.gsd', 'parallel')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, `${mid}.status.json`), JSON.stringify({
|
||||
milestoneId: mid,
|
||||
pid: process.pid,
|
||||
state: 'running',
|
||||
currentUnit: { type: 'execute-task', id: `${mid}/S01/T01`, startedAt: Date.now() },
|
||||
completedUnits: 2,
|
||||
cost,
|
||||
lastHeartbeat: Date.now(),
|
||||
startedAt: Date.now() - 60_000,
|
||||
worktreePath: `/tmp/worktrees/${mid}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function createExecutingFixture(base: string): void {
|
||||
writeContext(base, 'M001')
|
||||
writeRoadmap(base, 'M001', `# M001: Test Milestone
|
||||
|
||||
**Vision:** Build something.
|
||||
|
||||
## Slices
|
||||
|
||||
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
|
||||
> After this: The first slice works.
|
||||
`)
|
||||
writeSlicePlan(base, 'M001', 'S01', `# S01: First Slice
|
||||
|
||||
**Goal:** Implement something.
|
||||
**Demo:** It works.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T01: First Task** — Do the first thing
|
||||
- Files: foo.ts
|
||||
- Verify: run tests
|
||||
- [ ] **T02: Second Task** — Do the second thing
|
||||
- Files: bar.ts
|
||||
`)
|
||||
writeTaskPlan(base, 'M001', 'S01', 'T01')
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('headless query', () => {
|
||||
let base: string
|
||||
|
||||
beforeEach(() => {
|
||||
base = createFixture()
|
||||
invalidateStateCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns snapshot with state, next, and cost', async () => {
|
||||
createExecutingFixture(base)
|
||||
const result = await handleQuery(base)
|
||||
const snap = result.data as QuerySnapshot
|
||||
|
||||
assert.equal(result.exitCode, 0)
|
||||
// state
|
||||
assert.equal(snap.state.phase, 'executing')
|
||||
assert.equal(snap.state.activeMilestone!.id, 'M001')
|
||||
assert.equal(snap.state.activeSlice!.id, 'S01')
|
||||
assert.equal(snap.state.activeTask!.id, 'T01')
|
||||
assert.ok(Array.isArray(snap.state.registry))
|
||||
assert.ok(snap.state.progress)
|
||||
// next
|
||||
assert.equal(snap.next.action, 'dispatch')
|
||||
assert.equal(snap.next.unitType, 'execute-task')
|
||||
assert.ok(snap.next.unitId)
|
||||
// cost (no parallel workers)
|
||||
assert.equal(snap.cost.workers.length, 0)
|
||||
assert.equal(snap.cost.total, 0)
|
||||
})
|
||||
|
||||
it('returns stop when no milestones exist', async () => {
|
||||
const result = await handleQuery(base)
|
||||
const snap = result.data as QuerySnapshot
|
||||
|
||||
assert.equal(result.exitCode, 0)
|
||||
assert.equal(snap.state.phase, 'pre-planning')
|
||||
assert.equal(snap.state.activeMilestone, null)
|
||||
assert.equal(snap.next.action, 'stop')
|
||||
assert.ok(snap.next.reason)
|
||||
})
|
||||
|
||||
it('aggregates parallel worker costs', async () => {
|
||||
createExecutingFixture(base)
|
||||
writeParallelStatus(base, 'M001', 1.50)
|
||||
writeParallelStatus(base, 'M002', 2.75)
|
||||
const result = await handleQuery(base)
|
||||
const snap = result.data as QuerySnapshot
|
||||
|
||||
assert.equal(snap.cost.workers.length, 2)
|
||||
assert.equal(snap.cost.total, 4.25)
|
||||
assert.ok(snap.cost.workers.some(w => w.milestoneId === 'M001' && w.cost === 1.50))
|
||||
assert.ok(snap.cost.workers.some(w => w.milestoneId === 'M002' && w.cost === 2.75))
|
||||
})
|
||||
|
||||
it('shows dispatch preview for pre-planning with context', async () => {
|
||||
writeContext(base, 'M001')
|
||||
const result = await handleQuery(base)
|
||||
const snap = result.data as QuerySnapshot
|
||||
|
||||
assert.equal(snap.state.phase, 'pre-planning')
|
||||
assert.equal(snap.state.activeMilestone!.id, 'M001')
|
||||
assert.equal(snap.next.action, 'dispatch')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue