Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ gsd headless query
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. 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/user-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, except deterministic no-work tails that are classified as `no-work-deterministic` and suppress restart loops. 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/user-docs/remote-questions.md) to route decisions to Slack or Discord when human input is needed.

**Multi-session orchestration** — headless mode supports DB-backed coordination across multiple GSD workers on the same machine. Worker registration, milestone leases, unit dispatch tracking, and command delivery live in `.gsd/gsd.db`, while `.gsd/parallel/` remains a local runtime area for per-milestone locks and isolation artifacts.

Expand Down
4 changes: 3 additions & 1 deletion docs/user-docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ echo "Build a CLI tool" | gsd headless new-milestone --context -
| Flag | Description |
|------|-------------|
| `--timeout N` | Overall timeout in milliseconds (default: 300000 / 5 min) |
| `--max-restarts N` | Auto-restart on crash with exponential backoff (default: 3). Set 0 to disable |
| `--max-restarts N` | Auto-restart on crash with exponential backoff (default: 3). Set 0 to disable. Deterministic no-work failures are not restart-eligible. |
| `--json` | Stream all events as JSONL to stdout |
| `--model ID` | Override the model for the headless session |
| `--context <file>` | Context file for `new-milestone` (use `-` for stdin) |
Expand All @@ -353,6 +353,8 @@ echo "Build a CLI tool" | gsd headless new-milestone --context -

**Exit codes:** `0` = complete, `1` = error or timeout, `2` = blocked.

In `--output-format json` summaries, headless can also return `status: "no-work-deterministic"` for repeatable no-progress tails (for example select → input → cancelled). This status exits with code `1` and suppresses automatic restart loops.

Any `/gsd` subcommand works as a positional argument — `gsd headless status`, `gsd headless doctor`, `gsd headless dispatch execute`, etc.

### `gsd headless recover` (v2.79)
Expand Down
67 changes: 67 additions & 0 deletions src/headless-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,73 @@ export function shouldArmHeadlessIdleTimeout(toolCallCount: number, interactiveT
return toolCallCount > 0 && interactiveToolCount === 0
}

export interface HeadlessTrackedEventLike {
type: string
detail?: string
}

export type HeadlessFinalStatus = 'complete' | 'blocked' | 'cancelled' | 'error' | 'timeout' | 'no-work-deterministic'

export interface HeadlessRunSummary {
exitCode: number
interrupted: boolean
totalEvents: number
toolCallCount: number
recentEvents: readonly HeadlessTrackedEventLike[]
}

function hasCancelledDetail(detail: string | undefined): boolean {
return /\bcancelled\b/i.test(String(detail ?? ''))
}

/**
* Detect deterministic no-work tails seen in repeatable headless failures:
* select -> input -> notify(cancelled)
*/
export function hasDeterministicNoWorkTail(recentEvents: readonly HeadlessTrackedEventLike[]): boolean {
const uiTail = recentEvents
.filter((event) => event.type === 'extension_ui_request')
.slice(-3)

if (uiTail.length < 3) return false
const [first, second, third] = uiTail
return first.detail?.startsWith('select:') === true
&& second.detail?.startsWith('input:') === true
&& third.detail?.startsWith('notify:') === true
&& hasCancelledDetail(third.detail)
}

/**
* Classify final status for summary/logging. Keeps legacy semantics except
* for deterministic no-work tails, which are labeled distinctly.
*/
export function classifyHeadlessFinalStatus(args: {
blocked: boolean
exitCode: number
totalEvents: number
recentEvents: readonly HeadlessTrackedEventLike[]
}): HeadlessFinalStatus {
if (args.blocked) return 'blocked'
if (args.exitCode === EXIT_CANCELLED) return 'cancelled'
if (args.exitCode === EXIT_ERROR) {
if (hasDeterministicNoWorkTail(args.recentEvents)) return 'no-work-deterministic'
return args.totalEvents === 0 ? 'error' : 'timeout'
}
return 'complete'
}

/**
* Decide whether a failed run is restart-eligible.
*/
export function shouldRestartHeadlessRun(summary: HeadlessRunSummary): boolean {
if (summary.interrupted) return false
if (summary.exitCode !== EXIT_ERROR) return false
if (hasDeterministicNoWorkTail(summary.recentEvents)) return false
if (summary.totalEvents === 0) return true
if (summary.toolCallCount > 0 && summary.totalEvents > 5) return true
return true
}

// ---------------------------------------------------------------------------
// Quick Command Detection
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/headless-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const VALID_OUTPUT_FORMATS: ReadonlySet<string> = new Set(['text', 'json'
// ---------------------------------------------------------------------------

export interface HeadlessJsonResult {
status: 'success' | 'error' | 'blocked' | 'cancelled' | 'timeout'
status: 'success' | 'error' | 'blocked' | 'cancelled' | 'timeout' | 'no-work-deterministic'
exitCode: number
sessionId?: string
duration: number
Expand Down
29 changes: 17 additions & 12 deletions src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
NEW_MILESTONE_IDLE_TIMEOUT_MS,
isInteractiveHeadlessTool,
shouldArmHeadlessIdleTimeout,
shouldRestartHeadlessRun,
classifyHeadlessFinalStatus,
EXIT_SUCCESS,
EXIT_ERROR,
EXIT_BLOCKED,
Expand Down Expand Up @@ -247,14 +249,19 @@
process.exit(result.exitCode)
}

// Crash/error — check if we should restart
if (restartCount >= maxRestarts) {
process.stderr.write(`[headless] Max restarts (${maxRestarts}) reached. Exiting.\n`)
// Don't restart if SIGINT/SIGTERM was received
if (result.interrupted) {
process.exit(result.exitCode)
}

// Don't restart if SIGINT/SIGTERM was received
if (result.interrupted) {
if (!shouldRestartHeadlessRun(result)) {
process.stderr.write(`[headless] Restart suppressed: ${result.status}\n`)
process.exit(result.exitCode)
}

// Crash/error — check if we should restart
if (restartCount >= maxRestarts) {
process.stderr.write(`[headless] Max restarts (${maxRestarts}) reached. Exiting.\n`)
process.exit(result.exitCode)
}

Expand All @@ -265,7 +272,7 @@
}
}

async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): Promise<{ exitCode: number; interrupted: boolean }> {
async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): Promise<{ exitCode: number; interrupted: boolean; totalEvents: number; toolCallCount: number; recentEvents: TrackedEvent[]; status: string }> {
let interrupted = false
const startTime = Date.now()
const isNewMilestone = options.command === 'new-milestone'
Expand Down Expand Up @@ -349,7 +356,7 @@
if (options.command === 'query') {
const { handleQuery } = await import('./headless-query.js')
const result = await handleQuery(process.cwd())
return { exitCode: result.exitCode, interrupted: false }

Check failure on line 359 in src/headless.ts

View workflow job for this annotation

GitHub Actions / build

Type '{ exitCode: number; interrupted: false; }' is missing the following properties from type '{ exitCode: number; interrupted: boolean; totalEvents: number; toolCallCount: number; recentEvents: TrackedEvent[]; status: string; }': totalEvents, toolCallCount, recentEvents, status
}

// Recover: rebuild DB hierarchy from on-disk markdown projections, no RPC
Expand Down Expand Up @@ -443,10 +450,8 @@
function emitBatchJsonResult(): void {
if (options.outputFormat !== 'json') return
const duration = Date.now() - startTime
const status: HeadlessJsonResult['status'] = blocked ? 'blocked'
: exitCode === EXIT_CANCELLED ? 'cancelled'
: exitCode === EXIT_ERROR ? (totalEvents === 0 ? 'error' : 'timeout')
: 'success'
const finalStatus = classifyHeadlessFinalStatus({ blocked, exitCode, totalEvents, recentEvents })
const status: HeadlessJsonResult['status'] = finalStatus === 'complete' ? 'success' : finalStatus
const result: HeadlessJsonResult = {
status,
exitCode,
Expand Down Expand Up @@ -970,7 +975,7 @@

// Summary
const duration = ((Date.now() - startTime) / 1000).toFixed(1)
const status = blocked ? 'blocked' : exitCode === EXIT_CANCELLED ? 'cancelled' : exitCode === EXIT_ERROR ? (totalEvents === 0 ? 'error' : 'timeout') : 'complete'
const status = classifyHeadlessFinalStatus({ blocked, exitCode, totalEvents, recentEvents })

process.stderr.write(`[headless] Status: ${status}\n`)
process.stderr.write(`[headless] Duration: ${duration}s\n`)
Expand Down Expand Up @@ -1005,5 +1010,5 @@
// Emit structured JSON result in batch mode
emitBatchJsonResult()

return { exitCode, interrupted }
return { exitCode, interrupted, totalEvents, toolCallCount, recentEvents: [...recentEvents], status }
}
50 changes: 50 additions & 0 deletions src/tests/headless-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ import {
EXIT_CANCELLED,
isInteractiveHeadlessTool,
shouldArmHeadlessIdleTimeout,
hasDeterministicNoWorkTail,
classifyHeadlessFinalStatus,
shouldRestartHeadlessRun,
} from '../headless-events.js'

// ─── mapStatusToExitCode ─────────────────────────────────────────────────
Expand Down Expand Up @@ -221,3 +224,50 @@ test('shouldArmHeadlessIdleTimeout: stays disarmed before any tool call has star
assert.equal(shouldArmHeadlessIdleTimeout(0, 0), false)
assert.equal(shouldArmHeadlessIdleTimeout(0, 1), false)
})

test('hasDeterministicNoWorkTail: detects select -> input -> notify(cancelled)', () => {
const recentEvents = [
{ type: 'extension_ui_request', detail: 'select: choose milestone' },
{ type: 'extension_ui_request', detail: 'input: provide context' },
{ type: 'extension_ui_request', detail: 'notify: cancelled' },
]
assert.equal(hasDeterministicNoWorkTail(recentEvents), true)
})

test('hasDeterministicNoWorkTail: returns false for non-cancelled notify', () => {
const recentEvents = [
{ type: 'extension_ui_request', detail: 'select: choose milestone' },
{ type: 'extension_ui_request', detail: 'input: provide context' },
{ type: 'extension_ui_request', detail: 'notify: continuing' },
]
assert.equal(hasDeterministicNoWorkTail(recentEvents), false)
})

test('classifyHeadlessFinalStatus: deterministic tail maps to no-work-deterministic', () => {
const status = classifyHeadlessFinalStatus({
blocked: false,
exitCode: EXIT_ERROR,
totalEvents: 11,
recentEvents: [
{ type: 'extension_ui_request', detail: 'select: choose milestone' },
{ type: 'extension_ui_request', detail: 'input: provide context' },
{ type: 'extension_ui_request', detail: 'notify: cancelled' },
],
})
assert.equal(status, 'no-work-deterministic')
})

test('shouldRestartHeadlessRun: deterministic no-work tail is not restartable', () => {
const shouldRestart = shouldRestartHeadlessRun({
exitCode: EXIT_ERROR,
interrupted: false,
totalEvents: 11,
toolCallCount: 0,
recentEvents: [
{ type: 'extension_ui_request', detail: 'select: choose milestone' },
{ type: 'extension_ui_request', detail: 'input: provide context' },
{ type: 'extension_ui_request', detail: 'notify: cancelled' },
],
})
assert.equal(shouldRestart, false)
})
Loading