Skip to content

Commit 6485d20

Browse files
outsourc-eAurora release bot
andauthored
fix(swarm): write runtime.json on stop + sync roster model on start (#238)
Closes #235 — workers stuck after stop because runtime.json was never updated. `POST /api/swarm-tmux-stop` killed the tmux session but left `lifecycle.state` / `phase` / `currentTask` at whatever the last in-process update wrote. The Swarm UI rendered the worker as 'stuck': no tmux session, but lifecycle still says running/blocked. Adds `patchSwarmRuntimeFile` in swarm-foundation, atomic-write into `runtime.json` so unrelated fields survive. Stop handler now patches: state: idle, phase: stopped, currentTask: null, blockedReason: null, checkpointStatus: none, lastDispatchResult: 'Stopped via UI', lastOutputAt: now Best-effort: a runtime-write failure does NOT fail the kill request (the tmux session is already gone — caller deserves to know that). Closes #236 — roster `model:` field was display-only. The wrapper at `~/.local/bin/swarm<N>` invokes `hermes chat --continue` with no `--model` flag, so the per-profile config.yaml wins. Profiles all defaulted to `gpt-5.5`, so the roster value never made it to the model. Adds: - `swarm-model-resolver.ts` — maps roster display labels (e.g. 'Opus 4.7', 'PC1 Coder (97 TPS)', 'GPT-5.5', 'minimax M2.7') to canonical provider+model id pairs. Tolerant: unknown labels return null so the worker is left alone instead of getting wedged. - `swarm-profile-config.ts` — atomic YAML patch for `profiles/<id>/config.yaml` that updates only `model.provider` and `model.default`, preserving sibling fields (toolsets, providers, agent settings). - swarm-tmux-start now resolves the roster model and syncs the profile config before (re)attaching the tmux session. Returns a `modelSync` block in the response so the UI can surface 'now using …' or quietly ignore unknown labels. Tests: - 10 new tests for `patchSwarmRuntimeFile` (4) + resolver (7) + config sync (7) - All 24 pass; pre-existing failing tests on main are unrelated and untouched. Co-authored-by: Aurora release bot <release@outsourc-e.com>
1 parent 57618a0 commit 6485d20

8 files changed

Lines changed: 672 additions & 2 deletions

src/routes/api/swarm-tmux-start.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { existsSync, readFileSync } from 'node:fs'
55
import { homedir } from 'node:os'
66
import { join } from 'node:path'
77
import { isAuthenticated } from '../../server/auth-middleware'
8+
import { rosterByWorkerId } from '../../server/swarm-roster'
9+
import { resolveSwarmModelLabel } from '../../server/swarm-model-resolver'
10+
import { syncSwarmProfileModel } from '../../server/swarm-profile-config'
811

912
// Inlined to avoid SSR module-resolution races against freshly-written
1013
// helpers; mirrors `src/server/claude-paths.ts` getProfilesDir().
@@ -161,6 +164,39 @@ export const Route = createFileRoute('/api/swarm-tmux-start')({
161164
)
162165
}
163166

167+
// Sync the worker's profile config.yaml model section to the
168+
// roster's `model:` label before we (re)attach tmux. Hermes Agent
169+
// reads config.yaml on every invocation, and the wrapper does not
170+
// pass `--model`, so this is the only way the roster value is
171+
// honored. Best-effort: unrecognised labels (typos, custom
172+
// models) are left as-is so a worker never gets wedged. See #236.
173+
let modelSync: {
174+
attempted: boolean
175+
changed: boolean
176+
target?: string
177+
previous?: string
178+
error?: string
179+
} = { attempted: false, changed: false }
180+
try {
181+
const roster = rosterByWorkerId([workerId]).get(workerId)
182+
const resolved = resolveSwarmModelLabel(roster?.model ?? null)
183+
if (resolved) {
184+
modelSync.attempted = true
185+
const result = syncSwarmProfileModel(profilePath, resolved)
186+
if (result.ok) {
187+
modelSync.changed = result.changed
188+
modelSync.target = `${resolved.provider}/${resolved.default}`
189+
if (result.previous) {
190+
modelSync.previous = `${result.previous.provider}/${result.previous.default}`
191+
}
192+
} else {
193+
modelSync.error = result.error
194+
}
195+
}
196+
} catch (err) {
197+
modelSync.error = err instanceof Error ? err.message : String(err)
198+
}
199+
164200
const sessionName = `swarm-${workerId}`
165201
const alreadyRunning = await tmuxHasSession(tmuxBin, sessionName)
166202
if (alreadyRunning) {
@@ -170,6 +206,7 @@ export const Route = createFileRoute('/api/swarm-tmux-start')({
170206
alreadyRunning: true,
171207
started: false,
172208
tmuxBin,
209+
modelSync,
173210
})
174211
}
175212

@@ -194,6 +231,7 @@ export const Route = createFileRoute('/api/swarm-tmux-start')({
194231
started: true,
195232
tmuxBin,
196233
cwd,
234+
modelSync,
197235
})
198236
},
199237
},

src/routes/api/swarm-tmux-stop.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { existsSync } from 'node:fs'
55
import { homedir } from 'node:os'
66
import { join } from 'node:path'
77
import { isAuthenticated } from '../../server/auth-middleware'
8-
// (no profile path needed for stop; only the session name)
8+
import {
9+
getSwarmProfilePath,
10+
patchSwarmRuntimeFile,
11+
} from '../../server/swarm-foundation'
912

1013
/**
1114
* POST /api/swarm-tmux-stop
@@ -119,7 +122,32 @@ export const Route = createFileRoute('/api/swarm-tmux-stop')({
119122
)
120123
}
121124

122-
return json({ workerId, sessionName, wasRunning: true, killed: true })
125+
// Reconcile runtime.json so the Swarm UI doesn't show a 'stuck'
126+
// worker (tmux gone, lifecycle still says running/blocked). Best
127+
// effort — the kill already succeeded, so a write failure here
128+
// should NOT fail the stop request. Reported in #235.
129+
const profilePath = getSwarmProfilePath(workerId)
130+
const stoppedAt = Date.now()
131+
const patchResult = patchSwarmRuntimeFile(profilePath, workerId, {
132+
state: 'idle',
133+
phase: 'stopped',
134+
currentTask: null,
135+
activeTool: null,
136+
needsHuman: false,
137+
blockedReason: null,
138+
checkpointStatus: 'none',
139+
lastDispatchResult: 'Stopped via UI',
140+
lastOutputAt: stoppedAt,
141+
})
142+
143+
return json({
144+
workerId,
145+
sessionName,
146+
wasRunning: true,
147+
killed: true,
148+
runtimePatched: patchResult.ok,
149+
runtimePatchError: patchResult.ok ? undefined : patchResult.error,
150+
})
123151
},
124152
},
125153
},

src/server/swarm-foundation.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,97 @@ describe('parseSwarmPluginManifest', () => {
155155
}
156156
})
157157
})
158+
159+
import { patchSwarmRuntimeFile, readSwarmRuntimeFile } from './swarm-foundation'
160+
161+
describe('patchSwarmRuntimeFile', () => {
162+
it('returns ok=false when the profile path does not exist', () => {
163+
const tempDir = path.join(os.tmpdir(), `patch-runtime-missing-${Date.now()}`)
164+
const result = patchSwarmRuntimeFile(tempDir, 'swarm9', { state: 'idle' })
165+
expect(result.ok).toBe(false)
166+
expect(result.error).toContain('profile path missing')
167+
})
168+
169+
it('writes a fresh runtime.json when none exists', () => {
170+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-runtime-fresh-'))
171+
try {
172+
const result = patchSwarmRuntimeFile(tempDir, 'swarm9', {
173+
state: 'idle',
174+
phase: 'stopped',
175+
currentTask: null,
176+
})
177+
expect(result.ok).toBe(true)
178+
const { source, runtime } = readSwarmRuntimeFile(tempDir, 'swarm9', {
179+
workspaceRoot: tempDir,
180+
})
181+
expect(source).toBe('runtime.json')
182+
expect(runtime.state).toBe('idle')
183+
expect(runtime.phase).toBe('stopped')
184+
expect(runtime.currentTask).toBeNull()
185+
} finally {
186+
fs.rmSync(tempDir, { recursive: true, force: true })
187+
}
188+
})
189+
190+
it('preserves unrelated fields and only overwrites the patched ones', () => {
191+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-runtime-merge-'))
192+
try {
193+
const runtimePath = path.join(tempDir, 'runtime.json')
194+
fs.writeFileSync(
195+
runtimePath,
196+
JSON.stringify({
197+
workerId: 'swarm9',
198+
state: 'blocked',
199+
phase: 'blocked',
200+
currentTask: 'old task',
201+
blockedReason: 'legacy reason',
202+
assignedTaskCount: 7,
203+
customField: 'kept',
204+
}) + '\n',
205+
'utf8',
206+
)
207+
208+
const result = patchSwarmRuntimeFile(tempDir, 'swarm9', {
209+
state: 'idle',
210+
phase: 'stopped',
211+
currentTask: null,
212+
blockedReason: null,
213+
})
214+
expect(result.ok).toBe(true)
215+
216+
const raw = JSON.parse(fs.readFileSync(runtimePath, 'utf8')) as Record<
217+
string,
218+
unknown
219+
>
220+
expect(raw.state).toBe('idle')
221+
expect(raw.phase).toBe('stopped')
222+
expect(raw.currentTask).toBeNull()
223+
expect(raw.blockedReason).toBeNull()
224+
expect(raw.assignedTaskCount).toBe(7)
225+
expect(raw.customField).toBe('kept')
226+
} finally {
227+
fs.rmSync(tempDir, { recursive: true, force: true })
228+
}
229+
})
230+
231+
it('rewrites cleanly even when existing runtime.json is corrupt', () => {
232+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-runtime-corrupt-'))
233+
try {
234+
const runtimePath = path.join(tempDir, 'runtime.json')
235+
fs.writeFileSync(runtimePath, '{ this is not json', 'utf8')
236+
237+
const result = patchSwarmRuntimeFile(tempDir, 'swarm9', {
238+
state: 'idle',
239+
phase: 'stopped',
240+
})
241+
expect(result.ok).toBe(true)
242+
const { runtime } = readSwarmRuntimeFile(tempDir, 'swarm9', {
243+
workspaceRoot: tempDir,
244+
})
245+
expect(runtime.state).toBe('idle')
246+
expect(runtime.phase).toBe('stopped')
247+
} finally {
248+
fs.rmSync(tempDir, { recursive: true, force: true })
249+
}
250+
})
251+
})

src/server/swarm-foundation.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,61 @@ export function readSwarmRuntimeFile(
394394
}
395395
}
396396

397+
/**
398+
* Patch a worker's `runtime.json` in place.
399+
*
400+
* Reads the existing file (if any) so unspecified fields are preserved,
401+
* applies the supplied partial update, and writes atomically. The runtime
402+
* file is the source of truth for `lifecycle.state` / `phase` / `currentTask`
403+
* etc that the Swarm UI renders, so it MUST be kept in sync with operations
404+
* that change worker state out-of-band (for example: tmux kill from
405+
* `swarm-tmux-stop`).
406+
*
407+
* Best-effort: if the profile directory is missing, do nothing. If the
408+
* write fails, log the error but do not throw — callers are usually
409+
* lifecycle hooks that should not fail because state.json bookkeeping
410+
* could not persist.
411+
*/
412+
export function patchSwarmRuntimeFile(
413+
profilePath: string,
414+
workerId: string,
415+
patch: Partial<SwarmRuntime>,
416+
): { ok: boolean; error?: string } {
417+
if (!fs.existsSync(profilePath)) {
418+
return { ok: false, error: `profile path missing: ${profilePath}` }
419+
}
420+
const runtimePath = path.join(profilePath, 'runtime.json')
421+
let existing: Record<string, unknown> = {}
422+
if (fs.existsSync(runtimePath)) {
423+
try {
424+
existing = JSON.parse(fs.readFileSync(runtimePath, 'utf8')) as Record<
425+
string,
426+
unknown
427+
>
428+
} catch {
429+
// Corrupt JSON: fall through and rewrite from scratch using the patch.
430+
existing = {}
431+
}
432+
}
433+
const merged = { ...existing, ...patch, workerId }
434+
const tmpPath = `${runtimePath}.tmp-${process.pid}-${Date.now()}`
435+
try {
436+
fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + '\n', 'utf8')
437+
fs.renameSync(tmpPath, runtimePath)
438+
return { ok: true }
439+
} catch (err) {
440+
try {
441+
fs.unlinkSync(tmpPath)
442+
} catch {
443+
// tmp may not exist if the write failed before creation
444+
}
445+
return {
446+
ok: false,
447+
error: err instanceof Error ? err.message : String(err),
448+
}
449+
}
450+
}
451+
397452
export function listSwarmWorkerIds(options?: { swarmOnly?: boolean }): Array<string> {
398453
const profilesDir = getProfilesDir()
399454
if (!fs.existsSync(profilesDir)) return []
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { resolveSwarmModelLabel } from './swarm-model-resolver'
3+
4+
describe('resolveSwarmModelLabel', () => {
5+
it('returns null for empty / blank / sentinel labels', () => {
6+
expect(resolveSwarmModelLabel(null)).toBeNull()
7+
expect(resolveSwarmModelLabel('')).toBeNull()
8+
expect(resolveSwarmModelLabel(' ')).toBeNull()
9+
expect(resolveSwarmModelLabel('Worker')).toBeNull()
10+
})
11+
12+
it('resolves Anthropic Opus labels', () => {
13+
expect(resolveSwarmModelLabel('Opus 4.7')).toEqual({
14+
provider: 'anthropic-oauth',
15+
default: 'claude-opus-4-7',
16+
})
17+
expect(resolveSwarmModelLabel('Claude Opus 4.6')).toEqual({
18+
provider: 'anthropic-oauth',
19+
default: 'claude-opus-4-6',
20+
})
21+
expect(resolveSwarmModelLabel('opus 4.5')).toEqual({
22+
provider: 'anthropic-oauth',
23+
default: 'claude-opus-4-5',
24+
})
25+
})
26+
27+
it('resolves Claude Sonnet labels', () => {
28+
expect(resolveSwarmModelLabel('Sonnet 4.6')).toEqual({
29+
provider: 'anthropic-oauth',
30+
default: 'claude-sonnet-4-6',
31+
})
32+
expect(resolveSwarmModelLabel('Sonnet 4.5')).toEqual({
33+
provider: 'anthropic',
34+
default: 'claude-sonnet-4-5',
35+
})
36+
})
37+
38+
it('resolves OpenAI Codex labels', () => {
39+
expect(resolveSwarmModelLabel('GPT-5.5')).toEqual({
40+
provider: 'openai-codex',
41+
default: 'gpt-5.5',
42+
})
43+
expect(resolveSwarmModelLabel('GPT 5.4')).toEqual({
44+
provider: 'openai-codex',
45+
default: 'gpt-5.4',
46+
})
47+
expect(resolveSwarmModelLabel('Codex (GPT-5.5)')).toEqual({
48+
provider: 'openai-codex',
49+
default: 'gpt-5.5',
50+
})
51+
})
52+
53+
it('resolves PC1 local labels regardless of TPS qualifier', () => {
54+
expect(resolveSwarmModelLabel('PC1 Coder (97 TPS)')).toEqual({
55+
provider: 'ollama-pc1',
56+
default: 'qwen3-coder-30b-fixed:latest',
57+
})
58+
expect(resolveSwarmModelLabel('PC1 Planner (175 TPS)')).toEqual({
59+
provider: 'ollama-pc1',
60+
default: 'pc1-planner:latest',
61+
})
62+
expect(resolveSwarmModelLabel('PC1 Critic')).toEqual({
63+
provider: 'ollama-pc1',
64+
default: 'pc1-critic:latest',
65+
})
66+
})
67+
68+
it('passes through fully-qualified provider/model ids', () => {
69+
expect(resolveSwarmModelLabel('openai-codex/gpt-5.5')).toEqual({
70+
provider: 'openai-codex',
71+
default: 'gpt-5.5',
72+
})
73+
expect(resolveSwarmModelLabel('anthropic-oauth/claude-opus-4-7')).toEqual({
74+
provider: 'anthropic-oauth',
75+
default: 'claude-opus-4-7',
76+
})
77+
})
78+
79+
it('returns null for unknown labels (so the worker is left alone)', () => {
80+
expect(resolveSwarmModelLabel('Unknown 9000')).toBeNull()
81+
expect(resolveSwarmModelLabel('typo opus')).toBeNull()
82+
})
83+
})

0 commit comments

Comments
 (0)