Opstartpad betrouwbaar maken
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Een team start betrouwbaar, meldt nooit succes zonder bewijs, raakt nooit een ander team aan, en ruimt zichzelf op.
Architecture: Drie gedeelde bouwstenen lossen het grootste deel op. Een sessieregister per team ($RD/sessions) maakt van “elke tmux-sessie die op collab lijkt” een expliciete lijst, zodat postcheck, rescue en cleanup alleen hun eigen team aanraken. Een bewijsfunctie (team_has_evidence) vervangt elke groene melding die nu op een timer of een aanname leunt. En een gedeelde stopfunctie (stop_team_processes) stopt bridge en poller met escalatie van TERM naar KILL, zodat opruimen ook echt opruimt. De rest zijn losse fixes in TypeScript.
Tech Stack: bash 3.2 (macOS systeem-bash), python3 voor JSON, TypeScript met vitest, tmux, launchd.
Global Constraints
- bash 3.2. Geen associatieve arrays, geen
mapfile, geen${var^^}. De Mac draait de oude systeem-bash. - Nederlands in nieuwe comments en commits, zoals de rest van de recente code.
- Geen gedachtestreepjes in tekst die een mens leest (meldingen, commits, docs). Bestaande regels niet aanpassen tenzij de taak ze toch al raakt.
- Chirurgisch wijzigen. Alleen aanraken wat de taak noemt. Geen aangrenzende opmaak of dode code meenemen.
- Nooit een sessielink, transcript of verwijzing naar een AI-gesprek in een commit of bestand. De repo is publiek.
- Runtime-root altijd via
collab-paths.sh. Nooit/tmp/ensemblehardcoden, anders is het script niet testbaar. - Testen draaien met
npx vitest run <pad>, typecheck metnpm run typecheck.
Fase 0: testbaarheid eerst
Zonder deze taak zijn postcheck en rescue niet te testen, want ze negeren COLLAB_RUNTIME_ROOT en schrijven altijd in de echte /tmp/ensemble. Alle latere taken in fase 1 leunen hierop.
Taak 1: postcheck en rescue via de gedeelde padlaag
Files:
- Modify:
scripts/collab-postcheck.sh:16 - Modify:
scripts/collab-rescue.sh:30 - Test:
tests/collab-runtime-root.test.ts(nieuw)
Interfaces:
- Consumes:
collab_runtime_dir <team-id>uitscripts/collab-paths.sh, die$COLLAB_RUNTIME_ROOTrespecteert en terugvalt op/tmp/ensemble. -
Produces: beide scripts accepteren
COLLAB_RUNTIME_ROOT, waarmee latere taken ze in een tijdelijke map kunnen testen. - Step 1: Write the failing test
import fs from 'fs'
import os from 'os'
import path from 'path'
import { execFileSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const POSTCHECK = path.resolve(process.cwd(), 'scripts/collab-postcheck.sh')
const RESCUE = path.resolve(process.cwd(), 'scripts/collab-rescue.sh')
let root: string
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'ensemble-root-')) })
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
function run(script: string, args: string[]): { out: string; code: number } {
try {
const out = execFileSync(script, args, {
env: { ...process.env, COLLAB_RUNTIME_ROOT: root },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
return { out, code: 0 }
} catch (err: any) {
return { out: `${err.stdout ?? ''}${err.stderr ?? ''}`, code: err.status ?? 1 }
}
}
describe('runtime root', () => {
it('postcheck kijkt in COLLAB_RUNTIME_ROOT, niet in /tmp/ensemble', () => {
fs.mkdirSync(path.join(root, 'team-x'), { recursive: true })
fs.writeFileSync(path.join(root, 'team-x', 'messages.jsonl'), '')
const { out } = run(POSTCHECK, ['team-x'])
expect(out).not.toMatch(/Team not found/i)
})
it('rescue kijkt in COLLAB_RUNTIME_ROOT, niet in /tmp/ensemble', () => {
fs.mkdirSync(path.join(root, 'team-y', 'prompts'), { recursive: true })
fs.writeFileSync(path.join(root, 'team-y', 'prompts', 'claude-1.txt'), 'hoi')
const { out } = run(RESCUE, ['team-y'])
expect(out).not.toMatch(/team-dir bestaat niet/i)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/collab-runtime-root.test.ts Expected: FAIL, beide tests melden dat de map niet gevonden is omdat de scripts in /tmp/ensemble kijken.
- Step 3: Write minimal implementation
In scripts/collab-postcheck.sh, vervang regel 16:
# was: RD="/tmp/ensemble/$TEAM_ID"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=collab-paths.sh
. "$SCRIPT_DIR/collab-paths.sh"
RD="$(collab_runtime_dir "$TEAM_ID")"
In scripts/collab-rescue.sh, vervang regel 30 met exact hetzelfde blok. Staat er in een van beide al een SCRIPT_DIR-regel, hergebruik die dan en voeg alleen de source en de RD-toekenning toe.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/collab-runtime-root.test.ts Expected: PASS, 2 tests.
- Step 5: Commit
git add tests/collab-runtime-root.test.ts scripts/collab-postcheck.sh scripts/collab-rescue.sh
git commit -m "fix(collab): postcheck en rescue gingen buiten de gedeelde padlaag om"
Fase 1: een team raakt nooit een ander team aan
Drie scripts denken in team-id’s maar handelen op wat er toevallig in tmux staat. Dat is de zwaarste klasse fouten in dit pad.
Taak 2: launch legt vast welke sessies bij het team horen
Files:
- Modify:
scripts/collab-launch.sh(na het spawnen van de agent-sessies) - Test:
tests/team-sessions.test.ts(nieuw)
Interfaces:
-
Produces:
$RUNTIME_DIR/sessions, één tmux-sessienaam per regel, geschreven vóór de eerste aflevering. Taken 3, 4 en 8 lezen dit bestand. -
Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const LAUNCH = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-launch.sh'), 'utf8')
describe('sessieregister', () => {
it('launch schrijft elke gespawnde sessie naar $RUNTIME_DIR/sessions', () => {
expect(LAUNCH).toMatch(/RUNTIME_DIR\/sessions/)
})
it('het register wordt geschreven voor de prompt-aflevering begint', () => {
const register = LAUNCH.indexOf('$RUNTIME_DIR/sessions')
const deliver = LAUNCH.search(/prompts\/.*\.txt|deliver_/)
expect(register).toBeGreaterThan(-1)
expect(register).toBeLessThan(deliver)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/team-sessions.test.ts Expected: FAIL, “sessions” komt niet voor in het script.
- Step 3: Write minimal implementation
In scripts/collab-launch.sh, direct na de lus die de agent-sessies aanmaakt en vóór de aflevering:
# Leg vast welke tmux-sessies bij dit team horen. Postcheck, rescue en cleanup
# lezen dit in plaats van te raden op de vorm van de sessienaam, want daarmee
# grepen ze ook de sessies van andere teams mee.
: > "$RUNTIME_DIR/sessions"
for s in $SPAWNED_SESSIONS; do
printf '%s\n' "$s" >> "$RUNTIME_DIR/sessions"
done
Bestaat SPAWNED_SESSIONS nog niet, voeg dan in de spawn-lus toe: SPAWNED_SESSIONS="$SPAWNED_SESSIONS $SESSION_NAME", met SPAWNED_SESSIONS="" vóór de lus.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/team-sessions.test.ts Expected: PASS, 2 tests.
- Step 5: Commit
git add tests/team-sessions.test.ts scripts/collab-launch.sh
git commit -m "feat(collab): team legt zijn eigen tmux-sessies vast in runtime/sessions"
Taak 3: postcheck kilt alleen zijn eigen sessies
De postcheck haalt nu elke sessie op die op collab-<cijfers>-<cijfers>-<letters>-<cijfer> lijkt en kilt bij een fout die hele lijst. Draaien er twee teams, dan sloopt het ene het andere. De comment op regel 50-52 belooft al dat andere teams worden overgeslagen, en AGENT_NAME="${s##*-collab-*-}" staat er met de aantekening “imperfect, best effort” maar wordt nergens gelezen.
Files:
- Modify:
scripts/collab-postcheck.sh:34-46en:81 - Test:
tests/postcheck-scope.test.ts(nieuw)
Interfaces:
-
Consumes:
$RD/sessionsuit taak 2. -
Step 1: Write the failing test
import fs from 'fs'
import os from 'os'
import path from 'path'
import { execFileSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const POSTCHECK = path.resolve(process.cwd(), 'scripts/collab-postcheck.sh')
let root: string
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'ensemble-pc-')) })
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
describe('postcheck scope', () => {
it('leest de sessielijst van het team in plaats van tmux te scannen', () => {
const src = fs.readFileSync(POSTCHECK, 'utf8')
expect(src).toMatch(/\$RD\/sessions/)
expect(src).not.toMatch(/tmux ls .*grep -oE "\^collab-/)
})
it('raakt geen sessies aan die niet in het register staan', () => {
const dir = path.join(root, 'team-a')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'sessions'), 'collab-1-1-claude-1\n')
fs.writeFileSync(path.join(dir, 'messages.jsonl'), '')
const out = execFileSync(POSTCHECK, ['team-a'], {
env: { ...process.env, COLLAB_RUNTIME_ROOT: root, COLLAB_POSTCHECK_WAIT: '0' },
encoding: 'utf8',
})
expect(out).not.toMatch(/collab-2-2-codex-1/)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/postcheck-scope.test.ts Expected: FAIL, het script bevat nog de tmux-scan.
- Step 3: Write minimal implementation
Vervang in scripts/collab-postcheck.sh het blok op regel 34-38:
# Alleen de sessies van dit team. Eerder stond hier een tmux-scan op de vorm van
# de sessienaam, waardoor een fout in team A alle sessies van team B meenam.
if [ -f "$RD/sessions" ]; then
SESSIONS=$(cat "$RD/sessions")
else
echo -e "${YEL}!${R} Geen sessieregister voor $TEAM_ID (ouder team?), postcheck slaat over"
exit 0
fi
if [ -z "$SESSIONS" ]; then
echo -e "${YEL}!${R} Sessieregister is leeg, niets te controleren"
exit 0
fi
Verwijder de regel AGENT_NAME="${s##*-collab-*-}" # imperfect — best effort en de twee commentregels erboven die beloven wat de code niet deed. De kill-lus op regel 81 kan blijven staan: die loopt nu over de juiste lijst.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/postcheck-scope.test.ts Expected: PASS, 2 tests.
- Step 5: Commit
git add tests/postcheck-scope.test.ts scripts/collab-postcheck.sh
git commit -m "fix(postcheck): kilde alle collab-sessies in plaats van alleen die van het team"
Taak 4: rescue levert af in het team dat je vraagt
TEAM_ID bepaalt nu alleen waar de prompts vandaan komen. De tmux-basis is de eerste sessie die een -codex-1 heeft, zonder enige vergelijking met het gevraagde id. Gevolg: collab-rescue.sh Y plakt de prompts van Y in de panes van X. En omdat de zoeklus specifiek naar codex-1 kijkt, weigert rescue volledig bij een team zonder codex, wat precies het geval is dat het noodpad zou moeten redden.
Files:
- Modify:
scripts/collab-rescue.sh:44-53en:110-116 - Test:
tests/rescue-scope.test.ts(nieuw)
Interfaces:
-
Consumes:
$RD/sessionsuit taak 2. -
Step 1: Write the failing test
import fs from 'fs'
import os from 'os'
import path from 'path'
import { execFileSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const RESCUE = path.resolve(process.cwd(), 'scripts/collab-rescue.sh')
let root: string
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'ensemble-rs-')) })
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
describe('rescue scope', () => {
it('gebruikt het sessieregister en niet de eerste codex-sessie in tmux', () => {
const src = fs.readFileSync(RESCUE, 'utf8')
expect(src).toMatch(/\$RD\/sessions/)
expect(src).not.toMatch(/\$\{s\}-codex-1/)
})
it('werkt voor een team zonder codex', () => {
const dir = path.join(root, 'team-c', 'prompts')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'claude-1.txt'), 'hoi')
fs.writeFileSync(path.join(root, 'team-c', 'sessions'), 'collab-9-9-claude-1\n')
let out = ''
try {
out = execFileSync(RESCUE, ['team-c'], {
env: { ...process.env, COLLAB_RUNTIME_ROOT: root },
encoding: 'utf8',
})
} catch (err: any) {
out = `${err.stdout ?? ''}${err.stderr ?? ''}`
}
expect(out).not.toMatch(/geen actieve tmux collab-sessie/i)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/rescue-scope.test.ts Expected: FAIL, het script zoekt nog naar ${s}-codex-1.
- Step 3: Write minimal implementation
Vervang in scripts/collab-rescue.sh de zoeklus op regel 44-53:
# De sessies van dit team staan in het register dat launch schrijft. Hiervoor
# pakte rescue de eerste tmux-sessie met een codex-1 erin, dus de prompts van
# dit team belandden in de panes van een ander team. En een team zonder codex
# werd nooit gevonden, precies het geval waarvoor rescue bedoeld is.
if [ ! -f "$RD/sessions" ]; then
echo "FOUT: geen sessieregister voor $TEAM_ID; dit team is niet door deze launcher gestart" >&2
exit 1
fi
Vervang de afleverlus op regel 110-116:
echo "=== prompt-injectie ==="
while IFS= read -r SESSION; do
[ -n "$SESSION" ] || continue
AGENT_NAME="${SESSION##*-}"
PROMPT_FILE="$RD/prompts/$(basename "$SESSION" | sed 's/^.*-\([a-z]*-[0-9]*\)$/\1/').txt"
[ -f "$PROMPT_FILE" ] || { echo " $SESSION: geen promptbestand, overgeslagen"; continue; }
deliver_to_session "$SESSION" "$PROMPT_FILE" "$AGENT_NAME"
done < "$RD/sessions"
Verwijder de regels die TEAMNAME zetten en afdrukken, die variabele bestaat niet meer.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/rescue-scope.test.ts Expected: PASS, 2 tests.
- Step 5: Commit
git add tests/rescue-scope.test.ts scripts/collab-rescue.sh
git commit -m "fix(rescue): leverde af in het eerste team met codex in plaats van het gevraagde team"
Taak 5: preflight kilt de tmux-server niet onder draaiende teams vandaan
Bij een stale DNS-resolver doet preflight tmux kill-server, met als enige rem tmux list-clients. Dat telt alleen attached clients, en agent-panes draaien detached. Een draaiend team wordt dus zonder waarschuwing gesloopt, waarna het script meldt dat nieuwe spawns een frisse resolver krijgen.
Files:
- Modify:
scripts/collab-preflight.sh:178-186 -
Test:
tests/preflight-killserver.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-preflight.sh'), 'utf8')
describe('preflight kill-server', () => {
it('weigert kill-server zolang er collab-sessies draaien', () => {
const blok = SRC.slice(SRC.indexOf('TMUX DNS resolver stale'), SRC.indexOf('TMUX DNS probe inconclusive'))
expect(blok).toMatch(/collab-/)
expect(blok).toMatch(/kill-server/)
const killIndex = blok.indexOf('tmux kill-server')
const guardIndex = blok.search(/COLLAB_SESSIONS|collab-\*/)
expect(guardIndex).toBeGreaterThan(-1)
expect(guardIndex).toBeLessThan(killIndex)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/preflight-killserver.test.ts Expected: FAIL, er staat geen controle op collab-sessies vóór de kill.
- Step 3: Write minimal implementation
Voeg in scripts/collab-preflight.sh vlak vóór tmux kill-server toe:
# list-clients telt alleen attached clients, en agent-panes draaien detached.
# Zonder deze controle sloopt een nieuwe collab de panes van een team dat
# gewoon aan het werk is.
COLLAB_SESSIONS=$(tmux ls 2>/dev/null | grep -c '^collab-' || true)
if [ "${COLLAB_SESSIONS:-0}" -gt 0 ]; then
fail 5 "TMUX DNS is stale, maar er draaien $COLLAB_SESSIONS collab-sessies.
Die zouden door een kill-server verdwijnen. Rond die teams eerst af, of draai
zelf: tmux kill-server"
fi
- Step 4: Run test to verify it passes
Run: npx vitest run tests/preflight-killserver.test.ts Expected: PASS, 1 test.
- Step 5: Commit
git add tests/preflight-killserver.test.ts scripts/collab-preflight.sh
git commit -m "fix(preflight): kill-server sloopte draaiende teams, want detached panes tellen niet als client"
Fase 2: nooit groen zonder bewijs
Taak 6: de codex-probe vraagt iets dat niet in de prompt staat
De probe grept op PROBE-OK-7391 in de hele uitvoer van codex exec, terwijl codex elke prompt terugechoot onder het kopje user. De sentinel staat er dus altijd in. Bewezen met een ingetrokken token: 401 op alles, en toch groen.
Files:
- Modify:
scripts/collab-preflight.sh:235-250 -
Test:
tests/preflight-probe.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-preflight.sh'), 'utf8')
describe('codex-probe', () => {
it('zoekt niet naar een string die zelf in de prompt staat', () => {
const probe = SRC.slice(SRC.indexOf('codex exec'), SRC.indexOf('codex exec') + 900)
const sentinels = probe.match(/PROBE-OK-\d+/g) ?? []
const uniek = new Set(sentinels)
expect(uniek.size, 'dezelfde sentinel in prompt en grep betekent dat de echo hem matcht').toBeLessThan(2)
})
it('vraagt om een berekend antwoord', () => {
const probe = SRC.slice(SRC.indexOf('codex exec') - 400, SRC.indexOf('codex exec') + 900)
expect(probe).toMatch(/7391\s*\+\s*1|som|bereken/i)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/preflight-probe.test.ts Expected: FAIL, prompt en grep gebruiken dezelfde string.
- Step 3: Write minimal implementation
Vervang de probe. De prompt vraagt om een som, de grep zoekt de uitkomst, die nergens in de prompt voorkomt:
# Vraag om een antwoord dat codex moet uitrekenen. Hiervoor stond de sentinel
# zelf in de prompt, en omdat codex elke prompt terugechoot onder 'user' stond
# de string altijd in de uitvoer: een dode codex met een ingetrokken token gaf
# net zo goed groen licht.
CODEX_PROBE_OUT=$(timeout 30 codex exec "Antwoord met het resultaat van 7391 plus 1. Alleen het getal." </dev/null 2>&1 || true)
if printf '%s' "$CODEX_PROBE_OUT" | grep -q '7392'; then
ok "Codex works (probe computed the answer)"
else
fail 4 "Codex antwoordde niet met de uitkomst van de probe.
Laatste regels: $(printf '%s' "$CODEX_PROBE_OUT" | tail -3)"
fi
- Step 4: Run test to verify it passes
Run: npx vitest run tests/preflight-probe.test.ts Expected: PASS, 2 tests.
- Step 5: Commit
git add tests/preflight-probe.test.ts scripts/collab-preflight.sh
git commit -m "fix(preflight): codex-probe matchte op de prompt-echo en keurde een dode codex goed"
Taak 7: “Team is live” alleen bij bewijs
Launch wacht 12 seconden, ziet nul berichten, schrijft “Agents warming up” en meldt daarna alsnog “Team is live!”. De postcheck concludeert later “agents may be in deep work”, wat een lege pane niet is.
Files:
- Modify:
scripts/collab-launch.sh:236-276 - Modify:
scripts/collab-postcheck.sh:90-91 - Test:
tests/launch-evidence.test.ts(nieuw)
Interfaces:
-
Produces:
team_has_evidence <runtime-dir>inscripts/collab-paths.sh, gebruikt door launch en postcheck. Geeft 0 als er minstens één regel inmessages.jsonlstaat of een pane niet-leeg is, anders 1. -
Step 1: Write the failing test
import fs from 'fs'
import os from 'os'
import path from 'path'
import { execFileSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
let root: string
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'ensemble-ev-')) })
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
function evidence(dir: string): number {
const paths = path.resolve(process.cwd(), 'scripts/collab-paths.sh')
try {
execFileSync('bash', ['-c', `. "${paths}" && team_has_evidence "${dir}"`], { encoding: 'utf8' })
return 0
} catch (err: any) {
return err.status ?? 1
}
}
describe('team_has_evidence', () => {
it('is onwaar bij een lege messages.jsonl', () => {
const dir = path.join(root, 'leeg')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'messages.jsonl'), '')
expect(evidence(dir)).toBe(1)
})
it('is waar zodra er een bericht staat', () => {
const dir = path.join(root, 'vol')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'messages.jsonl'), '{"from":"claude-1"}\n')
expect(evidence(dir)).toBe(0)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/launch-evidence.test.ts Expected: FAIL, team_has_evidence: command not found.
- Step 3: Write minimal implementation
Voeg toe aan scripts/collab-paths.sh:
# Bewijs dat een team echt leeft: minstens één regel in messages.jsonl.
# Zonder deze functie meldde launch "Team is live!" terwijl hij net zelf had
# vastgesteld dat er nul berichten waren.
team_has_evidence() {
local rd="$1"
[ -s "$rd/messages.jsonl" ]
}
Vervang in scripts/collab-launch.sh het blok dat “Team is live!” afdrukt:
if team_has_evidence "$RUNTIME_DIR"; then
echo -e "${BD}${GRN}Team is live!${R}"
else
echo -e "${BD}${YEL}Team gestart, maar nog geen enkel bericht na ${WAIT}s.${R}" >&2
echo -e " Dat is hetzelfde beeld als een prompt die de TUI niet heeft gehaald." >&2
echo -e " Controleer met: tmux attach -t \$(head -1 "$RUNTIME_DIR/sessions")" >&2
echo -e " Opnieuw afleveren: scripts/collab-rescue.sh $TEAM_ID" >&2
exit 3
fi
Vervang in scripts/collab-postcheck.sh de melding bij nul berichten:
if [ "$MSG_COUNT" -eq 0 ]; then
echo -e "${RED}✗${R} Nul berichten na ${WAIT}s. Dat is geen diep werk maar een team dat niets doet." >&2
echo -e " Opnieuw afleveren: scripts/collab-rescue.sh $TEAM_ID" >&2
exit 2
fi
- Step 4: Run test to verify it passes
Run: npx vitest run tests/launch-evidence.test.ts && npx vitest run tests/ensemble.test.ts Expected: PASS, beide bestanden groen.
- Step 5: Commit
git add tests/launch-evidence.test.ts scripts/collab-paths.sh scripts/collab-launch.sh scripts/collab-postcheck.sh
git commit -m "fix(collab): team meldde zich live terwijl het nul berichten had"
Taak 8: bridge en poller melden pas succes als ze draaien
Launch start beide met nohup ... & en zet er meteen een vinkje achter. Een bridge die direct op zijn health check stukloopt, ziet niemand.
Files:
- Modify:
scripts/collab-launch.sh:137-138en:234 -
Test:
tests/launch-bridge-alive.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-launch.sh'), 'utf8')
describe('bridge-start', () => {
it('controleert met kill -0 of het proces nog leeft voor het vinkje', () => {
const blok = SRC.slice(SRC.indexOf('ensemble-bridge.sh'), SRC.indexOf('ensemble-bridge.sh') + 700)
expect(blok).toMatch(/kill -0/)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/launch-bridge-alive.test.ts Expected: FAIL, geen kill -0 in het blok.
- Step 3: Write minimal implementation
Na het starten van de bridge:
# Even kijken of hij het overleeft. Hiervoor kwam het vinkje er altijd, ook als
# de bridge meteen stuksloeg op zijn health check en het team dus nooit een
# bericht naar de API zou sturen.
sleep 0.3
BRIDGE_PID=$(cat "$RUNTIME_DIR/bridge.pid" 2>/dev/null || echo "")
if [ -n "$BRIDGE_PID" ] && kill -0 "$BRIDGE_PID" 2>/dev/null; then
echo -e " ${CHECK} Bridge started ${D}(pid $BRIDGE_PID)${R}"
else
echo -e " ${RED}✗${R} Bridge stopte direct, zie /tmp/ensemble-bridge-$TEAM_ID.log" >&2
exit 4
fi
Doe hetzelfde voor de poller op regel 234, met poller.pid en de bijbehorende melding.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/launch-bridge-alive.test.ts Expected: PASS, 1 test.
- Step 5: Commit
git add tests/launch-bridge-alive.test.ts scripts/collab-launch.sh
git commit -m "fix(launch): bridge en poller kregen een vinkje zonder dat iemand keek of ze leefden"
Taak 9: team-read faalt luid
curl -sf ... | python3 ... 2>/dev/null zonder set -e. Server weg of kapotte JSON geeft een lege feed en exit 0, en de agent concludeert dat er niets gezegd is.
Files:
- Modify:
scripts/team-read.sh:5-9 -
Test:
tests/team-read.test.ts(nieuw) - Step 1: Write the failing test
import path from 'path'
import { execFileSync } from 'child_process'
import { describe, expect, it } from 'vitest'
const READ = path.resolve(process.cwd(), 'scripts/team-read.sh')
describe('team-read', () => {
it('geeft een niet-nul exitcode als de API onbereikbaar is', () => {
let code = 0
try {
execFileSync(READ, ['team-x'], {
env: { ...process.env, ENSEMBLE_API: 'http://127.0.0.1:1' },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch (err: any) {
code = err.status ?? 1
}
expect(code).not.toBe(0)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/team-read.test.ts Expected: FAIL, exitcode is 0.
- Step 3: Write minimal implementation
set -euo pipefail
RESPONSE=$(curl -sf "$API/api/ensemble/teams/$TEAM_ID/feed") || {
echo "team-read: API niet bereikbaar op $API" >&2
exit 1
}
printf '%s' "$RESPONSE" | python3 "$SCRIPT_DIR/parse-messages.py" || {
echo "team-read: antwoord van de API was geen bruikbare JSON" >&2
exit 1
}
- Step 4: Run test to verify it passes
Run: npx vitest run tests/team-read.test.ts Expected: PASS, 1 test.
- Step 5: Commit
git add tests/team-read.test.ts scripts/team-read.sh
git commit -m "fix(team-read): lege feed bij een dode API zag eruit als stilte"
Fase 3: opruimen ruimt echt op
Taak 10: bridge en poller stoppen op SIGTERM
47 runtime-mappen, 28 zonder .finished, een bridge die 2 dagen doorliep, en een gewone kill die niets deed.
Files:
- Modify:
scripts/ensemble-bridge.sh,scripts/collab-poller.sh -
Test:
tests/signal-handling.test.ts(nieuw) - Step 1: Write the failing test
import { spawn } from 'child_process'
import path from 'path'
import { describe, expect, it } from 'vitest'
function stopsOnTerm(script: string): Promise<boolean> {
return new Promise(resolve => {
const p = spawn(path.resolve(process.cwd(), script), ['team-x'], { stdio: 'ignore' })
setTimeout(() => p.kill('SIGTERM'), 500)
const timer = setTimeout(() => { p.kill('SIGKILL'); resolve(false) }, 3000)
p.on('exit', () => { clearTimeout(timer); resolve(true) })
})
}
describe('signaalafhandeling', () => {
it('bridge stopt op SIGTERM', async () => {
expect(await stopsOnTerm('scripts/ensemble-bridge.sh')).toBe(true)
}, 10000)
it('poller stopt op SIGTERM', async () => {
expect(await stopsOnTerm('scripts/collab-poller.sh')).toBe(true)
}, 10000)
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/signal-handling.test.ts Expected: FAIL, beide blijven draaien tot de SIGKILL.
- Step 3: Write minimal implementation
Bovenaan beide scripts, na de set-regel:
# Zonder deze trap negeert de lus een gewone kill: de sleep loopt door en de
# subshell krijgt het signaal niet. Daardoor bleven er bridges dagenlang staan.
STOPPEN=0
trap 'STOPPEN=1' TERM INT
En in de hoofdlus, direct na elke sleep:
[ "$STOPPEN" -eq 1 ] && { echo "[$(basename "$0")] gestopt op signaal" >&2; exit 0; }
Vervang elke sleep N in de lus door sleep N & wait $!, zodat het signaal de sleep onderbreekt in plaats van erop te wachten.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/signal-handling.test.ts Expected: PASS, 2 tests.
- Step 5: Commit
git add tests/signal-handling.test.ts scripts/ensemble-bridge.sh scripts/collab-poller.sh
git commit -m "fix(runtime): bridge en poller negeerden SIGTERM en bleven dagen draaien"
Taak 11: cleanup stopt processen voordat het mappen weggooit
Cleanup verwijdert mappen maar stuurt nooit een signaal naar bridge.pid of poller.pid. Mappen mét messages.jsonl en zonder .finished blijven bovendien altijd staan, wat precies de 28 wezen verklaart.
Files:
- Modify:
scripts/collab-cleanup.sh:73-84,:167-168,:195-196 - Modify:
scripts/collab-paths.sh(nieuwe functie) - Test:
tests/collab-cleanup.test.ts(bestaand, uitbreiden)
Interfaces:
-
Produces:
stop_team_processes <runtime-dir>incollab-paths.sh. Stuurt TERM naar de pids uitbridge.pidenpoller.pid, wacht tot 2 seconden, escaleert naar KILL, en verwijdert de pid-bestanden. Ook gebruikt door taak 12. -
Step 1: Write the failing test
Voeg toe aan tests/collab-cleanup.test.ts:
it('stopt het bridge-proces voordat de map verdwijnt', () => {
const dir = makeDir(root, 'team-live', { 'messages.jsonl': '{}\n' }, true)
const child = spawn('sleep', ['30'], { detached: true, stdio: 'ignore' })
fs.writeFileSync(path.join(dir, 'bridge.pid'), String(child.pid))
run(root, '--force')
let leeft = true
try { process.kill(child.pid!, 0) } catch { leeft = false }
expect(leeft, 'bridge moet gestopt zijn').toBe(false)
})
Voeg bovenaan het bestand import { spawn } from 'child_process' toe.
- Step 2: Run test to verify it fails
Run: npx vitest run tests/collab-cleanup.test.ts Expected: FAIL, het proces leeft nog.
- Step 3: Write minimal implementation
In scripts/collab-paths.sh:
# Stop de achtergrondprocessen van een team. Cleanup gooide alleen de map weg,
# waarna de bridge doorliep zonder map: dagenlang, in een lus die nooit eindigt.
stop_team_processes() {
local rd="$1" naam pid wacht
for naam in bridge poller; do
[ -f "$rd/$naam.pid" ] || continue
pid=$(cat "$rd/$naam.pid" 2>/dev/null)
case "$pid" in ''|*[!0-9]*) rm -f "$rd/$naam.pid"; continue ;; esac
kill "$pid" 2>/dev/null || true
wacht=0
while [ "$wacht" -lt 20 ] && kill -0 "$pid" 2>/dev/null; do
sleep 0.1
wacht=$((wacht + 1))
done
kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true
rm -f "$rd/$naam.pid"
done
}
In scripts/collab-cleanup.sh, direct vóór elke rm -rf van een runtime-map:
stop_team_processes "$dir"
Breid de abandoned-definitie uit: een map zonder .finished waarvan de pids niet meer leven en die ouder is dan de drempel, telt ook als verlaten.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/collab-cleanup.test.ts Expected: PASS, alle tests inclusief de nieuwe.
- Step 5: Commit
git add tests/collab-cleanup.test.ts scripts/collab-paths.sh scripts/collab-cleanup.sh
git commit -m "fix(cleanup): mappen werden opgeruimd maar de processen bleven draaien"
Taak 12: cleanupStaleTeams ruimt echt op
Deze functie draait bij elke service-start en zet elk team ouder dan 2 uur op disbanded, zonder sessies te killen of de poller te stoppen. Daarna slaan de watchdog en checkIdleTeams() het team voorgoed over, want die filteren op status === 'active'. Dat is een tweede bron van dezelfde wezen.
Files:
- Modify:
services/ensemble-service.ts:218-238 -
Test:
tests/ensemble.test.ts(bestaand, uitbreiden) - Step 1: Write the failing test
it('cleanupStaleTeams stopt de processen van het team dat het opruimt', async () => {
const team = maakTeam({ status: 'active', createdAt: new Date(Date.now() - 3 * 3600 * 1000).toISOString() })
const gestopt: string[] = []
const service = new EnsembleService({
loadTeams: () => [team],
updateTeam: () => {},
stopTeamProcesses: (id: string) => { gestopt.push(id) },
} as any)
expect(gestopt).toContain(team.id)
})
Pas maakTeam aan de bestaande helper in dit bestand aan als die anders heet.
- Step 2: Run test to verify it fails
Run: npx vitest run tests/ensemble.test.ts -t cleanupStaleTeams Expected: FAIL, stopTeamProcesses wordt nooit aangeroepen.
- Step 3: Write minimal implementation
In cleanupStaleTeams(), vóór de updateTeam-aanroep:
// Een team dat op disbanded gaat zonder dat zijn processen stoppen, wordt
// daarna door de watchdog en checkIdleTeams overgeslagen omdat die op
// status 'active' filteren. De tmux-sessies en de poller blijven dan voor
// altijd draaien.
this.stopTeamProcesses(team.id)
Implementeer stopTeamProcesses(teamId) als een methode die scripts/collab-cleanup.sh niet nodig heeft: kill de sessies uit $RD/sessions via tmux kill-session en roep de bash-functie stop_team_processes aan met execFileSync('bash', ['-c', . scripts/collab-paths.sh && stop_team_processes “${rd}”]).
- Step 4: Run test to verify it passes
Run: npx vitest run tests/ensemble.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/ensemble.test.ts services/ensemble-service.ts
git commit -m "fix(service): stale teams gingen op disbanded terwijl hun processen doorliepen"
Fase 4: geen bericht raakt zoek
Taak 13: de bridge retryt een 429
400 <= e.code < 500 zet success = True en slaat de regel permanent over. Een rate limit of een timeout betekent dus een verloren bericht. Let op de volgorde: taak 16 zet rate limiting weer aan, dus deze taak moet daarvóór.
Files:
- Modify:
scripts/ensemble-bridge.sh:133-137 -
Test:
tests/bridge-retry.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), 'scripts/ensemble-bridge.sh'), 'utf8')
describe('bridge retry', () => {
it('behandelt 429 en 408 als tijdelijk', () => {
expect(SRC).toMatch(/429/)
expect(SRC).not.toMatch(/400 <= e\.code < 500:\s*\n\s*print.*skipping/)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/bridge-retry.test.ts Expected: FAIL, 429 komt niet voor.
- Step 3: Write minimal implementation
except urllib.error.HTTPError as e:
# 429 en 408 zijn tijdelijk. Die vielen hiervoor onder "client error,
# permanent overslaan", waardoor een bericht bij een rate limit
# definitief verdween.
if e.code in (408, 429):
delay = min(30.0, 0.5 * (2 ** attempt))
print(f'[bridge] {e.code} on line {i}, retry {attempt+1}/10 in {delay:.1f}s', file=sys.stderr, flush=True)
if attempt == 9:
break
time.sleep(delay)
continue
if 400 <= e.code < 500:
print(f'[bridge] client error {e.code} on line {i}, skipping: {e}', file=sys.stderr, flush=True)
success = True
break
- Step 4: Run test to verify it passes
Run: npx vitest run tests/bridge-retry.test.ts Expected: PASS, 1 test.
- Step 5: Commit
git add tests/bridge-retry.test.ts scripts/ensemble-bridge.sh
git commit -m "fix(bridge): een 429 gold als permanent en het bericht verdween"
Taak 14: de paste-retry levert niet dubbel af
De retry-lus omvat het plakken én de twee Enters erna. Faalt alleen die laatste Enter, dan wordt de hele prompt opnieuw geplakt.
Files:
- Modify:
lib/agent-runtime.ts:249-262 -
Test:
tests/agent-runtime-paste.test.ts(nieuw) - Step 1: Write the failing test
import { describe, expect, it, vi } from 'vitest'
describe('pasteFromFile', () => {
it('plakt niet opnieuw als alleen de Enter faalt', async () => {
const calls: string[] = []
const exec = vi.fn(async (cmd: string) => {
calls.push(cmd)
if (cmd.includes('send-keys') && calls.filter(c => c.includes('send-keys')).length === 1) {
throw new Error('tmux busy')
}
})
const { pasteFromFile } = await import('../lib/agent-runtime')
await pasteFromFile('sess', '/tmp/x.txt', { exec } as any)
expect(calls.filter(c => c.includes('paste-buffer')).length).toBe(1)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/agent-runtime-paste.test.ts Expected: FAIL, paste-buffer wordt twee keer aangeroepen.
- Step 3: Write minimal implementation
Splits de lus: retry alleen het plakken, en daarna een eigen lus voor de Enters.
let lastErr: unknown
let geplakt = false
for (let attempt = 1; attempt <= 3 && !geplakt; attempt++) {
try {
await execAsync(cmd, { shell: '/bin/bash' })
geplakt = true
} catch (err) {
lastErr = err
if (attempt < 3) await new Promise(r => setTimeout(r, 200 * 2 ** (attempt - 1)))
}
}
if (!geplakt) throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
// De Enters apart, want opnieuw plakken na een geslaagde paste levert de
// prompt een tweede keer af in dezelfde pane.
await new Promise(r => setTimeout(r, 1000))
for (const wacht of [0, 300]) {
if (wacht) await new Promise(r => setTimeout(r, wacht))
try {
await execAsync(`tmux send-keys -t "${sName}" Enter`)
} catch (err) {
console.error(`[runtime] Enter naar ${sName} mislukte:`, err)
}
}
- Step 4: Run test to verify it passes
Run: npx vitest run tests/agent-runtime-paste.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/agent-runtime-paste.test.ts lib/agent-runtime.ts
git commit -m "fix(runtime): een mislukte Enter liet de hele prompt opnieuw plakken"
Taak 15: de .completed-uitzondering werkt
De negatieve lookbehind staat vóór de boundary-groep, die de punt zelf opsnoept. task.completed matcht dus nog steeds en kan auto-disband triggeren.
Files:
- Modify:
services/ensemble-service.ts:67 -
Test:
tests/premature-disband.test.ts(bestaand, uitbreiden) - Step 1: Write the failing test
it('een property met .completed is geen afrondingssignaal', () => {
expect(isCompletionStatement('ik kijk naar promise.completed in de logs')).toBe(false)
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/premature-disband.test.ts Expected: FAIL, geeft true.
- Step 3: Write minimal implementation
// De punt hoort in de boundary-groep zelf, anders kijkt de lookbehind naar het
// teken vóór de punt en doet de uitzondering niets.
/(?:^|[^\p{L}\p{N}_.])completed(?:[^\p{L}\p{N}_]|$)/iu,
- Step 4: Run test to verify it passes
Run: npx vitest run tests/premature-disband.test.ts Expected: PASS, alle tests.
- Step 5: Commit
git add tests/premature-disband.test.ts services/ensemble-service.ts
git commit -m "fix(service): de uitzondering voor .completed stond op de verkeerde plek"
Taak 16: rate limiting geldt weer
path.startsWith('/api/ensemble/') matcht elke route die de server heeft, dus de limiet van 100 per minuut is dode code. Taak 13 moet af zijn, anders verdwijnen berichten zodra dit weer aanstaat.
Files:
- Modify:
server.ts:130-134 -
Test:
tests/ensemble.test.ts(bestaand, uitbreiden) - Step 1: Write the failing test
it('de teams-route is niet vrijgesteld van rate limiting', () => {
const src = fs.readFileSync(path.resolve(process.cwd(), 'server.ts'), 'utf8')
expect(src).not.toMatch(/startsWith\('\/api\/ensemble\/'\)/)
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/ensemble.test.ts -t "rate limiting" Expected: FAIL.
- Step 3: Write minimal implementation
// Hier stond een uitzondering op '/api/ensemble/', wat elke route is die deze
// server heeft. De limiet stond daarmee volledig uit. Alleen de feed van een
// lopend team blijft vrij, want de agents pollen die zelf.
const isFeedPoll = method === 'GET' && /^\/api\/ensemble\/teams\/[^/]+\/feed$/.test(path)
if (!isFeedPoll && isRateLimited(getClientIp(req))) {
return json(res, { error: 'Rate limit exceeded' }, 429, origin)
}
- Step 4: Run test to verify it passes
Run: npx vitest run tests/ensemble.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/ensemble.test.ts server.ts
git commit -m "fix(server): de uitzondering op rate limiting dekte elke route"
Taak 17: de export bij disband kan de service niet neerhalen
void exportObservation(...).then(...) zonder .catch(). Gooit appendMessage() daarbinnen, dan is het een unhandled rejection en stopt het proces.
Files:
- Modify:
services/ensemble-service.ts:1201-1222 -
Test:
tests/memory-export.test.ts(bestaand, uitbreiden) - Step 1: Write the failing test
it('een fout in de afhandeling van de export blijft binnen de handler', async () => {
const boom = () => { throw new Error('schijf vol') }
await expect(
afhandelenExport(Promise.resolve({ ok: false }), boom),
).resolves.toBeUndefined()
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/memory-export.test.ts Expected: FAIL, de fout ontsnapt.
- Step 3: Write minimal implementation
void exportObservation(payload)
.then(result => { /* bestaande afhandeling ongewijzigd */ })
.catch(err => {
// exportObservation gooit zelf nooit, maar appendMessage in de handler
// wel. Zonder deze catch is dat een unhandled rejection, en die haalt
// met de standaardinstelling van Node het hele proces neer.
console.error('[Ensemble] Afhandeling van de memory-export mislukte:', err)
})
- Step 4: Run test to verify it passes
Run: npx vitest run tests/memory-export.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/memory-export.test.ts services/ensemble-service.ts
git commit -m "fix(service): een schijffout bij het exporteren kon de service neerhalen"
Fase 5: sneller, en niet meer herstarten zonder reden
Taak 18: de server wordt beoordeeld op gezondheid, niet op leeftijd
Dit is de directe aanleiding voor “moet die server nou echt telkens herstart worden”. Nee. De leeftijdscheck is een proxy voor de vraag die er werkelijk toe doet: is deze server gestart met werkende credentials. Een server die 30 uur draait en prima werkt, herstarten kost tijd en levert niets op. Een server die na 10 minuten al zonder credentials draait, glipt er nu juist doorheen.
Files:
- Modify:
scripts/collab-preflight.sh:69-122 - Modify:
server.ts(health-endpoint uitbreiden) - Test:
tests/service-health.test.ts(nieuw)
Interfaces:
-
Produces:
/api/v1/healthgeeft{ status, version, credentials: 'ok' | 'missing', uptimeSeconds }. De preflight herstart alleen bijcredentials: 'missing'. -
Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
describe('service-gezondheid', () => {
it('health meldt of de credentials bruikbaar zijn', () => {
const src = fs.readFileSync(path.resolve(process.cwd(), 'server.ts'), 'utf8')
expect(src).toMatch(/credentials/)
})
it('preflight herstart op credentials, niet op leeftijd alleen', () => {
const src = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-preflight.sh'), 'utf8')
const blok = src.slice(src.indexOf('Service age'), src.indexOf('TMUX DNS'))
expect(blok).toMatch(/credentials/)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/service-health.test.ts Expected: FAIL, credentials komt in geen van beide voor.
- Step 3: Write minimal implementation
In server.ts, bij het health-endpoint:
if (path === '/api/v1/health') {
// De preflight herstartte de service ooit puur op leeftijd, als proxy voor
// "is hij met credentials gestart". Dat herstart een gezonde server en laat
// een jonge kapotte server door. Hier is het antwoord zelf te halen.
const credentials = process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN
? 'ok'
: 'missing'
return json(res, {
status: 'healthy',
version: '1.0.0',
credentials,
uptimeSeconds: Math.round(process.uptime()),
}, 200, origin)
}
In scripts/collab-preflight.sh, vervang de hele leeftijdsvergelijking door:
HEALTH=$(curl -sf "$API/api/v1/health" 2>/dev/null || echo "")
CREDS=$(printf '%s' "$HEALTH" | python3 -c "import json,sys; print(json.load(sys.stdin).get('credentials','onbekend'))" 2>/dev/null || echo "onbekend")
if [ "$CREDS" = "missing" ]; then
warn "Service draait zonder credentials, herstarten via launchd ($LAUNCHD_LABEL)"
# bestaande kickstart-code hier
elif [ "$CREDS" = "ok" ]; then
UP=$(printf '%s' "$HEALTH" | python3 -c "import json,sys; print(json.load(sys.stdin).get('uptimeSeconds',0))" 2>/dev/null || echo 0)
ok "Service gezond (draait $((UP / 3600))h, credentials in orde)"
else
warn "Kon de gezondheid van de service niet uitlezen, val terug op de leeftijdscheck"
# bestaande leeftijdscode als fallback
fi
- Step 4: Run test to verify it passes
Run: npx vitest run tests/service-health.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/service-health.test.ts server.ts scripts/collab-preflight.sh
git commit -m "feat(preflight): service wordt beoordeeld op credentials in plaats van op leeftijd"
Taak 19: wachten gaat sneller
De wachtlus slaapt een hele seconde voordat hij voor het eerst kijkt, acht keer achter elkaar. Een server die na 100ms klaar is kost zo altijd minstens een seconde.
Files:
- Modify:
scripts/collab-launch.sh:64 -
Test:
tests/launch-wait.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-launch.sh'), 'utf8')
describe('wachtlus', () => {
it('checkt voordat hij slaapt', () => {
const lus = SRC.slice(SRC.indexOf('for i in'), SRC.indexOf('for i in') + 320)
const check = lus.indexOf('curl')
const slaap = lus.indexOf('sleep')
expect(check).toBeGreaterThan(-1)
expect(check).toBeLessThan(slaap)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/launch-wait.test.ts Expected: FAIL, de sleep staat vóór de check.
- Step 3: Write minimal implementation
# Eerst kijken, dan pas slapen, en in kleinere stappen. Andersom kostte een
# server die na 100ms klaar was altijd minstens een volle seconde.
for i in $(seq 1 40); do
if curl -sf "$API/api/v1/health" > /dev/null 2>&1; then
SERVER_OK=1
break
fi
sleep 0.2
done
- Step 4: Run test to verify it passes
Run: npx vitest run tests/launch-wait.test.ts Expected: PASS, 1 test.
- Step 5: Commit
git add tests/launch-wait.test.ts scripts/collab-launch.sh
git commit -m "perf(launch): wachtlus sliep voor hij keek, elke start kostte een seconde extra"
Fase 6: hygiëne
Taak 20: sleutels worden niet meer als toetsaanslagen getypt
export ${k}="${v}" zonder escaping, terwijl shellEscape() drie regels hoger staat en wel voor het startcommando gebruikt wordt. Gevolg: de API-sleutels staan letterlijk in de scrollback van elke pane.
Files:
- Modify:
lib/agent-spawner.ts:83-89 -
Test:
tests/agent-spawner-env.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), 'lib/agent-spawner.ts'), 'utf8')
describe('env-forwarding', () => {
it('escapet de waarde', () => {
expect(SRC).not.toMatch(/export \$\{k\}="\$\{v\}"/)
expect(SRC).toMatch(/export \$\{k\}=\$\{shellEscape/)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/agent-spawner-env.test.ts Expected: FAIL.
- Step 3: Write minimal implementation
.map(([k, v]) => `export ${k}=${shellEscape(v as string)}`)
- Step 4: Run test to verify it passes
Run: npx vitest run tests/agent-spawner-env.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/agent-spawner-env.test.ts lib/agent-spawner.ts
git commit -m "fix(spawner): doorgegeven env-waarden werden ongeescaped de pane in getypt"
Taak 21: het geheim van de alert-hub staat niet meer in de argv
${ALERT_HUB_URL}?key=${...} gaat als argv naar curl en is daarmee leesbaar in ps.
Files:
- Modify:
services/ensemble-service.ts:392-401 -
Test:
tests/ensemble.test.ts(bestaand, uitbreiden) - Step 1: Write the failing test
it('het hub-geheim staat niet in de URL', () => {
const src = fs.readFileSync(path.resolve(process.cwd(), 'services/ensemble-service.ts'), 'utf8')
expect(src).not.toMatch(/\?key=\$\{encodeURIComponent\(ALERT_HUB_SECRET\)\}/)
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/ensemble.test.ts -t "hub-geheim" Expected: FAIL.
- Step 3: Write minimal implementation
Vervang het curl-subproces door de ingebouwde fetch, dan bestaat er geen argv om uit te lezen:
// Het geheim ging als URL-parameter mee in de argv van curl en was daarmee
// leesbaar in ps voor elke andere gebruiker op de machine.
void fetch(ALERT_HUB_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Alert-Key': ALERT_HUB_SECRET },
body: hubPayload,
signal: AbortSignal.timeout(5000),
}).catch(err => console.error('[Ensemble] Alert-hub onbereikbaar:', err))
- Step 4: Run test to verify it passes
Run: npx vitest run tests/ensemble.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/ensemble.test.ts services/ensemble-service.ts
git commit -m "fix(service): het geheim van de alert-hub stond leesbaar in ps"
Taak 22: de transcript-hook slaat geen bestanden over
for file in $STAGED zonder quotes, dus een bestandsnaam met een spatie wordt gesplitst en de scan op sessielinks slaat hem over. Gezien waar die hook voor staat, is dat het gat dat je niet wil hebben.
Files:
- Modify:
.githooks/pre-commit:36 -
Test:
tests/pre-commit-hook.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), '.githooks/pre-commit'), 'utf8')
describe('pre-commit', () => {
it('leest de bestandslijst regel voor regel', () => {
expect(SRC).not.toMatch(/for file in \$STAGED/)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/pre-commit-hook.test.ts Expected: FAIL.
- Step 3: Write minimal implementation
# Regel voor regel, net als de controle hierboven. Met een gewone for-lus
# splitst de shell op spaties en glipt een bestandsnaam met een spatie erin
# ongezien langs deze scan.
while IFS= read -r file; do
[ -n "$file" ] || continue
if git show ":$file" 2>/dev/null | grep -qE 'claude\.ai/code/session_[A-Za-z0-9]{10,}'; then
note "$file contains a claude.ai session link"
fi
done <<< "$STAGED"
- Step 4: Run test to verify it passes
Run: npx vitest run tests/pre-commit-hook.test.ts Expected: PASS, 1 test.
- Step 5: Commit
git add tests/pre-commit-hook.test.ts .githooks/pre-commit
git commit -m "fix(hooks): bestandsnamen met een spatie glipten langs de scan op sessielinks"
Taak 23: het dode vangnet weg of echt maken
park() schrijft pending-observation.json en niets leest het ooit terug, terwijl de melding aan de gebruiker zegt dat de payload bewaard is voor een latere poging. Elke nieuwe mislukking overschrijft bovendien de vorige.
Files:
- Modify:
lib/memory-export.ts:108-123 - Modify:
services/ensemble-service.ts:1219(de melding) -
Test:
tests/memory-export.test.ts(bestaand, uitbreiden) - Step 1: Write the failing test
it('een tweede mislukking overschrijft de eerste niet', () => {
park(dir, { id: 'een' })
park(dir, { id: 'twee' })
const bestanden = fs.readdirSync(dir).filter(f => f.startsWith('pending-observation'))
expect(bestanden.length).toBe(2)
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/memory-export.test.ts Expected: FAIL, er is één bestand.
- Step 3: Write minimal implementation
export function pendingExportFile(runtimeDir: string): string {
// Een vaste naam betekende dat de tweede mislukking de eerste overschreef, en
// niets las het bestand ooit terug. Een unieke naam per poging maakt de map
// tenminste een eerlijke lijst van wat er niet is aangekomen.
return path.join(runtimeDir, `pending-observation-${Date.now()}-${process.pid}.json`)
}
Pas de melding in services/ensemble-service.ts aan naar wat er werkelijk gebeurt: “Payload bewaard in de runtime-map; er is geen automatische nieuwe poging.”
- Step 4: Run test to verify it passes
Run: npx vitest run tests/memory-export.test.ts && npm run typecheck Expected: PASS en geen typefouten.
- Step 5: Commit
git add tests/memory-export.test.ts lib/memory-export.ts services/ensemble-service.ts
git commit -m "fix(export): geparkeerde payloads overschreven elkaar en de melding klopte niet"
Taak 24: één shellEscape
lib/agent-config.ts:123 en lib/agent-spawner.ts:38 hebben allebei een shellEscape met verschillend gedrag. Na taak 20 hangt de veiligheid van de spawner aan de tweede, dus dit moet er één worden.
Files:
- Modify:
lib/agent-spawner.ts:38-40 - Modify:
lib/agent-config.ts:123-127(exporteren) -
Test:
tests/agent-config-contract.test.ts(bestaand, uitbreiden) - Step 1: Write the failing test
it('er is maar één shellEscape in de codebase', () => {
const spawner = fs.readFileSync(path.resolve(process.cwd(), 'lib/agent-spawner.ts'), 'utf8')
expect(spawner).not.toMatch(/function shellEscape/)
expect(spawner).toMatch(/import .*shellEscape.* from '\.\/agent-config'/)
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/agent-config-contract.test.ts Expected: FAIL.
- Step 3: Write minimal implementation
Exporteer de functie in lib/agent-config.ts (export function shellEscape), verwijder de kopie uit lib/agent-spawner.ts en importeer hem daar. Controleer daarna dat taak 20 nog werkt: de variant in agent-config slaat quoting over voor tokens die al veilig zijn, wat voor env-waarden prima is.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/ && npm run typecheck Expected: PASS, de hele suite groen.
- Step 5: Commit
git add tests/agent-config-contract.test.ts lib/agent-config.ts lib/agent-spawner.ts
git commit -m "refactor(lib): twee versies van shellEscape teruggebracht tot een"
Taak 25: de agent-override is niet meer globaal
/tmp/collab-agents-override.txt en /tmp/collab-team-id.txt zijn vaste paden die elke launch deelt. Twee starts binnen een seconde en het ene team krijgt de agents van het andere. Dat versterkt taak 4, want rescue zonder argument pakt dan ook nog het verkeerde id.
Files:
- Modify:
scripts/collab-preflight.sh:450-455 - Modify:
scripts/collab-launch.sh:39-43en:130 -
Test:
tests/override-scope.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const PRE = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-preflight.sh'), 'utf8')
const LAUNCH = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-launch.sh'), 'utf8')
describe('override', () => {
it('gebruikt geen vast pad in /tmp dat elke launch deelt', () => {
expect(PRE).not.toMatch(/\/tmp\/collab-agents-override\.txt/)
expect(LAUNCH).not.toMatch(/\/tmp\/collab-team-id\.txt/)
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/override-scope.test.ts Expected: FAIL, beide vaste paden staan er nog.
- Step 3: Write minimal implementation
In scripts/collab-preflight.sh, schrijf de override naar een pad dat de aanroeper meegeeft en druk dat pad af op stdout:
# Een vast pad in /tmp is van iedereen: twee gelijktijdige starts lazen elkaars
# override. De aanroeper bepaalt nu waar het bestand staat.
OVERRIDE_FILE="${COLLAB_OVERRIDE_FILE:-$(mktemp -t collab-agents-override)}"
printf '%s\n' "$AGENTS_FALLBACK" > "$OVERRIDE_FILE"
echo "override_file=$OVERRIDE_FILE"
In scripts/collab-launch.sh, lees de override alleen uit $COLLAB_OVERRIDE_FILE als die gezet is, en schrijf het team-id niet meer naar /tmp/collab-team-id.txt maar alleen naar stdout als TEAM_ID=<id>, zoals de comment daar al voorstelt.
- Step 4: Run test to verify it passes
Run: npx vitest run tests/override-scope.test.ts && npx vitest run tests/ensemble.test.ts Expected: PASS, beide groen.
- Step 5: Commit
git add tests/override-scope.test.ts scripts/collab-preflight.sh scripts/collab-launch.sh
git commit -m "fix(collab): gedeelde override-bestanden in /tmp lieten teams elkaars agents pakken"
Taak 26: het herdr-vinkje komt pas als de pane-id er is
collab-launch.sh:182 drukt “Monitor opened” af, en pas op regel 184 wordt geprobeerd de pane-id uit te lezen. Vindt die sed niets, dan komt er geen herdr-pane-id op schijf en blijft de pane na afloop openstaan, terwijl de gebruiker een vinkje zag.
Files:
- Modify:
scripts/collab-launch.sh:181-187 -
Test:
tests/herdr-vinkje.test.ts(nieuw) - Step 1: Write the failing test
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
const SRC = fs.readFileSync(path.resolve(process.cwd(), 'scripts/collab-launch.sh'), 'utf8')
describe('herdr-monitor', () => {
it('leest de pane-id voordat het vinkje verschijnt', () => {
const blok = SRC.slice(SRC.indexOf('open-herdr-monitor.sh'), SRC.indexOf('Falling back to tmux'))
expect(blok.indexOf('HERDR_PANE=')).toBeLessThan(blok.indexOf('Monitor opened'))
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run tests/herdr-vinkje.test.ts Expected: FAIL, het vinkje staat eerst.
- Step 3: Write minimal implementation
HERDR_PANE=$(printf '%s\n' "$HERDR_RESULT" | sed -n 's/.*new_pane_id=\([^ ]*\).*/\1/p' | tail -1)
if [ -n "$HERDR_PANE" ]; then
printf '%s\n' "$HERDR_PANE" > "$RUNTIME_DIR/herdr-pane-id"
echo -e " ${CHECK} Monitor opened ${D}(herdr ${HERDR_MODE})${R}"
else
# Zonder pane-id kan de monitor zijn eigen pane straks niet sluiten, dus dat
# is geen geslaagde start om een vinkje voor te geven.
echo -e " ${YEL}!${R} Monitor gestart maar herdr gaf geen pane-id terug (zie /tmp/collab-herdr-last.log)"
fi
MONITOR_MODE="herdr"
- Step 4: Run test to verify it passes
Run: npx vitest run tests/herdr-vinkje.test.ts Expected: PASS, 1 test.
- Step 5: Commit
git add tests/herdr-vinkje.test.ts scripts/collab-launch.sh
git commit -m "fix(launch): herdr-vinkje verscheen voordat de pane-id gelezen was"
Afsluiten
- Volledige suite en typecheck
Run: npm run test && npm run typecheck Expected: alles groen.
- Echte rookproef
Start een team met twee agents en een opdracht die één antwoord vraagt. Controleer dat runtime/sessions bestaat, dat er berichten in messages.jsonl komen, dat er geen “Team is live” verschijnt als de pane leeg blijft, en dat na afloop geen bridge of poller meer draait (ps aux | grep ensemble-bridge).
- Controle op wezen
Run: ls /tmp/ensemble | wc -l vóór en na een volledige cyclus met scripts/collab-cleanup.sh --force. Het aantal moet dalen en er mag geen proces overblijven.