@ucsandman/legcli 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// live-capture — the first time a real limit signal arrives from an agent
|
|
2
|
+
// (claude StopFailure, codex usage_limit_exceeded, agy RESOURCE_EXHAUSTED),
|
|
3
|
+
// keep the payload with secrets scrubbed, so the docs rows that say
|
|
4
|
+
// "docs-only" can become "observed-live" with evidence. One file per
|
|
5
|
+
// (agent, signal); a later arrival never overwrites the first. A payload a
|
|
6
|
+
// `baton sessions simulate-limit` produced is marked and never captured.
|
|
7
|
+
// Where: BATON_LIVE_DIR, else this checkout's fixtures/live/ when it exists
|
|
8
|
+
// (a dev clone), else <BATON_HOME>/live/. In a dev clone the matching docs
|
|
9
|
+
// rows are flipped in the same call (scripts/live-limits.mjs).
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join, dirname, resolve } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import { redact } from './redact.mjs'
|
|
16
|
+
import { home } from './store.mjs'
|
|
17
|
+
|
|
18
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
19
|
+
const FIXTURES_LIVE = join(ROOT, 'fixtures', 'live')
|
|
20
|
+
const FLIP_SCRIPT = join(ROOT, 'scripts', 'live-limits.mjs')
|
|
21
|
+
|
|
22
|
+
// A real limit payload (claude StopFailure, codex, agy) carries cwd,
|
|
23
|
+
// transcript_path and scratchpad_dir. redact() strips secret shapes and the
|
|
24
|
+
// values of the well-known key variables this process holds; this also folds
|
|
25
|
+
// the home and repo roots to placeholders (in every slash form, including the
|
|
26
|
+
// doubled backslashes JSON escaping produces) so nothing path-shaped is written
|
|
27
|
+
// into a git-tracked fixtures/live/ file.
|
|
28
|
+
function redactPaths(s) {
|
|
29
|
+
let out = redact(String(s))
|
|
30
|
+
for (const [root, tag] of [[homedir(), '[redacted-home]'], [ROOT, '[redacted-repo]']]) {
|
|
31
|
+
if (!root) continue
|
|
32
|
+
for (const variant of [root.replace(/\\/g, '\\\\'), root.replace(/\\/g, '/'), root]) out = out.split(variant).join(tag)
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function liveDir() {
|
|
38
|
+
if (process.env.LEG_LIVE_DIR || process.env.BATON_LIVE_DIR) return process.env.LEG_LIVE_DIR || process.env.BATON_LIVE_DIR
|
|
39
|
+
if (existsSync(FIXTURES_LIVE) && existsSync(join(ROOT, '.git'))) return FIXTURES_LIVE
|
|
40
|
+
return join(home(), 'live')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isSimulated(payload) {
|
|
44
|
+
if (payload && typeof payload === 'object' && (payload.leg_simulated || payload.baton_simulated)) return true
|
|
45
|
+
return /simulated by (?:leg|baton)/i.test(typeof payload === 'string' ? payload : JSON.stringify(payload ?? ''))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function livePath(agent, signal, dir = liveDir()) {
|
|
49
|
+
return join(dir, agent, `limit-${String(signal).replace(/[^a-z0-9_-]+/gi, '_')}.json`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// → { path, written: bool } | null (simulated, or nothing to keep)
|
|
53
|
+
export function captureLive(agent, signal, payload, { sessionId = null, flipDocs = true } = {}) {
|
|
54
|
+
if (!agent || !signal || payload === undefined || isSimulated(payload)) return null
|
|
55
|
+
const dir = liveDir()
|
|
56
|
+
const file = livePath(agent, signal, dir)
|
|
57
|
+
if (existsSync(file)) return { path: file, written: false }
|
|
58
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
59
|
+
const record = {
|
|
60
|
+
agent, signal, captured_at: new Date().toISOString(), session_id: sessionId,
|
|
61
|
+
source: 'observed-live',
|
|
62
|
+
payload: JSON.parse(redactPaths(JSON.stringify(payload))),
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(file, JSON.stringify(record, null, 2) + '\n')
|
|
65
|
+
if (flipDocs && dir === FIXTURES_LIVE) {
|
|
66
|
+
try { spawnSync(process.execPath, [FLIP_SCRIPT], { windowsHide: true, encoding: 'utf8', timeout: 8000 }) } catch {}
|
|
67
|
+
}
|
|
68
|
+
return { path: file, written: true }
|
|
69
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// mergequeue — the land station. One land at a time per repo root, FIFO: the
|
|
2
|
+
// queue key is the canonical root (two spellings of one path are one queue) and
|
|
3
|
+
// the turn itself is a file lock under BATON_HOME, so a `baton card run` CLI
|
|
4
|
+
// and the board server cannot land into one checkout at the same time.
|
|
5
|
+
// land(card, worktree):
|
|
6
|
+
// 1. root must be on <trunk> and clean, else bounce `dirty-trunk` (root untouched)
|
|
7
|
+
// 1b. commit whatever the agents left uncommitted in the worktree
|
|
8
|
+
// 2. rebase the card branch onto trunk in the worktree; abort + bounce `rebase-conflict` with the file list, or `rebase-failed` when git failed for another reason
|
|
9
|
+
// 3. run the test command (card.test_command → package.json test → pytest → none + land_warning); red → bounce `tests-red` with the tail
|
|
10
|
+
// 4. from the root: git merge --ff-only baton/<id>; trunk moved meanwhile → one retry, then bounce `trunk-moved`
|
|
11
|
+
// 5. success → { landed, sha, files, insertions, deletions, duration_ms }
|
|
12
|
+
// No force flags, no remote writes, no hard resets on the root, ever.
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { existsSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { branchName } from './worktree.mjs'
|
|
18
|
+
import { runCommandAsync } from './commands.mjs'
|
|
19
|
+
import { canonPath, withFileLock, writeJsonAtomic } from './fsx.mjs'
|
|
20
|
+
import { home } from './store.mjs'
|
|
21
|
+
|
|
22
|
+
const TEST_TIMEOUT_MS = Number((process.env.LEG_LAND_TEST_TIMEOUT_MS || process.env.BATON_LAND_TEST_TIMEOUT_MS) || 600000)
|
|
23
|
+
const queues = new Map() // canonical repo root → tail promise
|
|
24
|
+
// A turn covers the commit, the rebase and the repo's whole test run, so a
|
|
25
|
+
// claim is only stale once its holder died: the test budget plus the git work
|
|
26
|
+
// around it. The guard lock around the claim file protects two file writes and
|
|
27
|
+
// keeps a short staleness of its own.
|
|
28
|
+
const LOCK_STALE_MS = TEST_TIMEOUT_MS + 120000
|
|
29
|
+
const LOCK_GUARD_STALE_MS = 5000
|
|
30
|
+
const LOCK_POLL_MS = 250
|
|
31
|
+
|
|
32
|
+
function git(cwd, args, { ok = true } = {}) {
|
|
33
|
+
const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
34
|
+
if (ok && r.status !== 0) throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${(r.stderr || r.stdout).trim().slice(0, 500)}`)
|
|
35
|
+
return r
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A rebase/merge/cherry-pick the agent or a human left mid-flight. Landing must
|
|
39
|
+
// never commit that half-state and must never abort an operation it did not
|
|
40
|
+
// start (aborting a rebase throws away the uncommitted work sitting on it).
|
|
41
|
+
function operationInProgress(worktree) {
|
|
42
|
+
for (const p of ['rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD']) {
|
|
43
|
+
const rel = git(worktree, ['rev-parse', '--git-path', p], { ok: false }).stdout.trim()
|
|
44
|
+
if (rel && existsSync(resolve(worktree, rel))) return p
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function trunkHead(repo) {
|
|
50
|
+
return git(repo, ['rev-parse', 'HEAD']).stdout.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function rootState(repo, trunk) {
|
|
54
|
+
const branch = git(repo, ['symbolic-ref', '--short', '-q', 'HEAD'], { ok: false }).stdout.trim() || 'DETACHED'
|
|
55
|
+
const dirty = git(repo, ['status', '--porcelain']).stdout.split(/\r?\n/).filter(Boolean)
|
|
56
|
+
return { branch, onTrunk: branch === trunk, dirty }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Commit the agents' work so the branch can be rebased and merged. The commit
|
|
60
|
+
// skips git hooks (--no-verify): the repo's test command is Baton's gate, and
|
|
61
|
+
// interactive commit hooks (linters, wire-dark style checks) belong to humans
|
|
62
|
+
// typing commits. BATON_COMMIT_VERIFY=1 runs them anyway.
|
|
63
|
+
export function commitWorktree(worktree, message) {
|
|
64
|
+
const dirty = git(worktree, ['status', '--porcelain']).stdout.split(/\r?\n/).filter(Boolean)
|
|
65
|
+
if (!dirty.length) return { committed: false }
|
|
66
|
+
git(worktree, ['add', '-A'])
|
|
67
|
+
const verify = (process.env.LEG_COMMIT_VERIFY || process.env.BATON_COMMIT_VERIFY) === '1' ? [] : ['--no-verify']
|
|
68
|
+
git(worktree, ['-c', 'user.email=baton@localhost', '-c', 'user.name=baton', 'commit', '-q', ...verify, '-m', message])
|
|
69
|
+
return { committed: true, files: dirty.length }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveTestCommand(card, worktree) {
|
|
73
|
+
if (card.test_command) return { command: card.test_command, source: 'card' }
|
|
74
|
+
const pkg = join(worktree, 'package.json')
|
|
75
|
+
if (existsSync(pkg)) {
|
|
76
|
+
try {
|
|
77
|
+
const p = JSON.parse(readFileSync(pkg, 'utf8'))
|
|
78
|
+
if (p.scripts?.test) return { command: 'npm test', source: 'package.json' }
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
if (existsSync(join(worktree, 'pyproject.toml'))) return { command: 'pytest', source: 'pyproject.toml' }
|
|
82
|
+
return { command: null, source: 'none' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function runTests(command, worktree) {
|
|
86
|
+
return runCommandAsync(command, worktree, { timeoutMs: TEST_TIMEOUT_MS, tailLines: 40 })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bounce(reason, detail, extra = {}) {
|
|
90
|
+
return { landed: false, bounced: true, reason, detail, ...extra }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A non-zero `git rebase` is a conflict only when git names unmerged files.
|
|
94
|
+
// Anything else (a hook that refuses, an upstream git cannot use) is a
|
|
95
|
+
// different failure, and the bounce reason is the next agent's brief: it must
|
|
96
|
+
// never read as a conflict in files git never mentioned.
|
|
97
|
+
function rebaseBounce(worktree, trunk, result, preSha, when) {
|
|
98
|
+
const files = git(worktree, ['diff', '--name-only', '--diff-filter=U'], { ok: false }).stdout.split(/\r?\n/).filter(Boolean)
|
|
99
|
+
git(worktree, ['rebase', '--abort'], { ok: false })
|
|
100
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 300)
|
|
101
|
+
if (files.length) return bounce('rebase-conflict', `rebase onto ${trunk}${when} conflicted in: ${files.join(', ')}`, { files, pre_sha: preSha })
|
|
102
|
+
return bounce('rebase-failed', `rebase onto ${trunk}${when} failed: ${detail || '(no git output)'}`, { files, pre_sha: preSha })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function claimFile(key) {
|
|
106
|
+
const dir = join(home(), 'locks')
|
|
107
|
+
mkdirSync(dir, { recursive: true })
|
|
108
|
+
return join(dir, `land-${createHash('sha1').update(key).digest('hex').slice(0, 16)}.json`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function claimHolder(file) {
|
|
112
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// withFileLock guards the read-modify-write of the claim; the claim it leaves
|
|
116
|
+
// behind outlives that guard, which is what makes the turn last a whole land.
|
|
117
|
+
function takeTurn(file, token) {
|
|
118
|
+
return withFileLock(`${file}.lock`, () => {
|
|
119
|
+
const held = claimHolder(file)
|
|
120
|
+
if (held && held.token !== token && Date.now() - held.ts < LOCK_STALE_MS) return false
|
|
121
|
+
writeJsonAtomic(file, { token, pid: process.pid, ts: Date.now() })
|
|
122
|
+
return true
|
|
123
|
+
}, { staleMs: LOCK_GUARD_STALE_MS })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function dropTurn(file, token) {
|
|
127
|
+
withFileLock(`${file}.lock`, () => {
|
|
128
|
+
const held = claimHolder(file)
|
|
129
|
+
if (!held || held.token === token) { try { unlinkSync(file) } catch {} }
|
|
130
|
+
}, { staleMs: LOCK_GUARD_STALE_MS })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function withRepoTurn(repo, fn) {
|
|
134
|
+
const file = claimFile(canonPath(repo))
|
|
135
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
136
|
+
while (!takeTurn(file, token)) await new Promise((r) => setTimeout(r, LOCK_POLL_MS))
|
|
137
|
+
try { return await fn() } finally { dropTurn(file, token) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function landNow(card, worktree, { onWarning = () => {}, allowDirtyRoot = false } = {}) {
|
|
141
|
+
const t0 = Date.now()
|
|
142
|
+
const repo = card.repo
|
|
143
|
+
const trunk = card.trunk || 'main'
|
|
144
|
+
const branch = branchName(card.card_id)
|
|
145
|
+
|
|
146
|
+
const checkedOut = git(worktree, ['symbolic-ref', '--short', '-q', 'HEAD'], { ok: false }).stdout.trim() || 'DETACHED'
|
|
147
|
+
if (checkedOut !== branch) return bounce('worktree-branch', `worktree is on ${checkedOut}, not ${branch}; switch it back before landing`)
|
|
148
|
+
|
|
149
|
+
const busy = operationInProgress(worktree)
|
|
150
|
+
if (busy) return bounce('worktree-busy', `a ${busy.replace(/-/g, ' ')} is already in progress in ${worktree}; finish or abort it there before landing (Baton will not touch a rebase it did not start)`)
|
|
151
|
+
|
|
152
|
+
const root = rootState(repo, trunk)
|
|
153
|
+
if (!root.onTrunk) return bounce('dirty-trunk', `repo root is on ${root.branch}, not ${trunk}; check out ${trunk} and retry`)
|
|
154
|
+
// allowDirtyRoot (a terminal's Land): the root is a live checkout, dirty by
|
|
155
|
+
// nature; git's own fast-forward still refuses to overwrite a local change
|
|
156
|
+
if (root.dirty.length && !allowDirtyRoot) return bounce('dirty-trunk', `repo root has ${root.dirty.length} uncommitted change(s): ${root.dirty.slice(0, 10).join(', ')}`)
|
|
157
|
+
|
|
158
|
+
const committed = commitWorktree(worktree, `baton: ${card.title ?? card.card_id}`)
|
|
159
|
+
const preSha = git(worktree, ['rev-parse', 'HEAD']).stdout.trim()
|
|
160
|
+
const trunkBefore = trunkHead(repo)
|
|
161
|
+
|
|
162
|
+
const rebase = git(worktree, ['rebase', trunk], { ok: false })
|
|
163
|
+
if (rebase.status !== 0) return rebaseBounce(worktree, trunk, rebase, preSha, '')
|
|
164
|
+
|
|
165
|
+
const tc = resolveTestCommand(card, worktree)
|
|
166
|
+
let tests = null
|
|
167
|
+
if (!tc.command) {
|
|
168
|
+
onWarning(`no test command (card, package.json, pyproject.toml); landing untested`)
|
|
169
|
+
} else {
|
|
170
|
+
tests = await runTests(tc.command, worktree)
|
|
171
|
+
if (!tests.green) {
|
|
172
|
+
return bounce('tests-red', `${tests.command} exit ${tests.status}${tests.timedOut ? ' (timed out)' : ''}:\n${tests.tail}`, { test_tail: tests.tail, pre_sha: preSha })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let merge = git(repo, ['merge', '--ff-only', branch], { ok: false })
|
|
177
|
+
let retried = false
|
|
178
|
+
if (merge.status !== 0 && trunkHead(repo) !== trunkBefore) {
|
|
179
|
+
// trunk moved while we tested: rebase once more and retry the ff
|
|
180
|
+
retried = true
|
|
181
|
+
const again = git(worktree, ['rebase', trunk], { ok: false })
|
|
182
|
+
if (again.status !== 0) return rebaseBounce(worktree, trunk, again, preSha, ` after ${trunk} moved`)
|
|
183
|
+
if (tc.command) {
|
|
184
|
+
tests = await runTests(tc.command, worktree)
|
|
185
|
+
if (!tests.green) {
|
|
186
|
+
return bounce('tests-red', `${tests.command} exit ${tests.status}${tests.timedOut ? ' (timed out)' : ''} after ${trunk} moved:\n${tests.tail}`, { test_tail: tests.tail, pre_sha: preSha })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
merge = git(repo, ['merge', '--ff-only', branch], { ok: false })
|
|
190
|
+
}
|
|
191
|
+
if (merge.status !== 0 && /would be overwritten by merge/.test(merge.stderr || merge.stdout)) {
|
|
192
|
+
const files = (merge.stderr || merge.stdout).split(/\r?\n/).filter((l) => /^\s+\S/.test(l)).map((l) => l.trim())
|
|
193
|
+
return bounce('dirty-trunk', `the ${trunk} checkout has local changes this landing would overwrite: ${files.join(', ') || '(see git output)'}`, { files })
|
|
194
|
+
}
|
|
195
|
+
if (merge.status !== 0) {
|
|
196
|
+
return bounce('trunk-moved', `fast-forward of ${trunk} failed${retried ? ' after one retry' : ''}: ${(merge.stderr || merge.stdout).trim().slice(0, 300)}`, { retried })
|
|
197
|
+
}
|
|
198
|
+
const sha = trunkHead(repo)
|
|
199
|
+
const stat = git(repo, ['diff', '--shortstat', `${trunkBefore}..${sha}`], { ok: false }).stdout.trim()
|
|
200
|
+
const files = git(repo, ['diff', '--name-only', `${trunkBefore}..${sha}`], { ok: false }).stdout.split(/\r?\n/).filter(Boolean)
|
|
201
|
+
const m = /(\d+) insertion/.exec(stat)
|
|
202
|
+
const d = /(\d+) deletion/.exec(stat)
|
|
203
|
+
return {
|
|
204
|
+
landed: true, sha, sha_before: trunkBefore, files, insertions: m ? Number(m[1]) : 0, deletions: d ? Number(d[1]) : 0,
|
|
205
|
+
duration_ms: Date.now() - t0, retried, committed: committed.committed, tests: tests ? { command: tests.command, tail: tests.tail.split('\n').slice(-3).join(' | ') } : null,
|
|
206
|
+
summary: `landed on ${trunk}: ${trunkBefore.slice(0, 7)} → ${sha.slice(0, 7)} (${files.length} file${files.length === 1 ? '' : 's'}, +${m ? m[1] : 0}/-${d ? d[1] : 0})${tests ? '' : ' [untested]'}`,
|
|
207
|
+
body: JSON.stringify({ sha, files, insertions: m ? Number(m[1]) : 0, deletions: d ? Number(d[1]) : 0, duration_ms: Date.now() - t0, retried, tests: tests?.command ?? null }),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// FIFO per repo root: the caller awaits its turn, here and in every other
|
|
212
|
+
// process landing into the same root.
|
|
213
|
+
export function land(card, worktree, opts) {
|
|
214
|
+
const key = canonPath(card.repo)
|
|
215
|
+
const prev = queues.get(key) ?? Promise.resolve()
|
|
216
|
+
const mine = prev.catch(() => {}).then(() => withRepoTurn(card.repo, () => landNow(card, worktree, opts)))
|
|
217
|
+
queues.set(key, mine)
|
|
218
|
+
// the bookkeeping chain must never reject on its own: landNow's throw is the
|
|
219
|
+
// caller's to handle (it returns `mine`), and an unhandled rejection here
|
|
220
|
+
// would take the whole board server process down with it
|
|
221
|
+
mine.catch(() => {}).finally(() => { if (queues.get(key) === mine) queues.delete(key) })
|
|
222
|
+
return mine
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function isLanding(repo) { return queues.has(canonPath(repo)) }
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// orchestrator — moves one card through its pipeline: worktree, contract,
|
|
2
|
+
// runner legs, classification (from run.json), chain transitions, handoff
|
|
3
|
+
// bundles, test and (stub) land stations. Every state change goes through the
|
|
4
|
+
// ledger with an actor; the chain machine (src/chain.mjs) decides, this file
|
|
5
|
+
// only executes.
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { transition } from './chain.mjs'
|
|
10
|
+
import { stationIndex } from './pipeline.mjs'
|
|
11
|
+
import { ensure as ensureWorktree } from './worktree.mjs'
|
|
12
|
+
import { renderContract, writeContract, legPrompt } from './contract.mjs'
|
|
13
|
+
import { writeHandoff, loadResume } from './handoff.mjs'
|
|
14
|
+
import { runCommandAsync } from './commands.mjs'
|
|
15
|
+
import { landCard } from './land.mjs'
|
|
16
|
+
import { resolveTestCommand } from './mergequeue.mjs'
|
|
17
|
+
import {
|
|
18
|
+
RUNNER, BATON_ACTOR, readCard, ledgerAppend, ledgerUpdate, cardDir, sleep,
|
|
19
|
+
} from './store.mjs'
|
|
20
|
+
import { scrub, updateRun } from './runner.mjs'
|
|
21
|
+
import * as agentStation from './stations/agent.mjs'
|
|
22
|
+
import * as testStation from './stations/test.mjs'
|
|
23
|
+
import * as humanStation from './stations/human.mjs'
|
|
24
|
+
|
|
25
|
+
// Station kinds that run inside runCard; land stays inline below (it drives
|
|
26
|
+
// the merge queue). Each handler gets the orchestrator's helpers as `ops`.
|
|
27
|
+
const KIND_HANDLERS = { agent: agentStation, test: testStation, human: humanStation }
|
|
28
|
+
|
|
29
|
+
const POLL_MS = Number(process.env.BATON_POLL_MS || 2000)
|
|
30
|
+
const WAITING = ['done', 'failed', 'killed', 'paused', 'waiting_human', 'needs_approval']
|
|
31
|
+
// Patchable card keys the chain machine may change; everything else is a
|
|
32
|
+
// named ledger flag.
|
|
33
|
+
const PATCH_KEYS = ['pipeline', 'leases', 'land_attempts', 'bounce_reason', 'kill_requested', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'pr_url']
|
|
34
|
+
|
|
35
|
+
const log = (msg) => { if (process.env.BATON_QUIET !== '1') process.stderr.write(`[baton] ${msg}\n`) }
|
|
36
|
+
|
|
37
|
+
// Persist a chain transition: card fields via ledger update, events via append.
|
|
38
|
+
export function apply(id, before, result, actor = BATON_ACTOR) {
|
|
39
|
+
const next = result.card
|
|
40
|
+
const patch = {}
|
|
41
|
+
for (const k of PATCH_KEYS) {
|
|
42
|
+
if (JSON.stringify(next[k]) !== JSON.stringify(before[k])) patch[k] = next[k] ?? null
|
|
43
|
+
}
|
|
44
|
+
ledgerUpdate(id, {
|
|
45
|
+
status: next.status !== before.status ? next.status : undefined,
|
|
46
|
+
station: next.station !== before.station ? next.station : undefined,
|
|
47
|
+
leg: next.leg !== before.leg ? next.leg : undefined,
|
|
48
|
+
patch,
|
|
49
|
+
})
|
|
50
|
+
for (const e of result.events) {
|
|
51
|
+
ledgerAppend(id, { actor, type: e.type, summary: e.summary, body: e.body, station: next.station, leg: next.leg })
|
|
52
|
+
}
|
|
53
|
+
return readCard(id)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function step(id, action, payload, actor = BATON_ACTOR) {
|
|
57
|
+
const card = readCard(id)
|
|
58
|
+
if (!card) throw new Error(`card not found: ${id}`)
|
|
59
|
+
return apply(id, card, transition(card, action, payload), actor)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runJsonPath(id, n) { return join(cardDir(id), 'runs', String(n), 'run.json') }
|
|
63
|
+
|
|
64
|
+
function latestRun(id) {
|
|
65
|
+
const dir = join(cardDir(id), 'runs')
|
|
66
|
+
if (!existsSync(dir)) return null
|
|
67
|
+
const ns = readdirSync(dir).map(Number).filter(Number.isInteger).sort((a, b) => b - a)
|
|
68
|
+
for (const n of ns) { try { return JSON.parse(readFileSync(runJsonPath(id, n), 'utf8')) } catch {} }
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A run whose verdict no orchestrator has consumed: the latest run has no
|
|
73
|
+
// settled_at. Happens after `baton down` (agents killed, the supervisor wrote
|
|
74
|
+
// its verdict, the server that would apply it was already gone) or a crashed
|
|
75
|
+
// server. runCard re-attaches to it instead of launching a fresh leg.
|
|
76
|
+
export function unsettledRun(id) {
|
|
77
|
+
const r = latestRun(id)
|
|
78
|
+
return r && !r.settled_at ? r : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A run nobody alive is driving: unsettled and its driver (the orchestrator
|
|
82
|
+
// process that launched or re-attached to it) is gone. The scheduler picks
|
|
83
|
+
// these up; a live driver is left alone so two processes never apply one verdict.
|
|
84
|
+
export function orphanedRun(id) {
|
|
85
|
+
const r = unsettledRun(id)
|
|
86
|
+
return r && !(r.driver_pid && pidAlive(r.driver_pid)) ? r : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The claim on a card: one file per card, created atomically ('wx') at the top
|
|
90
|
+
// of runCard and removed when it returns. run.json's driver_pid only exists
|
|
91
|
+
// once a leg has launched; before that (worktree add, the start step, the
|
|
92
|
+
// contract) a card had no owner, and a `card run` beside a scheduler tick, or
|
|
93
|
+
// two schedulers, could both launch a leg into one worktree.
|
|
94
|
+
function driverLockPath(id) { return join(cardDir(id), 'driver.lock') }
|
|
95
|
+
|
|
96
|
+
function readDriver(id) {
|
|
97
|
+
try { return JSON.parse(readFileSync(driverLockPath(id), 'utf8')) } catch { return null }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The pid of another live process driving this card, else null.
|
|
101
|
+
export function driverAlive(id) {
|
|
102
|
+
const d = readDriver(id)
|
|
103
|
+
return d?.pid && d.pid !== process.pid && pidAlive(d.pid) ? d.pid : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function claimDriver(id) {
|
|
107
|
+
const f = driverLockPath(id)
|
|
108
|
+
for (let i = 0; i < 3; i++) {
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(f, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n', { flag: 'wx' })
|
|
111
|
+
return { ok: true }
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err.code !== 'EEXIST') throw err
|
|
114
|
+
}
|
|
115
|
+
const d = readDriver(id)
|
|
116
|
+
if (d?.pid === process.pid) return { ok: true }
|
|
117
|
+
if (d?.pid && pidAlive(d.pid)) return { ok: false, pid: d.pid }
|
|
118
|
+
// the holder died, or the file is not readable yet: a claim being written
|
|
119
|
+
// right now is younger than a second, a torn one from a crash is not
|
|
120
|
+
let stale = Boolean(d?.pid)
|
|
121
|
+
if (!stale) { try { stale = Date.now() - statSync(f).mtimeMs > 1000 } catch { stale = true } }
|
|
122
|
+
if (!stale) { const t = Date.now() + 50; while (Date.now() < t) { /* spin */ } continue }
|
|
123
|
+
try { unlinkSync(f) } catch {}
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, pid: readDriver(id)?.pid ?? null }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function releaseDriver(id) {
|
|
129
|
+
if (readDriver(id)?.pid === process.pid) { try { unlinkSync(driverLockPath(id)) } catch {} }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function patchRun(id, n, fields) {
|
|
133
|
+
if (!n) return
|
|
134
|
+
try { updateRun(id, n, (cur) => (cur ? { ...cur, ...fields } : null)) } catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function settleRun(id, run) { patchRun(id, run?.run, { settled_at: new Date().toISOString() }) }
|
|
138
|
+
|
|
139
|
+
function pidAlive(pid) {
|
|
140
|
+
if (!pid) return false
|
|
141
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const FINAL_RUN = ['exited', 'killed', 'failed', 'orphaned']
|
|
145
|
+
|
|
146
|
+
async function waitForRun(id, n, { pollMs = POLL_MS } = {}) {
|
|
147
|
+
let deadSince = null
|
|
148
|
+
let interrupted = null
|
|
149
|
+
for (;;) {
|
|
150
|
+
let run = null
|
|
151
|
+
try { run = JSON.parse(readFileSync(runJsonPath(id, n), 'utf8')) } catch {}
|
|
152
|
+
if (run && FINAL_RUN.includes(run.status)) {
|
|
153
|
+
if (run.status === 'orphaned' && !run.outcome) return { ...run, outcome: 'failed', handoff: true, signal: 'none', reason: 'supervisor died before the leg finished' }
|
|
154
|
+
return run
|
|
155
|
+
}
|
|
156
|
+
const supervisorAlive = Boolean(run?.supervisor_pid) && pidAlive(run.supervisor_pid)
|
|
157
|
+
// A human acted on the card (kill, pause, hand off now, reassign): the leg
|
|
158
|
+
// is over for us. Wait for the supervisor to finish writing (it is
|
|
159
|
+
// classifying the kill) so the next leg's launch never finds this run
|
|
160
|
+
// still active, then report the interruption.
|
|
161
|
+
const card = readCard(id)
|
|
162
|
+
if (card && card.status !== 'running') interrupted ??= { ...(run ?? {}), status: 'interrupted', outcome: 'killed', handoff: false }
|
|
163
|
+
if (interrupted && (!run || !supervisorAlive)) return interrupted
|
|
164
|
+
// A dead supervisor never writes `exited`; do not wait on it forever.
|
|
165
|
+
if (run && ['launching', 'running'].includes(run.status) && run.supervisor_pid && !supervisorAlive) {
|
|
166
|
+
deadSince ??= Date.now()
|
|
167
|
+
if (Date.now() - deadSince > 5000) {
|
|
168
|
+
patchRun(id, n, { status: 'orphaned' })
|
|
169
|
+
return { ...run, status: 'orphaned', outcome: 'failed', handoff: true, signal: 'none', reason: `supervisor pid ${run.supervisor_pid} died before the leg finished` }
|
|
170
|
+
}
|
|
171
|
+
} else deadSince = null
|
|
172
|
+
await sleep(pollMs)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function changedFiles(worktree) {
|
|
177
|
+
const r = spawnSync('git', ['status', '--porcelain'], { cwd: worktree, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
178
|
+
if (r.status !== 0) return []
|
|
179
|
+
return r.stdout.split(/\r?\n/).filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => f && !f.startsWith('.baton'))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function diffStat(worktree) {
|
|
183
|
+
const r = spawnSync('git', ['diff', '--stat'], { cwd: worktree, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
184
|
+
return r.status === 0 ? r.stdout : ''
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function runLeg(card, station, worktree) {
|
|
188
|
+
const entry = station.chain[card.leg]
|
|
189
|
+
if (!entry) throw new Error(`no chain entry for leg ${card.leg} at ${station.name}`)
|
|
190
|
+
const resumed = card.leg > 0 || card.resume_from_bundle === true
|
|
191
|
+
const contractText = await renderContract({ card, station, leg: card.leg, entry, worktree, resumed, bounceReason: card.bounce_reason })
|
|
192
|
+
writeContract(worktree, contractText)
|
|
193
|
+
let resumeText = null
|
|
194
|
+
if (resumed) {
|
|
195
|
+
try { resumeText = loadResume(worktree) } catch (err) { log(`no bundle to resume from: ${err.message}`) }
|
|
196
|
+
}
|
|
197
|
+
const prompt = legPrompt({ contractText, resumeText })
|
|
198
|
+
const promptFile = join(cardDir(card.card_id), `prompt-${station.name}-leg${card.leg}.txt`)
|
|
199
|
+
writeFileSync(promptFile, prompt)
|
|
200
|
+
const args = ['launch', '--card', card.card_id, '--adapter', entry.adapter, '--prompt-file', promptFile, '--cwd', worktree, '--driver-pid', String(process.pid)]
|
|
201
|
+
if (entry.mode) args.push('--mode', entry.mode)
|
|
202
|
+
if (entry.maxTurns) args.push('--max-turns', String(entry.maxTurns))
|
|
203
|
+
if (entry.model) args.push('--model', String(entry.model))
|
|
204
|
+
if (entry.network) args.push('--network', '1')
|
|
205
|
+
const extraEnv = {}
|
|
206
|
+
if (entry.fakeMode) {
|
|
207
|
+
// "a;b;c" = one mode per landing attempt (demo: break-test on the first run, fix-test after the bounce)
|
|
208
|
+
const modes = String(entry.fakeMode).split(';').map((m) => m.trim()).filter(Boolean)
|
|
209
|
+
extraEnv.FAKE_MODE = modes[Math.min(card.land_attempts ?? 0, modes.length - 1)]
|
|
210
|
+
}
|
|
211
|
+
if (entry.fakeFixture) extraEnv.FAKE_LIMIT_FIXTURE = entry.fakeFixture
|
|
212
|
+
if (entry.fakeTarget) extraEnv.FAKE_TARGET = entry.fakeTarget
|
|
213
|
+
if (entry.fakeContent) extraEnv.FAKE_CONTENT = entry.fakeContent
|
|
214
|
+
extraEnv.FAKE_TRUNK = card.trunk || 'main'
|
|
215
|
+
if (Object.keys(extraEnv).length) args.push('--env-json', JSON.stringify(extraEnv))
|
|
216
|
+
let launch
|
|
217
|
+
try {
|
|
218
|
+
launch = JSON.parse(execFileSync(process.execPath, [RUNNER, ...args], { windowsHide: true, encoding: 'utf8', env: process.env }))
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const text = String(err.stderr ?? err.stdout ?? err.message)
|
|
221
|
+
return { status: 'failed', outcome: 'launch_failed', handoff: true, signal: 'none', reason: scrub(text).slice(0, 300), run: null, exit_code: null }
|
|
222
|
+
}
|
|
223
|
+
if (card.resume_from_bundle) ledgerUpdate(card.card_id, { patch: { resume_from_bundle: false } })
|
|
224
|
+
const run = await waitForRun(card.card_id, launch.run)
|
|
225
|
+
return run
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export { resolveCommand } from './commands.mjs'
|
|
229
|
+
// async: the test station runs inside the board server's scheduler, and a
|
|
230
|
+
// long suite must not freeze the board (src/commands.mjs)
|
|
231
|
+
const runTestCommand = (cmd, cwd) => runCommandAsync(cmd, cwd, { tailLines: 20 })
|
|
232
|
+
|
|
233
|
+
function handoffOn(card, station, run, worktree, extra = []) {
|
|
234
|
+
const entry = station.chain[card.leg]
|
|
235
|
+
const runDir = run?.run ? join(cardDir(card.card_id), 'runs', String(run.run)) : null
|
|
236
|
+
try {
|
|
237
|
+
const h = writeHandoff({ card, station, leg: card.leg, entry, run, worktree, runDir, extra, changedFiles: changedFiles(worktree), diffStat: diffStat(worktree) })
|
|
238
|
+
ledgerUpdate(card.card_id, { patch: { last_bundle: h.bundle_id } })
|
|
239
|
+
ledgerAppend(card.card_id, {
|
|
240
|
+
type: 'handoff_written', station: station.name, leg: card.leg,
|
|
241
|
+
summary: `handoff bundle ${h.bundle_id} (${h.quality ?? 'unscored'}) after ${run?.outcome ?? 'handoff'}; next: ${station.chain[card.leg + 1]?.adapter ?? 'none'}`,
|
|
242
|
+
body: `notes: ${h.notes_path}`,
|
|
243
|
+
})
|
|
244
|
+
return h
|
|
245
|
+
} catch (err) {
|
|
246
|
+
ledgerAppend(card.card_id, { type: 'error', station: station.name, leg: card.leg, summary: `handoff bundle failed: ${scrub(err.message).slice(0, 200)}` })
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Run a card until it reaches a waiting or terminal state. Returns the card.
|
|
252
|
+
export async function runCard(id, { actor = BATON_ACTOR } = {}) {
|
|
253
|
+
let card = readCard(id)
|
|
254
|
+
if (!card) throw new Error(`card not found: ${id}`)
|
|
255
|
+
const claim = claimDriver(id)
|
|
256
|
+
if (!claim.ok) {
|
|
257
|
+
log(`card ${id} is driven by pid ${claim.pid}; not attaching`)
|
|
258
|
+
return card
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
return await driveCard(id, card, actor)
|
|
262
|
+
} finally { releaseDriver(id) }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function driveCard(id, card, actor) {
|
|
266
|
+
if (card.status === 'backlog') card = step(id, 'enqueue', {}, actor)
|
|
267
|
+
const wt = ensureWorktree(card.repo, card.card_id, { trunk: card.trunk || 'main' })
|
|
268
|
+
if (card.worktree !== wt.path) {
|
|
269
|
+
ledgerUpdate(id, { patch: { worktree: wt.path } })
|
|
270
|
+
card = readCard(id)
|
|
271
|
+
}
|
|
272
|
+
for (;;) {
|
|
273
|
+
card = readCard(id)
|
|
274
|
+
if (WAITING.includes(card.status)) return card
|
|
275
|
+
if (card.status === 'handing_off') {
|
|
276
|
+
// Recovering a crash mid-handoff: finish the handoff step.
|
|
277
|
+
const st = card.pipeline[stationIndex(card.pipeline, card.station)]
|
|
278
|
+
card = step(id, 'bundle_written', {}, actor)
|
|
279
|
+
void st
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
if (card.status === 'queued') {
|
|
283
|
+
card = step(id, 'start', {}, actor)
|
|
284
|
+
if (card.status !== 'running') return card
|
|
285
|
+
}
|
|
286
|
+
if (card.status !== 'running') return card
|
|
287
|
+
const st = card.pipeline[stationIndex(card.pipeline, card.station)]
|
|
288
|
+
if (!st) throw new Error(`card ${id}: unknown station ${card.station}`)
|
|
289
|
+
const handler = KIND_HANDLERS[st.kind]
|
|
290
|
+
if (handler) {
|
|
291
|
+
const r = await handler.run({ id, card, station: st, worktree: wt.path, actor, ops: OPS })
|
|
292
|
+
card = r.card
|
|
293
|
+
if (r.done) return card
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (st.kind === 'land') {
|
|
297
|
+
const r = await landCard(card, wt.path)
|
|
298
|
+
const fresh = readCard(id)
|
|
299
|
+
if (fresh.status !== 'running') {
|
|
300
|
+
// a human acted while the land ran; trunk may have moved anyway, and
|
|
301
|
+
// the ledger must say so even though the card no longer advances
|
|
302
|
+
if (r.landed) ledgerAppend(id, { type: 'landed', station: st.name, leg: 0, summary: `landed on trunk after the card was ${fresh.status}` })
|
|
303
|
+
return fresh
|
|
304
|
+
}
|
|
305
|
+
if (r.bounced) {
|
|
306
|
+
// the failure travels with the card: Open findings of the bounce bundle
|
|
307
|
+
handoffOn(card, { name: st.name, chain: [] }, { outcome: `land-${r.reason}`, reason: r.detail ?? r.reason, exit_code: null, adapter: 'land' }, wt.path,
|
|
308
|
+
[`land failure: ${r.reason}`, r.detail ?? '', ...(r.files?.length ? [`conflicting files: ${r.files.join(', ')}`] : [])].filter(Boolean))
|
|
309
|
+
}
|
|
310
|
+
if (!r.landed && !r.bounced && !r.pr) {
|
|
311
|
+
ledgerAppend(id, { type: 'error', station: st.name, leg: 0, summary: `land failed: ${scrub(r.reason ?? 'unknown').slice(0, 200)}` })
|
|
312
|
+
card = step(id, 'land_result', { bounced: true, reason: r.reason }, actor)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
card = step(id, 'land_result', { ...r, reason: r.detail ? `${r.reason}: ${r.detail}` : r.reason }, actor)
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`unknown station kind ${st.kind}`)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Human actions from the CLI or the board. Returns the updated card.
|
|
323
|
+
export function humanAction(id, action, payload = {}, actor = { type: 'human', id: 'local' }) {
|
|
324
|
+
const card = readCard(id)
|
|
325
|
+
if (!card) throw new Error(`card not found: ${id}`)
|
|
326
|
+
const result = transition(card, action, payload)
|
|
327
|
+
const next = apply(id, card, result, actor)
|
|
328
|
+
if (['kill', 'pause', 'reassign', 'handoff_now'].includes(action) && card.status === 'running') killActiveRun(id)
|
|
329
|
+
return next
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Kill the agent process of the active run. The run record gets
|
|
333
|
+
// kill_requested so the supervisor classifies the exit as `killed` (a human
|
|
334
|
+
// decision), not `stalled` or `failed`. A run still `launching` has no
|
|
335
|
+
// agent_pid yet: its supervisor is killed with its whole tree instead, so an
|
|
336
|
+
// agent it spawns a moment later dies with it rather than running a full leg
|
|
337
|
+
// under a card the board already shows as killed.
|
|
338
|
+
export function killActiveRun(id) {
|
|
339
|
+
const runsDir = join(cardDir(id), 'runs')
|
|
340
|
+
if (!existsSync(runsDir)) return false
|
|
341
|
+
let killed = false
|
|
342
|
+
const runs = readdirSync(runsDir).map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
|
343
|
+
for (const n of runs) {
|
|
344
|
+
let run
|
|
345
|
+
try { run = JSON.parse(readFileSync(join(runsDir, String(n), 'run.json'), 'utf8')) } catch { continue }
|
|
346
|
+
if (!['launching', 'running'].includes(run.status)) continue
|
|
347
|
+
patchRun(id, n, { kill_requested: true })
|
|
348
|
+
if (run.agent_pid) {
|
|
349
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(run.agent_pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
|
|
350
|
+
else { try { process.kill(run.agent_pid, 'SIGKILL') } catch {} }
|
|
351
|
+
killed = true
|
|
352
|
+
} else if (run.supervisor_pid && pidAlive(run.supervisor_pid)) {
|
|
353
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(run.supervisor_pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
|
|
354
|
+
else { try { process.kill(-run.supervisor_pid, 'SIGKILL') } catch { try { process.kill(run.supervisor_pid, 'SIGKILL') } catch {} } }
|
|
355
|
+
killed = true
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return killed
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Helpers handed to the station-kind modules (see src/stations/*.mjs).
|
|
362
|
+
const OPS = {
|
|
363
|
+
readCard, ledgerAppend, step, apply, transition, handoffOn, runLeg, waitForRun,
|
|
364
|
+
unsettledRun, settleRun, patchRun, pidAlive, log, resolveTestCommand, runTestCommand,
|
|
365
|
+
}
|