@automagik/genie 4.260427.4 → 4.260427.5
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/README.md +1 -1
- package/dist/genie.js +2 -2
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/db/migrations/053_master_backfill_and_shadow_cleanup.sql +161 -0
- package/src/db/migrations/agents-kind.test.ts +5 -0
- package/src/db/migrations/master-backfill-and-shadow-cleanup.test.ts +310 -0
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
-->
|
|
26
26
|
|
|
27
27
|
<!-- METRICS:START -->
|
|
28
|
-
**🚀
|
|
28
|
+
**🚀 113 commits** this week · **3 releases** · **+12.5K LoC** · **5 contributors**
|
|
29
29
|
|
|
30
30
|

|
|
31
31
|
|
package/dist/genie.js
CHANGED
|
@@ -236,7 +236,7 @@ ${readFileSync5(params.extraArgs[fileIdx+1],"utf-8")}`,params.extraArgs.splice(f
|
|
|
236
236
|
`).slice(0,10);for(let line of lines){if(!line.includes("custom-title"))continue;let entry=JSON.parse(line);if(entry.type==="custom-title"&&entry.customTitle?.toLowerCase()===needle)return!0}}catch{}return!1}function sessionExists(name,cwd){try{let home=process.env.HOME??"/root",projectDir=ccProjectDirName(cwd??process.cwd()),projectPath=join9(home,".claude","projects",projectDir),files;try{files=readdirSync3(projectPath).filter((f)=>f.endsWith(".jsonl"))}catch{return!1}let needle=name.toLowerCase();return files.some((file)=>{let full=join9(projectPath,file);return fileHasSessionName(full,needle)||fileHasSessionName(full,`${needle}-${needle}`)})}catch{return!1}}var init_team_lead_command=__esm(()=>{init_claude_native_teams();init_genie_config2()});var exports_tmux_wrapper={};__export(exports_tmux_wrapper,{prependEnvVars:()=>prependEnvVars,genieTmuxPrefix:()=>genieTmuxPrefix,genieTmuxCmd:()=>genieTmuxCmd,executeTmux:()=>executeTmux});import{exec as execCallback}from"child_process";import{existsSync as existsSync7,mkdirSync as mkdirSync5}from"fs";import{homedir as homedir6}from"os";import{join as join10}from"path";import{promisify}from"util";function resolveGenieTmuxConf(){let home=homedir6(),genieHome3=process.env.GENIE_HOME??join10(home,".genie");return[join10(genieHome3,"tmux.conf"),join10(__dirname,"..","..","scripts","tmux","genie.tmux.conf"),join10(home,".bun","install","global","node_modules","@automagik","genie","scripts","tmux","genie.tmux.conf")].find((p)=>existsSync7(p))??"/dev/null"}function genieTmuxPrefix(){return["-L",GENIE_TMUX_SOCKET,"-f",resolveGenieTmuxConf()]}function genieTmuxCmd(subcommand){return`${tmuxBin()} ${genieTmuxPrefix().join(" ")} ${subcommand}`}function prependEnvVars(command,env){if(!env||Object.keys(env).length===0)return command;return`env ${Object.entries(env).map(([k,v])=>`${k}=${v}`).join(" ")} ${command}`}function getLogDir(){let logDir=join10(homedir6(),".genie","logs","tmux");if(!existsSync7(logDir))mkdirSync5(logDir,{recursive:!0});return logDir}function stripVerboseFlags(args){return args.filter((arg)=>!/^-v+$/.test(arg))}function isTmuxDebugEnabled(){return process.env.GENIE_TMUX_DEBUG==="1"}async function executeTmux(args){let argList=typeof args==="string"?args.split(/\s+/).filter(Boolean):args,finalArgs=stripVerboseFlags(argList),debugMode=isTmuxDebugEnabled(),options={};if(debugMode)finalArgs=["-v",...finalArgs],options.cwd=getLogDir();finalArgs=[...genieTmuxPrefix(),...finalArgs];let command=`${tmuxBin()} ${finalArgs.join(" ")}`,{stdout}=await exec(command,options);return stdout.trim()}var __dirname="/home/runner/_work/genie/genie/src/lib",exec,GENIE_TMUX_SOCKET;var init_tmux_wrapper=__esm(()=>{init_ensure_tmux();exec=promisify(execCallback),GENIE_TMUX_SOCKET=process.env.GENIE_TMUX_SOCKET||"genie"});var exports_tmux={};__export(exports_tmux,{setWindowEnv:()=>setWindowEnv,resolveRepoSession:()=>resolveRepoSession,listWindows:()=>listWindows,listPanes:()=>listPanes,killWindow:()=>killWindow,killSession:()=>killSession,isTmuxServerReachable:()=>isTmuxServerReachable,isPaneProcessRunning:()=>isPaneProcessRunning,isPaneAlive:()=>isPaneAlive,getWindowEnv:()=>getWindowEnv,getCurrentSessionName:()=>getCurrentSessionName,findWindowByName:()=>findWindowByName,findSessionByName:()=>findSessionByName,executeTmux:()=>executeTmux2,ensureTeamWindow:()=>ensureTeamWindow,createSession:()=>createSession,capturePaneContent:()=>capturePaneContent,applyPaneColor:()=>applyPaneColor,TmuxUnreachableError:()=>TmuxUnreachableError});import{existsSync as existsSync8}from"fs";import{basename as basename2,join as join11}from"path";async function executeTmux2(tmuxCommand){let{executeTmux:wrapperExec}=await Promise.resolve().then(() => (init_tmux_wrapper(),exports_tmux_wrapper));try{return await wrapperExec(tmuxCommand)}catch(error){let message=error instanceof Error?error.message:String(error);throw Error(`Failed to execute tmux command: ${message}`)}}async function getCurrentSessionName(hint){if(process.env.TMUX)try{return(await executeTmux2("display-message -p '#{session_name}'")).trim()||null}catch{return null}try{let sessions=await listSessions();if(sessions.length===0)return null;if(hint){let match=sessions.find((s)=>s.name.includes(hint));if(match)return match.name}return sessions[0].name}catch{return null}}async function listSessions(){try{let output=await executeTmux2("list-sessions -F '#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}'");if(!output)return[];return output.split(`
|
|
237
237
|
`).map((line)=>{let[id,name,attached,windows]=line.split(":");return{id,name,attached:attached==="1",windows:Number.parseInt(windows,10)}})}catch(error){if((error instanceof Error?error.message:String(error)).includes("no server running"))return[];throw error}}async function findSessionByName(name){try{return(await listSessions()).find((session)=>session.name===name)||null}catch(_error){return null}}async function getWindowEnv(target,varName){try{let output=await executeTmux2(`show-environment -t ${shellQuote(target)} ${shellQuote(varName)}`),prefix=`${varName}=`;if(output?.startsWith(prefix))return output.slice(prefix.length).trim();return null}catch{return null}}async function setWindowEnv(target,varName,value){await executeTmux2(`set-environment -t ${shellQuote(target)} ${shellQuote(varName)} ${shellQuote(value)}`)}async function killSession(sessionId){await executeTmux2(`kill-session -t '${sessionId}'`)}async function listWindows(sessionId){try{let output=await executeTmux2(`list-windows -t '=${sessionId}' -F '#{window_id}:#{window_name}:#{window_index}:#{?window_active,1,0}'`);if(!output)return[];return output.split(`
|
|
238
238
|
`).map((line)=>{let[id,name,indexStr,active]=line.split(":");return{id,name,index:Number.parseInt(indexStr,10),active:active==="1",sessionId}})}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("session not found"))return[];throw error}}async function listPanes(windowId){try{let output=await executeTmux2(`list-panes -t '${windowId}' -F '#{pane_id}:#{pane_title}:#{?pane_active,1,0}'`);if(!output)return[];return output.split(`
|
|
239
|
-
`).map((line)=>{let[id,title,active]=line.split(":");return{id,windowId,title,active:active==="1"}})}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("window not found"))return[];throw error}}async function capturePaneContent(paneId,lines=200,includeColors=!1){try{return await executeTmux2(`capture-pane -p ${includeColors?"-e":""} -t '${paneId}' -S -${lines} -E -`)}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("pane not found"))return"";throw error}}async function createSession(name){return await executeTmux2(`new-session -d -s "${name}" -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`),findSessionByName(name)}async function createWindow(sessionId,name,workingDir){let cdFlag=workingDir?` -c '${workingDir.replace(/'/g,"'\\''")}'`:"",output=await executeTmux2(`new-window -d -P -F '#{window_id}:#{window_index}' -t '${sessionId}:' -n '${name}'${cdFlag}`),[windowId,indexStr]=output.trim().split(":");if(!windowId)return null;try{await executeTmux2(`set-window-option -t '${windowId}' automatic-rename off`)}catch{}return{id:windowId,name,index:Number.parseInt(indexStr,10)||0,active:!1,sessionId}}async function findWindowByName(sessionId,name){return(await listWindows(sessionId)).find((w)=>w.name===name)||null}async function ensureMasterWindow(session,masterName){try{let windows=await listWindows(session);if(windows.length<2)return;let masterWindow=windows.find((w)=>w.name===masterName);if(!masterWindow)return;let minIndex=Math.min(...windows.map((w)=>w.index));if(masterWindow.index===minIndex)return;await executeTmux2(`swap-window -s '${session}:${masterWindow.index}' -t '${session}:${minIndex}'`)}catch{}}async function ensureSessionExists(name){try{await executeTmux2(`new-session -d -s "${name}" -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`)}catch(error){if((error instanceof Error?error.message:String(error)).includes("duplicate session"))return;throw error}}async function ensureTeamWindow(session,teamName,workingDir){for(let attempt=0;attempt<3;attempt++)try{return await ensureTeamWindowOnce(session,teamName,workingDir)}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("server exited")||message.includes("error connecting")){if(attempt<2){let delay=250*2**attempt;console.warn(`[genie-tmux] tmux server unreachable (attempt ${attempt+1}/3), retrying in ${delay}ms...`),await new Promise((resolve2)=>setTimeout(resolve2,delay));continue}}throw error}throw Error("Failed to ensure team window after 3 attempts")}async function ensureTeamWindowOnce(session,teamName,workingDir){await ensureSessionExists(session);let existing=await findWindowByName(session,teamName);if(existing){try{await executeTmux2(`set-window-option -t '${existing.id}' automatic-rename off`)}catch{}await rehydratePaneColorHook(existing.id);let panes2=await listPanes(existing.id),paneId2=panes2.length>0?panes2[0].id:`${session}:${teamName}.0`;return{windowId:existing.id,windowName:teamName,paneId:paneId2,created:!1}}let windowsBefore=await listWindows(session),masterBefore=windowsBefore.length>0?windowsBefore.reduce((a,b)=>a.index<=b.index?a:b):null,newWindow=await createWindow(session,teamName,workingDir);if(!newWindow)throw Error(`Failed to create team window "${teamName}" in session "${session}"`);if(masterBefore)await ensureMasterWindow(session,masterBefore.name);await rehydratePaneColorHook(newWindow.id);let panes=await listPanes(newWindow.id),paneId=panes.length>0?panes[0].id:`${session}:${teamName}.0`;return{windowId:newWindow.id,windowName:teamName,paneId,created:!0}}function ensurePaneColorScript(){let{existsSync:existsSync9,writeFileSync:writeFileSync6,mkdirSync:mkdirSync6,chmodSync:chmodSync2}=__require("fs"),{dirname:dirname4}=__require("path");if(existsSync9(PANE_COLOR_SCRIPT))return;mkdirSync6(dirname4(PANE_COLOR_SCRIPT),{recursive:!0});let bin=tmuxBin();writeFileSync6(PANE_COLOR_SCRIPT,`#!/bin/bash
|
|
239
|
+
`).map((line)=>{let[id,title,active]=line.split(":");return{id,windowId,title,active:active==="1"}})}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("window not found"))return[];throw error}}async function capturePaneContent(paneId,lines=200,includeColors=!1){try{return await executeTmux2(`capture-pane -p ${includeColors?"-e":""} -t '${paneId}' -S -${lines} -E -`)}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("pane not found"))return"";throw error}}async function createSession(name){return await executeTmux2(`new-session -d -s "${name}" -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`),findSessionByName(name)}async function createWindow(sessionId,name,workingDir){let cdFlag=workingDir?` -c '${workingDir.replace(/'/g,"'\\''")}'`:"",output=await executeTmux2(`new-window -d -P -F '#{window_id}:#{window_index}' -t '${sessionId}:' -n '${name}'${cdFlag}`),[windowId,indexStr]=output.trim().split(":");if(!windowId)return null;try{await executeTmux2(`set-window-option -t '${windowId}' automatic-rename off`)}catch{}return{id:windowId,name,index:Number.parseInt(indexStr,10)||0,active:!1,sessionId}}async function findWindowByName(sessionId,name){return(await listWindows(sessionId)).find((w)=>w.name===name)||null}async function ensureMasterWindow(session,masterName){try{let windows=await listWindows(session);if(windows.length<2)return;let masterWindow=windows.find((w)=>w.name===masterName);if(!masterWindow)return;let minIndex=Math.min(...windows.map((w)=>w.index));if(masterWindow.index===minIndex)return;await executeTmux2(`swap-window -s '${session}:${masterWindow.index}' -t '${session}:${minIndex}'`)}catch{}}async function ensureSessionExists(name){if(/^[@$]\d+$/.test(name))throw Error(`Refused to create tmux session with id-shaped name "${name}". Names matching /^[@$]\\d+$/ collide with tmux's window-id (@N) and session-id ($N) notation, producing ghost sessions that cannot be safely targeted. Pass a human-readable session name (e.g. the team or agent name) instead.`);try{await executeTmux2(`new-session -d -s "${name}" -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`)}catch(error){if((error instanceof Error?error.message:String(error)).includes("duplicate session"))return;throw error}}async function ensureTeamWindow(session,teamName,workingDir){for(let attempt=0;attempt<3;attempt++)try{return await ensureTeamWindowOnce(session,teamName,workingDir)}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("server exited")||message.includes("error connecting")){if(attempt<2){let delay=250*2**attempt;console.warn(`[genie-tmux] tmux server unreachable (attempt ${attempt+1}/3), retrying in ${delay}ms...`),await new Promise((resolve2)=>setTimeout(resolve2,delay));continue}}throw error}throw Error("Failed to ensure team window after 3 attempts")}async function ensureTeamWindowOnce(session,teamName,workingDir){await ensureSessionExists(session);let existing=await findWindowByName(session,teamName);if(existing){try{await executeTmux2(`set-window-option -t '${existing.id}' automatic-rename off`)}catch{}await rehydratePaneColorHook(existing.id);let panes2=await listPanes(existing.id),paneId2=panes2.length>0?panes2[0].id:`${session}:${teamName}.0`;return{windowId:existing.id,windowName:teamName,sessionName:session,paneId:paneId2,created:!1}}let windowsBefore=await listWindows(session),masterBefore=windowsBefore.length>0?windowsBefore.reduce((a,b)=>a.index<=b.index?a:b):null,newWindow=await createWindow(session,teamName,workingDir);if(!newWindow)throw Error(`Failed to create team window "${teamName}" in session "${session}"`);if(masterBefore)await ensureMasterWindow(session,masterBefore.name);await rehydratePaneColorHook(newWindow.id);let panes=await listPanes(newWindow.id),paneId=panes.length>0?panes[0].id:`${session}:${teamName}.0`;return{windowId:newWindow.id,windowName:teamName,sessionName:session,paneId,created:!0}}function ensurePaneColorScript(){let{existsSync:existsSync9,writeFileSync:writeFileSync6,mkdirSync:mkdirSync6,chmodSync:chmodSync2}=__require("fs"),{dirname:dirname4}=__require("path");if(existsSync9(PANE_COLOR_SCRIPT))return;mkdirSync6(dirname4(PANE_COLOR_SCRIPT),{recursive:!0});let bin=tmuxBin();writeFileSync6(PANE_COLOR_SCRIPT,`#!/bin/bash
|
|
240
240
|
# Genie tmux pane color router \u2014 reads @genie_color pane option
|
|
241
241
|
PANE_ID="$1"
|
|
242
242
|
COLOR=$(${bin} display-message -p -t "$PANE_ID" '#{@genie_color}' 2>/dev/null)
|
|
@@ -1962,7 +1962,7 @@ process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
|
1962
1962
|
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
1963
1963
|
`,{mode:420});let{spawn:spawnChild}=__require("child_process");spawnChild("node",[scriptFile],{detached:!0,stdio:"ignore"}).unref();for(let i2=0;i2<30;i2++)if(await new Promise((r)=>setTimeout(r,100)),isRelayAlive(pidFile))return!0;return!1}catch{return!1}}async function generateWorkerId2(team,role){let base=role?`${team}-${role}`:team;if(!(await list()).some((w)=>w.id===base))return base;let suffix=crypto.randomUUID().slice(0,8);return`${base}-${suffix}`}async function capturePanePid2(paneId){if(paneId==="inline")return null;try{let{execSync:execSync10}=__require("child_process"),output=execSync10(genieTmuxCmd(`display -t '${paneId}' -p '#{pane_pid}'`),{encoding:"utf-8"}).trim(),pid=Number.parseInt(output,10);return pid>0?pid:null}catch{return null}}function resolveExecutorTransport2(provider,spawnTransport){if(provider==="codex")return"api";if(provider==="claude-sdk")return"process";return spawnTransport==="inline"?"process":"tmux"}async function terminateActiveExecutorWithCleanup(agentIdentityId){try{let currentExec=await getCurrentExecutor(agentIdentityId);if(!currentExec||currentExec.state==="terminated"||currentExec.state==="done")return;let provider=getProvider(currentExec.provider);if(provider)try{await provider.terminate(currentExec)}catch{}await terminateActiveExecutor(agentIdentityId)}catch{}}async function createAndLinkExecutor2(agentIdentityId,provider,transport,opts){try{let executor=await createExecutor(agentIdentityId,provider,transport,opts);return await setCurrentExecutor(agentIdentityId,executor.id),executor.id}catch{return null}}async function registerSpawnWorker(ctx,paneId,windowInfo){let nt=ctx.validated.nativeTeam,workerEntry={id:ctx.workerId,paneId,session:ctx.validated.team,provider:ctx.validated.provider,transport:ctx.transport,role:ctx.validated.role,skill:ctx.validated.skill,team:ctx.validated.team,worktree:null,startedAt:ctx.now,state:"spawning",lastStateChange:ctx.now,repoPath:ctx.cwd,nativeTeamEnabled:nt?.enabled??!1,nativeAgentId:`${ctx.agentName}@${ctx.validated.team}`,nativeColor:nt?.color??ctx.spawnColor,parentSessionId:nt?.parentSessionId??ctx.parentSessionId,window:windowInfo?.windowName,windowName:windowInfo?.windowName,windowId:windowInfo?.windowId,autoResume:ctx.autoResume===!1?!1:void 0,resumeAttempts:0};await register(workerEntry);let role=ctx.validated.role??ctx.agentName;if(role!=="council")try{await hireAgent(ctx.validated.team,role)}catch{}return workerEntry}async function notifySpawnJoin(ctx,paneId){let nt=ctx.validated.nativeTeam;if(!nt?.enabled)return;await registerNativeMember(ctx.validated.team,{agentName:ctx.agentName,agentType:nt.agentType??ctx.validated.role??"general-purpose",color:nt.color??ctx.spawnColor??"blue",tmuxPaneId:paneId,cwd:ctx.cwd,planModeRequired:nt.planModeRequired});let leaderName=await resolveTeamLeaderName(ctx.validated.team);await writeNativeInbox(ctx.validated.team,leaderName,{from:ctx.agentName,text:`Worker ${ctx.agentName} (${ctx.validated.provider}) joined team ${ctx.validated.team}. cwd: ${ctx.cwd}. Ready for tasks.`,summary:`${ctx.agentName} (${ctx.validated.provider}) joined`,timestamp:new Date().toISOString(),color:nt.color??ctx.spawnColor??"blue",read:!1})}function registerOtelRelayPane(workerId,paneId,agentName,spawnColor,repoPath){let{writeFileSync:wfs}=__require("fs"),{join:pjoin}=__require("path"),{homedir:hdir}=__require("os"),rd=pjoin(hdir(),".genie","relay");wfs(pjoin(rd,`${workerId}-pane`),paneId),wfs(pjoin(rd,`${workerId}-meta`),JSON.stringify({agent:agentName,color:spawnColor,repoPath}))}function printSpawnInfo(ctx,paneId,workerEntry){let nt=ctx.validated.nativeTeam;if(console.log(`Agent "${ctx.workerId}" spawned.`),console.log(` Provider: ${ctx.launch.provider}`),console.log(` Command: ${ctx.fullCommand}`),console.log(` Team: ${ctx.validated.team}`),console.log(` Pane: ${paneId}`),ctx.validated.role)console.log(` Role: ${ctx.validated.role}`);if(ctx.executorId)console.log(` Executor: ${ctx.executorId}`);if(ctx.validated.skill)console.log(` Skill: ${ctx.validated.skill}`);if(ctx.claudeSessionId)console.log(` Session: ${ctx.claudeSessionId}`);if(console.log(` Layout: ${ctx.layoutMode}`),nt?.enabled)console.log(" Native: enabled"),console.log(` AgentID: ${workerEntry.nativeAgentId}`),console.log(` Color: ${nt.color}`);if(ctx.otelRelayActive)console.log(` OTel: relay on port ${OTEL_RELAY_PORT}`)}function shellQuote2(arg){return`'${arg.replace(/'/g,"'\\''")}'`}function writeTmuxLaunchScript(workerId,fullCommand){let{chmodSync:chmodSync3,mkdirSync:mkdirSync14,writeFileSync:writeFileSync13}=__require("fs"),{join:join41}=__require("path"),{homedir:homedir27}=__require("os"),dir=join41(homedir27(),".genie","spawn-scripts");mkdirSync14(dir,{recursive:!0});let safeId=workerId.replace(/[^a-zA-Z0-9._-]/g,"-"),scriptPath=join41(dir,`${safeId}-${Date.now().toString(36)}.sh`);return writeFileSync13(scriptPath,`#!/bin/sh
|
|
1964
1964
|
exec ${fullCommand}
|
|
1965
|
-
`,{mode:448}),chmodSync3(scriptPath,448),scriptPath}function buildInitialSplitWindowCommand(windowId,cwd,fullCommand){let cwdFlag=cwd?` -c ${shellQuote2(cwd)}`:"";return genieTmuxCmd(`split-window -d -t ${shellQuote2(windowId)}${cwdFlag} -P -F '#{pane_id}' ${shellQuote2(fullCommand)}`)}async function resolveSpawnTeamWindow(team,cwd,sessionOverride){if(!team)return null;try{let sessionName=sessionOverride;if(!sessionName)sessionName=(await getTeam(team))?.tmuxSessionName;if(!sessionName)sessionName=await resolveRepoSession(cwd);if(!sessionName)sessionName=team;return await ensureTeamWindow(sessionName,team,cwd)}catch(err){return console.warn(`Warning: could not ensure team window for "${team}": ${err instanceof Error?err.message:err}`),null}}async function autoConfirmTrustPrompt(paneId){let{execSync:execSync10}=__require("child_process"),maxWaitMs=15000,pollMs=500,start=Date.now();while(Date.now()-start<15000){await new Promise((r)=>setTimeout(r,500));let content;try{content=execSync10(genieTmuxCmd(`capture-pane -t '${paneId}' -p`),{encoding:"utf-8"})}catch{return}if(content.includes("trust this folder")||content.includes("Quick safety check")){try{execSync10(genieTmuxCmd(`send-keys -t '${paneId}' Enter`),{encoding:"utf-8"})}catch{}return}if(content.includes("Claude Code")||content.includes("\u276F")||content.includes("Churning"))return}}function createTmuxPane(ctx,teamWindow){let{execSync:execSync10}=__require("child_process"),useLaunchScript=ctx.validated.provider==="claude"&&Boolean(ctx.validated.nativeTeam?.enabled),tmuxCommand=useLaunchScript?shellQuote2(writeTmuxLaunchScript(ctx.workerId,ctx.fullCommand)):shellQuote2(ctx.fullCommand),tmuxPrefix=genieTmuxCmd("");if(ctx.validated.windowTarget){let cwdFlag2=ctx.cwd?` -c ${shellQuote2(ctx.cwd)}`:"",cmd=`${tmuxPrefix}split-window -d -t ${shellQuote2(ctx.validated.windowTarget)}${cwdFlag2} -P -F '#{pane_id}' ${tmuxCommand}`;return execSync10(cmd,{encoding:"utf-8"}).trim()}if(ctx.validated.newWindow){let session=ctx.sessionOverride??teamWindow?.
|
|
1965
|
+
`,{mode:448}),chmodSync3(scriptPath,448),scriptPath}function buildInitialSplitWindowCommand(windowId,cwd,fullCommand){let cwdFlag=cwd?` -c ${shellQuote2(cwd)}`:"";return genieTmuxCmd(`split-window -d -t ${shellQuote2(windowId)}${cwdFlag} -P -F '#{pane_id}' ${shellQuote2(fullCommand)}`)}async function resolveSpawnTeamWindow(team,cwd,sessionOverride){if(!team)return null;try{let sessionName=sessionOverride;if(!sessionName)sessionName=(await getTeam(team))?.tmuxSessionName;if(!sessionName)sessionName=await resolveRepoSession(cwd);if(!sessionName)sessionName=team;return await ensureTeamWindow(sessionName,team,cwd)}catch(err){return console.warn(`Warning: could not ensure team window for "${team}": ${err instanceof Error?err.message:err}`),null}}async function autoConfirmTrustPrompt(paneId){let{execSync:execSync10}=__require("child_process"),maxWaitMs=15000,pollMs=500,start=Date.now();while(Date.now()-start<15000){await new Promise((r)=>setTimeout(r,500));let content;try{content=execSync10(genieTmuxCmd(`capture-pane -t '${paneId}' -p`),{encoding:"utf-8"})}catch{return}if(content.includes("trust this folder")||content.includes("Quick safety check")){try{execSync10(genieTmuxCmd(`send-keys -t '${paneId}' Enter`),{encoding:"utf-8"})}catch{}return}if(content.includes("Claude Code")||content.includes("\u276F")||content.includes("Churning"))return}}function createTmuxPane(ctx,teamWindow){let{execSync:execSync10}=__require("child_process"),useLaunchScript=ctx.validated.provider==="claude"&&Boolean(ctx.validated.nativeTeam?.enabled),tmuxCommand=useLaunchScript?shellQuote2(writeTmuxLaunchScript(ctx.workerId,ctx.fullCommand)):shellQuote2(ctx.fullCommand),tmuxPrefix=genieTmuxCmd("");if(ctx.validated.windowTarget){let cwdFlag2=ctx.cwd?` -c ${shellQuote2(ctx.cwd)}`:"",cmd=`${tmuxPrefix}split-window -d -t ${shellQuote2(ctx.validated.windowTarget)}${cwdFlag2} -P -F '#{pane_id}' ${tmuxCommand}`;return execSync10(cmd,{encoding:"utf-8"}).trim()}if(ctx.validated.newWindow){let session=ctx.sessionOverride??teamWindow?.sessionName??ctx.validated.team,cwdFlag2=ctx.cwd?` -c ${shellQuote2(ctx.cwd)}`:"",sessionExists2=!1;try{execSync10(`${tmuxPrefix}has-session -t ${shellQuote2(`=${session}`)}`,{stdio:"ignore"}),sessionExists2=!0}catch{sessionExists2=!1}if(!sessionExists2)execSync10(`${tmuxPrefix}new-session -d -s ${shellQuote2(session)} -n home${cwdFlag2}`,{stdio:"ignore"});let cmd=`${tmuxPrefix}new-window -a -d -t ${shellQuote2(`${session}:`)} -n claude${cwdFlag2} -P -F '#{pane_id}' ${tmuxCommand}`;return execSync10(cmd,{encoding:"utf-8"}).trim()}if(teamWindow?.created){let cwdFlag2=ctx.cwd?` -c ${shellQuote2(ctx.cwd)}`:"",paneId=execSync10(`${tmuxPrefix}split-window -d -t ${shellQuote2(teamWindow.windowId)}${cwdFlag2} -P -F '#{pane_id}' ${tmuxCommand}`,{encoding:"utf-8"}).trim();try{execSync10(genieTmuxCmd(`kill-pane -t '${teamWindow.paneId}'`),{stdio:"ignore"})}catch{}return paneId}let callerPane=process.env.TMUX_PANE;if(!teamWindow&&!callerPane)throw Error("createTmuxPane: refusing to split with no target \u2014 neither teamWindow nor TMUX_PANE is set. "+"This indicates a missing --team or --window flag, or a caller outside tmux. See ~/.genie/reports/trace-genie-spawn-wrong-window.md");let splitTarget=teamWindow?`-t '${teamWindow.windowId}'`:`-t '${callerPane}'`,cwdFlag=ctx.cwd?`-c '${ctx.cwd}'`:"";if(useLaunchScript){let splitCmd2=`${tmuxPrefix}split-window -d ${splitTarget} ${cwdFlag} -P -F '#{pane_id}' ${tmuxCommand}`;return execSync10(splitCmd2,{encoding:"utf-8"}).trim()}let escapedCmd=ctx.fullCommand.replace(/'/g,"'\\''"),splitCmd=`${tmuxPrefix}split-window -d ${splitTarget} ${cwdFlag} -P -F '#{pane_id}' '${escapedCmd}'`;return execSync10(splitCmd,{encoding:"utf-8"}).trim()}async function applySpawnLayout(ctx,teamWindow){let{execSync:execSync10}=__require("child_process"),session=await getCurrentSessionName()??ctx.validated.team,layoutTarget=`${session}:${teamWindow?.windowName??""}`;if(!teamWindow){let wins=await listWindows(session);layoutTarget=wins[0]?wins[0].id:`${session}:`}try{execSync10(genieTmuxCmd(buildLayoutCommand(layoutTarget,ctx.layoutMode)),{stdio:"ignore"})}catch{}}async function createTmuxExecutor(ctx,paneId,pid,teamWindow){if(!ctx.agentIdentityId||!ctx.executorId)return;await createAndLinkExecutor2(ctx.agentIdentityId,ctx.validated.provider,resolveExecutorTransport2(ctx.validated.provider,"tmux"),{id:ctx.executorId,pid,tmuxSession:ctx.validated.team,tmuxPaneId:paneId,tmuxWindow:teamWindow?.windowName??null,tmuxWindowId:teamWindow?.windowId??null,claudeSessionId:ctx.claudeSessionId??null,state:"spawning",repoPath:ctx.cwd,paneColor:ctx.spawnColor})}async function finalizeTmuxSpawn(ctx,paneId,teamWindow,workerEntry){if(ctx.spawnColor&&paneId!=="inline")await applyPaneColor(paneId,ctx.spawnColor,teamWindow?.windowId);if(await saveTemplate({id:ctx.validated.role??ctx.workerId,provider:ctx.validated.provider,team:ctx.validated.team,role:ctx.validated.role,skill:ctx.validated.skill,cwd:ctx.cwd,extraArgs:ctx.extraArgs,nativeTeamEnabled:workerEntry.nativeTeamEnabled,lastSpawnedAt:new Date().toISOString()}),ctx.otelRelayActive&&paneId!=="%0")registerOtelRelayPane(ctx.workerId,paneId,ctx.agentName,ctx.spawnColor,ctx.cwd);if(teamWindow)console.log(` Window: ${teamWindow.windowName} (${teamWindow.windowId})`);printSpawnInfo(ctx,paneId,workerEntry)}async function awaitAgentReadiness(paneId){if(paneId==="inline")return;let result2=await waitForAgentReady(paneId);if(result2.ready)console.log(` \u2713 Agent ready (${(result2.elapsedMs/1000).toFixed(1)}s)`);else console.log(` \u26A0 Agent readiness timeout (${Math.round(result2.elapsedMs/1000)}s) \u2014 proceeding anyway`)}async function launchTmuxSpawn(ctx){let isolatedSessionSpawn=ctx.validated.newWindow===!0&&Boolean(ctx.sessionOverride),teamWindow=ctx.spawnIntoCurrentWindow||isolatedSessionSpawn?null:await resolveSpawnTeamWindow(ctx.validated.team,ctx.cwd,ctx.sessionOverride),paneId;try{paneId=createTmuxPane(ctx,teamWindow)}catch(err){return console.error(`Failed to create tmux pane: ${err instanceof Error?err.message:"unknown error"}`),process.exit(1)}let pid=await capturePanePid2(paneId);if(await createTmuxExecutor(ctx,paneId,pid,teamWindow),await applySpawnLayout(ctx,teamWindow),ctx.validated.provider==="claude")await autoConfirmTrustPrompt(paneId);let workerEntry=await registerSpawnWorker(ctx,paneId,teamWindow);if(await notifySpawnJoin(ctx,paneId),await finalizeTmuxSpawn(ctx,paneId,teamWindow,workerEntry),await awaitAgentReadiness(paneId),ctx.executorId)await updateExecutorState(ctx.executorId,"running").catch(()=>{});return await update(ctx.workerId,{state:"idle"}).catch(()=>{}),paneId}async function runSdkQuery(ctx,permConfig,streamOpts,sdkConfig,runtimeExtraOptions){let{ClaudeSdkProvider:ClaudeSdkProvider2}=await Promise.resolve().then(() => (init_claude_sdk(),exports_claude_sdk)),{startSession:startSession2,recordTurn:recordTurn2,updateTurnCount:updateTurnCount2,endSession:endSession2}=await Promise.resolve().then(() => exports_sdk_session_capture),{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db)),sdkProvider=new ClaudeSdkProvider2,spawnContext={agentId:ctx.agentIdentityId??ctx.workerId,executorId:ctx.executorId??crypto.randomUUID(),team:ctx.validated.team,role:ctx.validated.role,skill:ctx.validated.skill,cwd:ctx.cwd,model:ctx.validated.model,systemPrompt:ctx.validated.systemPrompt,systemPromptFile:ctx.validated.systemPromptFile,initialPrompt:ctx.validated.initialPrompt,name:ctx.validated.name},safePgCall=async(_op,fn,fallback)=>{try{let sql=await getConnection2();return await fn(sql)}catch{return fallback}},prompt2=ctx.validated.initialPrompt??`You are ${ctx.validated.role??"an agent"} on team "${ctx.validated.team}". Awaiting instructions.`,resumeSessionId=typeof runtimeExtraOptions?.resume==="string"?runtimeExtraOptions.resume:void 0,dbSessionId=null,turnIndex=0;if(resumeSessionId){let resolvedClaudeSessionId=resumeSessionId,byPgId=await safePgCall("resolve-session-resume",(sql)=>sql`
|
|
1966
1966
|
SELECT s.id, s.total_turns, COALESCE(s.claude_session_id, e.claude_session_id) as csid
|
|
1967
1967
|
FROM sessions s
|
|
1968
1968
|
LEFT JOIN executors e ON e.id = s.executor_id
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genie",
|
|
3
|
-
"version": "4.260427.
|
|
3
|
+
"version": "4.260427.5",
|
|
4
4
|
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Namastex Labs"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
-- 053_master_backfill_and_shadow_cleanup.sql
|
|
2
|
+
--
|
|
3
|
+
-- master-aware-spawn wish, Wave 2, Group 14 (sub-deliverables 14a + 14b).
|
|
4
|
+
--
|
|
5
|
+
-- The 2026-04-25 power-outage post-mortem surfaced that masters today fall
|
|
6
|
+
-- into two shadow patterns in PG (twin's analysis at
|
|
7
|
+
-- /tmp/genie-recover/group-1-shadow-analysis.json):
|
|
8
|
+
--
|
|
9
|
+
-- Type A — `dir:<name>` + bare-name pair (only `email` today). Both rows
|
|
10
|
+
-- exist; `findLiveWorkerFuzzy(name)` returns the bare row first,
|
|
11
|
+
-- so Group 1's `worker?.id ?? \`dir:\${recipientId}\`` chokepoint
|
|
12
|
+
-- fallback never fires. Result: master `email` re-spawns fresh
|
|
13
|
+
-- every time the live worker dies.
|
|
14
|
+
--
|
|
15
|
+
-- Type B — UUID + bare-name pair, NO `dir:<name>` row (`felipe`, `genie`,
|
|
16
|
+
-- `genie-pgserve`). The bare row's `custom_name=''` blocks the
|
|
17
|
+
-- jsonl-scan fallback (Group 7), and there is no `dir:` row for
|
|
18
|
+
-- Group 1's chokepoint to anchor on. Result: post-`unregister`
|
|
19
|
+
-- recovery is impossible without manual surgery.
|
|
20
|
+
--
|
|
21
|
+
-- This migration closes both gaps in two passes:
|
|
22
|
+
--
|
|
23
|
+
-- 1. **14b — master backfill:** for every agent row with
|
|
24
|
+
-- `kind='permanent' AND repo_path != ''` whose canonical name (custom_name
|
|
25
|
+
-- or role fallback) lacks a `dir:<name>` peer, create the missing
|
|
26
|
+
-- directory row using the bare row's identity columns. Brings
|
|
27
|
+
-- `dir:felipe`, `dir:genie`, `dir:genie-pgserve` into existence.
|
|
28
|
+
--
|
|
29
|
+
-- 2. **14a — bare-name shadow cleanup:** for every `dir:<name>` row that
|
|
30
|
+
-- pairs with a non-UUID, non-dir bare-name row whose
|
|
31
|
+
-- `current_executor_id IS NULL`, archive (state='archived',
|
|
32
|
+
-- auto_resume=false) the bare row. **Heal-not-wipe** — never DELETE.
|
|
33
|
+
-- The Group 3 guardrail in `src/lib/agent-directory.ts:rm()` blocks
|
|
34
|
+
-- DELETE on `kind='permanent' AND repo_path != ''` regardless, but
|
|
35
|
+
-- a SQL-side UPDATE bypasses that lock by design.
|
|
36
|
+
--
|
|
37
|
+
-- 14b runs before 14a so the dir-rows we just created can pair with their
|
|
38
|
+
-- bare shadows in the same migration. After this migration runs:
|
|
39
|
+
--
|
|
40
|
+
-- - `dir:email`, `dir:felipe`, `dir:genie`, `dir:genie-pgserve` all exist
|
|
41
|
+
-- and carry `repo_path`. Group 1's chokepoint extension covers all four.
|
|
42
|
+
-- - bare `email`, `felipe`, `genie`, `genie-pgserve` rows with
|
|
43
|
+
-- `current_executor_id IS NULL` are archived. registry.get(name) now
|
|
44
|
+
-- returns either nothing (so worker is null → Group 1 fallback fires) or
|
|
45
|
+
-- the dir:<name> row directly.
|
|
46
|
+
--
|
|
47
|
+
-- Idempotent: each pass gates on a NOT-EXISTS / DISTINCT-FROM-archived
|
|
48
|
+
-- predicate. Re-running the migration affects zero additional rows.
|
|
49
|
+
--
|
|
50
|
+
-- Audit: every backfilled row emits `directory.master_backfilled`; every
|
|
51
|
+
-- archived bare shadow emits `state_changed` with reason
|
|
52
|
+
-- `bare_name_shadow_archived`.
|
|
53
|
+
|
|
54
|
+
-- ---------------------------------------------------------------------------
|
|
55
|
+
-- Pass 1 (14b): backfill dir:<name> rows for masters that lack one.
|
|
56
|
+
-- ---------------------------------------------------------------------------
|
|
57
|
+
WITH
|
|
58
|
+
-- Pick one canonical source row per "name" equivalence class. The bare
|
|
59
|
+
-- row of a Type-B pair (custom_name='' but role + repo_path set) is the
|
|
60
|
+
-- only source carrying repo_path, so we filter on repo_path-non-empty
|
|
61
|
+
-- candidates here. Prefer rows with non-empty custom_name when both
|
|
62
|
+
-- candidates exist (NULLIF + ORDER BY in DISTINCT ON).
|
|
63
|
+
backfill_targets AS (
|
|
64
|
+
SELECT DISTINCT ON (COALESCE(NULLIF(a.custom_name, ''), a.role))
|
|
65
|
+
COALESCE(NULLIF(a.custom_name, ''), a.role) AS name,
|
|
66
|
+
a.role,
|
|
67
|
+
a.team,
|
|
68
|
+
a.repo_path
|
|
69
|
+
FROM agents a
|
|
70
|
+
WHERE a.kind = 'permanent'
|
|
71
|
+
AND a.id NOT LIKE 'dir:%'
|
|
72
|
+
AND a.repo_path IS NOT NULL AND a.repo_path <> ''
|
|
73
|
+
AND COALESCE(NULLIF(a.custom_name, ''), a.role) IS NOT NULL
|
|
74
|
+
AND a.auto_resume = true
|
|
75
|
+
AND a.state IS DISTINCT FROM 'archived'
|
|
76
|
+
AND NOT EXISTS (
|
|
77
|
+
SELECT 1 FROM agents d
|
|
78
|
+
WHERE d.id = 'dir:' || COALESCE(NULLIF(a.custom_name, ''), a.role)
|
|
79
|
+
)
|
|
80
|
+
ORDER BY
|
|
81
|
+
COALESCE(NULLIF(a.custom_name, ''), a.role),
|
|
82
|
+
-- Prefer rows that already populate custom_name (UUID peer in Type B),
|
|
83
|
+
-- so role/team fields come from the canonical identity row.
|
|
84
|
+
(CASE WHEN a.custom_name IS NOT NULL AND a.custom_name <> '' THEN 0 ELSE 1 END),
|
|
85
|
+
a.id
|
|
86
|
+
),
|
|
87
|
+
inserted AS (
|
|
88
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, metadata)
|
|
89
|
+
SELECT
|
|
90
|
+
'dir:' || c.name,
|
|
91
|
+
c.role,
|
|
92
|
+
-- The unique partial index `idx_agents_custom_name_team` requires
|
|
93
|
+
-- (custom_name, team) to be unique when both are non-null. If a peer
|
|
94
|
+
-- already owns that slot (Type B production: UUID peer with
|
|
95
|
+
-- custom_name=name, team=team), set custom_name=NULL on the dir row
|
|
96
|
+
-- so the new row sits outside the unique index. Session-sync's
|
|
97
|
+
-- `getAgentByName(name, team)` lookup will route to the UUID peer
|
|
98
|
+
-- while it lives; once the peer is unregistered, an UPDATE
|
|
99
|
+
-- backfill (separate migration if the slot ever frees) can repopulate.
|
|
100
|
+
CASE
|
|
101
|
+
WHEN c.team IS NOT NULL AND EXISTS (
|
|
102
|
+
SELECT 1 FROM agents x
|
|
103
|
+
WHERE x.custom_name = c.name AND x.team = c.team
|
|
104
|
+
AND x.id <> 'dir:' || c.name
|
|
105
|
+
) THEN NULL
|
|
106
|
+
ELSE c.name
|
|
107
|
+
END,
|
|
108
|
+
c.team,
|
|
109
|
+
c.repo_path,
|
|
110
|
+
now(),
|
|
111
|
+
NULL,
|
|
112
|
+
'{}'::jsonb
|
|
113
|
+
FROM backfill_targets c
|
|
114
|
+
ON CONFLICT (id) DO NOTHING
|
|
115
|
+
RETURNING id
|
|
116
|
+
)
|
|
117
|
+
INSERT INTO audit_events (entity_type, entity_id, event_type, actor, details)
|
|
118
|
+
SELECT 'agent', i.id, 'directory.master_backfilled',
|
|
119
|
+
'migration:053_master_backfill_and_shadow_cleanup',
|
|
120
|
+
jsonb_build_object('reason', 'master_backfill',
|
|
121
|
+
'wish', 'master-aware-spawn',
|
|
122
|
+
'group', '14b')
|
|
123
|
+
FROM inserted i;
|
|
124
|
+
|
|
125
|
+
-- ---------------------------------------------------------------------------
|
|
126
|
+
-- Pass 2 (14a): archive bare-name shadows whose dir:<name> peer now exists.
|
|
127
|
+
-- Heal-not-wipe — never DELETE. Idempotent via state IS DISTINCT FROM.
|
|
128
|
+
-- ---------------------------------------------------------------------------
|
|
129
|
+
WITH archived AS (
|
|
130
|
+
UPDATE agents bare
|
|
131
|
+
SET state = 'archived',
|
|
132
|
+
auto_resume = false,
|
|
133
|
+
last_state_change = now()
|
|
134
|
+
FROM agents dir
|
|
135
|
+
WHERE dir.id = 'dir:' || bare.id
|
|
136
|
+
AND bare.id NOT LIKE 'dir:%'
|
|
137
|
+
-- Exclude UUID-shaped ids (4 hyphens). Same heuristic as migration 050.
|
|
138
|
+
AND bare.id NOT LIKE '%-%-%-%-%'
|
|
139
|
+
AND bare.current_executor_id IS NULL
|
|
140
|
+
AND bare.state IS DISTINCT FROM 'archived'
|
|
141
|
+
AND bare.repo_path IS NOT NULL
|
|
142
|
+
AND bare.repo_path <> ''
|
|
143
|
+
-- Belt-and-suspenders identity match: the bare row's role or id must
|
|
144
|
+
-- agree with the dir's custom_name. Prevents archiving an unrelated
|
|
145
|
+
-- bare row whose id happens to suffix a dir: id.
|
|
146
|
+
AND (
|
|
147
|
+
bare.role = dir.custom_name
|
|
148
|
+
OR bare.id = dir.custom_name
|
|
149
|
+
OR bare.custom_name = dir.custom_name
|
|
150
|
+
)
|
|
151
|
+
RETURNING bare.id
|
|
152
|
+
)
|
|
153
|
+
INSERT INTO audit_events (entity_type, entity_id, event_type, actor, details)
|
|
154
|
+
SELECT 'worker', a.id, 'state_changed',
|
|
155
|
+
'migration:053_master_backfill_and_shadow_cleanup',
|
|
156
|
+
jsonb_build_object('reason', 'bare_name_shadow_archived',
|
|
157
|
+
'state', 'archived',
|
|
158
|
+
'auto_resume', false,
|
|
159
|
+
'wish', 'master-aware-spawn',
|
|
160
|
+
'group', '14a')
|
|
161
|
+
FROM archived a;
|
|
@@ -187,6 +187,11 @@ describe.skipIf(!DB_AVAILABLE)('migration 049 — agents.kind GENERATED column',
|
|
|
187
187
|
`--glob '!src/db/migrations/049_agents_kind_generated.sql' ` +
|
|
188
188
|
`--glob '!src/db/migrations/046_dir_agents_state_null.sql' ` +
|
|
189
189
|
`--glob '!src/db/migrations/agents-kind.test.ts' ` +
|
|
190
|
+
// Migration 053's test file (master-aware-spawn Group 14a+b)
|
|
191
|
+
// legitimately queries `id LIKE 'dir:%'` to verify dir-row
|
|
192
|
+
// backfill + bare-shadow archive shape post-migration. Distinguishing
|
|
193
|
+
// dir-shape vs bare-shape is the whole point — not permanence inference.
|
|
194
|
+
`--glob '!src/db/migrations/master-backfill-and-shadow-cleanup.test.ts' ` +
|
|
190
195
|
// The state-machine invariants test file legitimately references
|
|
191
196
|
// the pattern in test descriptions, comments, and the rg pattern
|
|
192
197
|
// it itself constructs.
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for migration 053 — master_backfill_and_shadow_cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Covers Group 14 sub-deliverables 14a (bare-name shadow archival) and 14b
|
|
5
|
+
* (master backfill of dir:<name> rows).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { getConnection } from '../../lib/db.js';
|
|
12
|
+
import { DB_AVAILABLE, setupTestDatabase } from '../../lib/test-db.js';
|
|
13
|
+
|
|
14
|
+
const MIGRATION_PATH = join(import.meta.dir, '053_master_backfill_and_shadow_cleanup.sql');
|
|
15
|
+
|
|
16
|
+
async function applyMigrationManually(): Promise<void> {
|
|
17
|
+
const sql = await getConnection();
|
|
18
|
+
const body = await readFile(MIGRATION_PATH, 'utf-8');
|
|
19
|
+
await sql.unsafe(body);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe.skipIf(!DB_AVAILABLE)('migration 053 — master_backfill_and_shadow_cleanup', () => {
|
|
23
|
+
let cleanup: () => Promise<void>;
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
cleanup = await setupTestDatabase();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await cleanup();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
const sql = await getConnection();
|
|
35
|
+
await sql`DELETE FROM audit_events WHERE actor = 'migration:053_master_backfill_and_shadow_cleanup'`;
|
|
36
|
+
await sql`DELETE FROM assignments`;
|
|
37
|
+
await sql`DELETE FROM executors`;
|
|
38
|
+
await sql`DELETE FROM agents`;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ==========================================================================
|
|
42
|
+
// 14b — master backfill: dir:<name> created from existing canonical fields
|
|
43
|
+
// ==========================================================================
|
|
44
|
+
|
|
45
|
+
test('14b: backfills dir:felipe from a bare felipe row (Type-B shape)', async () => {
|
|
46
|
+
const sql = await getConnection();
|
|
47
|
+
// Twin's analysis: the bare row is the only candidate carrying
|
|
48
|
+
// repo_path. `custom_name` is empty/null in production (the partial
|
|
49
|
+
// unique index `idx_agents_custom_name_team` requires it to be unique
|
|
50
|
+
// when populated alongside team — bare rows skirt that by leaving it
|
|
51
|
+
// null); role is the canonical identity.
|
|
52
|
+
await sql`
|
|
53
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
54
|
+
VALUES ('felipe', 'felipe', NULL, 'felipe', '/home/genie/workspace/agents/felipe', now(), NULL, true, NULL)
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
await applyMigrationManually();
|
|
58
|
+
|
|
59
|
+
const dirRow = await sql<
|
|
60
|
+
{ id: string; role: string; custom_name: string | null; team: string; repo_path: string }[]
|
|
61
|
+
>`
|
|
62
|
+
SELECT id, role, custom_name, team, repo_path
|
|
63
|
+
FROM agents WHERE id = 'dir:felipe'
|
|
64
|
+
`;
|
|
65
|
+
expect(dirRow.length).toBe(1);
|
|
66
|
+
expect(dirRow[0].role).toBe('felipe');
|
|
67
|
+
expect(dirRow[0].custom_name).toBe('felipe');
|
|
68
|
+
expect(dirRow[0].team).toBe('felipe');
|
|
69
|
+
expect(dirRow[0].repo_path).toBe('/home/genie/workspace/agents/felipe');
|
|
70
|
+
|
|
71
|
+
const audit = await sql<{ details: { reason: string; group: string } }[]>`
|
|
72
|
+
SELECT details FROM audit_events
|
|
73
|
+
WHERE actor = 'migration:053_master_backfill_and_shadow_cleanup'
|
|
74
|
+
AND entity_id = 'dir:felipe'
|
|
75
|
+
AND event_type = 'directory.master_backfilled'
|
|
76
|
+
`;
|
|
77
|
+
expect(audit.length).toBe(1);
|
|
78
|
+
expect(audit[0].details.reason).toBe('master_backfill');
|
|
79
|
+
expect(audit[0].details.group).toBe('14b');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('14b: backfills dir:genie and dir:genie-pgserve from twin-shape masters', async () => {
|
|
83
|
+
const sql = await getConnection();
|
|
84
|
+
await sql`
|
|
85
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
86
|
+
VALUES
|
|
87
|
+
('genie', 'genie', NULL, 'genie', '/home/genie/workspace/agents/genie', now(), 'idle', true, NULL),
|
|
88
|
+
('genie-pgserve', 'genie-pgserve', NULL, 'genie', '/home/genie/workspace/agents/genie-pgserve', now(), NULL, true, NULL)
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
await applyMigrationManually();
|
|
92
|
+
|
|
93
|
+
const created = await sql<{ id: string }[]>`
|
|
94
|
+
SELECT id FROM agents WHERE id LIKE 'dir:%' ORDER BY id
|
|
95
|
+
`;
|
|
96
|
+
expect(created.map((r: { id: string }) => r.id)).toEqual(['dir:genie', 'dir:genie-pgserve']);
|
|
97
|
+
|
|
98
|
+
const repoPaths = await sql<{ id: string; repo_path: string }[]>`
|
|
99
|
+
SELECT id, repo_path FROM agents WHERE id LIKE 'dir:%' ORDER BY id
|
|
100
|
+
`;
|
|
101
|
+
const byId = new Map(repoPaths.map((r: { id: string; repo_path: string }) => [r.id, r.repo_path]));
|
|
102
|
+
expect(byId.get('dir:genie')).toBe('/home/genie/workspace/agents/genie');
|
|
103
|
+
expect(byId.get('dir:genie-pgserve')).toBe('/home/genie/workspace/agents/genie-pgserve');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('14b: NULLs custom_name when (name, team) slot is held by another live peer', async () => {
|
|
107
|
+
const sql = await getConnection();
|
|
108
|
+
// Production Type-B: UUID peer holds (custom_name='felipe', team='felipe')
|
|
109
|
+
// in the unique partial index. The bare row lacks dir:<name>. Backfilling
|
|
110
|
+
// dir:felipe with custom_name='felipe' team='felipe' would conflict with
|
|
111
|
+
// the index, so the migration must NULL custom_name on the new row.
|
|
112
|
+
await sql`
|
|
113
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
114
|
+
VALUES
|
|
115
|
+
('00000000-0000-0000-0000-feedfacefeed', 'felipe', 'felipe', 'felipe', '/some/uuid/path', now(), 'idle', true, NULL),
|
|
116
|
+
('felipe', 'felipe', NULL, 'felipe', '/home/genie/workspace/agents/felipe', now(), 'idle', true, NULL)
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
await applyMigrationManually();
|
|
120
|
+
|
|
121
|
+
const dirRow = await sql<{ custom_name: string | null }[]>`
|
|
122
|
+
SELECT custom_name FROM agents WHERE id = 'dir:felipe'
|
|
123
|
+
`;
|
|
124
|
+
expect(dirRow.length).toBe(1);
|
|
125
|
+
expect(dirRow[0].custom_name).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('14b: skips agents that already have a dir:<name> peer (no duplicate insert)', async () => {
|
|
129
|
+
const sql = await getConnection();
|
|
130
|
+
await sql`
|
|
131
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
132
|
+
VALUES ('dir:email', 'email', 'email', 'felipe', '/home/genie/workspace/agents/email', now(), NULL, true, NULL)
|
|
133
|
+
`;
|
|
134
|
+
await sql`
|
|
135
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
136
|
+
VALUES ('email', 'email', NULL, 'felipe', '/home/genie/workspace/agents/email', now(), NULL, true, NULL)
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
await applyMigrationManually();
|
|
140
|
+
|
|
141
|
+
const dirRows = await sql<{ id: string }[]>`
|
|
142
|
+
SELECT id FROM agents WHERE id LIKE 'dir:%' ORDER BY id
|
|
143
|
+
`;
|
|
144
|
+
expect(dirRows.length).toBe(1);
|
|
145
|
+
expect(dirRows[0].id).toBe('dir:email');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('14b: skips bare task-shaped rows (kind=task / archived / no repo_path)', async () => {
|
|
149
|
+
const sql = await getConnection();
|
|
150
|
+
await sql`
|
|
151
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
152
|
+
VALUES
|
|
153
|
+
('engineer-w2g3', 'engineer', 'engineer', 'master-aware-spawn', '/some/path', now(), 'idle', true, 'team-lead-uuid'),
|
|
154
|
+
('archived-master', 'archived', '', 'archived', '/some/path', now(), 'archived', true, NULL),
|
|
155
|
+
('no-repo-master', 'foo', '', 'foo', NULL, now(), NULL, true, NULL)
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
await applyMigrationManually();
|
|
159
|
+
|
|
160
|
+
const dirRows = await sql<{ id: string }[]>`SELECT id FROM agents WHERE id LIKE 'dir:%'`;
|
|
161
|
+
expect(dirRows.length).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ==========================================================================
|
|
165
|
+
// 14a — bare-name shadow cleanup (heal-not-wipe)
|
|
166
|
+
// ==========================================================================
|
|
167
|
+
|
|
168
|
+
test('14a: archives bare-name shadow when dir:<name> peer exists (no executor)', async () => {
|
|
169
|
+
const sql = await getConnection();
|
|
170
|
+
await sql`
|
|
171
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
172
|
+
VALUES
|
|
173
|
+
('dir:email', 'email', 'email', 'felipe', '/home/genie/workspace/agents/email', now(), NULL, false, NULL),
|
|
174
|
+
('email', 'email', NULL, 'felipe', '/home/genie/workspace/agents/email', now(), NULL, true, NULL)
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
await applyMigrationManually();
|
|
178
|
+
|
|
179
|
+
const bare = await sql<{ state: string | null; auto_resume: boolean }[]>`
|
|
180
|
+
SELECT state, auto_resume FROM agents WHERE id = 'email'
|
|
181
|
+
`;
|
|
182
|
+
expect(bare[0].state).toBe('archived');
|
|
183
|
+
expect(bare[0].auto_resume).toBe(false);
|
|
184
|
+
|
|
185
|
+
// dir: row left intact.
|
|
186
|
+
const dir = await sql<{ state: string | null; repo_path: string }[]>`
|
|
187
|
+
SELECT state, repo_path FROM agents WHERE id = 'dir:email'
|
|
188
|
+
`;
|
|
189
|
+
expect(dir[0].state).toBeNull();
|
|
190
|
+
expect(dir[0].repo_path).toBe('/home/genie/workspace/agents/email');
|
|
191
|
+
|
|
192
|
+
const audit = await sql<{ details: { reason: string; group: string } }[]>`
|
|
193
|
+
SELECT details FROM audit_events
|
|
194
|
+
WHERE actor = 'migration:053_master_backfill_and_shadow_cleanup'
|
|
195
|
+
AND entity_id = 'email'
|
|
196
|
+
AND event_type = 'state_changed'
|
|
197
|
+
`;
|
|
198
|
+
expect(audit.length).toBe(1);
|
|
199
|
+
expect(audit[0].details.reason).toBe('bare_name_shadow_archived');
|
|
200
|
+
expect(audit[0].details.group).toBe('14a');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('14a: NEVER deletes bare-name shadow rows (heal-not-wipe contract)', async () => {
|
|
204
|
+
const sql = await getConnection();
|
|
205
|
+
await sql`
|
|
206
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
207
|
+
VALUES
|
|
208
|
+
('dir:email', 'email', 'email', 'felipe', '/home/genie/workspace/agents/email', now(), NULL, false, NULL),
|
|
209
|
+
('email', 'email', NULL, 'felipe', '/home/genie/workspace/agents/email', now(), NULL, true, NULL)
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
await applyMigrationManually();
|
|
213
|
+
|
|
214
|
+
// Row still exists (just archived). Wholesale deletion is the failure
|
|
215
|
+
// mode the master-aware-spawn wish was born from.
|
|
216
|
+
const stillThere = await sql<{ id: string; state: string | null }[]>`
|
|
217
|
+
SELECT id, state FROM agents WHERE id = 'email'
|
|
218
|
+
`;
|
|
219
|
+
expect(stillThere.length).toBe(1);
|
|
220
|
+
expect(stillThere[0].state).toBe('archived');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('14a: leaves bare-name shadow alone when current_executor_id is set (live peer)', async () => {
|
|
224
|
+
const sql = await getConnection();
|
|
225
|
+
await sql`
|
|
226
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
227
|
+
VALUES
|
|
228
|
+
('dir:email', 'email', 'email', 'felipe', '/some/path', now(), NULL, false, NULL),
|
|
229
|
+
('email', 'email', NULL, 'felipe', '/some/path', now(), 'idle', true, NULL)
|
|
230
|
+
`;
|
|
231
|
+
// Attach a live executor to the bare row.
|
|
232
|
+
await sql`
|
|
233
|
+
INSERT INTO executors (id, agent_id, provider, transport, state)
|
|
234
|
+
VALUES ('exec-live', 'email', 'claude', 'tmux', 'running')
|
|
235
|
+
`;
|
|
236
|
+
await sql`UPDATE agents SET current_executor_id = 'exec-live' WHERE id = 'email'`;
|
|
237
|
+
|
|
238
|
+
await applyMigrationManually();
|
|
239
|
+
|
|
240
|
+
const bare = await sql<{ state: string | null }[]>`
|
|
241
|
+
SELECT state FROM agents WHERE id = 'email'
|
|
242
|
+
`;
|
|
243
|
+
expect(bare[0].state).toBe('idle');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('14a: never archives UUID-shaped rows even if dir:<uuid> exists', async () => {
|
|
247
|
+
const sql = await getConnection();
|
|
248
|
+
const uuid = '11111111-2222-3333-4444-555555555555';
|
|
249
|
+
await sql`
|
|
250
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
251
|
+
VALUES
|
|
252
|
+
(${`dir:${uuid}`}, 'weird', 'weird', 'team-dir', '/p', now(), NULL, false, NULL),
|
|
253
|
+
(${uuid}, 'weird', 'weird', 'team-uuid', '/p', now(), 'idle', true, NULL)
|
|
254
|
+
`;
|
|
255
|
+
|
|
256
|
+
await applyMigrationManually();
|
|
257
|
+
|
|
258
|
+
const uuidRow = await sql<{ state: string | null }[]>`
|
|
259
|
+
SELECT state FROM agents WHERE id = ${uuid}
|
|
260
|
+
`;
|
|
261
|
+
expect(uuidRow[0].state).toBe('idle');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ==========================================================================
|
|
265
|
+
// Composite + idempotency
|
|
266
|
+
// ==========================================================================
|
|
267
|
+
|
|
268
|
+
test('14a + 14b run in one pass: bare felipe gets archived AFTER dir:felipe is backfilled', async () => {
|
|
269
|
+
const sql = await getConnection();
|
|
270
|
+
// Only the bare row exists; the migration must first create dir:felipe
|
|
271
|
+
// (14b) and only THEN archive the bare felipe shadow (14a).
|
|
272
|
+
await sql`
|
|
273
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
274
|
+
VALUES ('felipe', 'felipe', NULL, 'felipe', '/home/genie/workspace/agents/felipe', now(), 'idle', true, NULL)
|
|
275
|
+
`;
|
|
276
|
+
|
|
277
|
+
await applyMigrationManually();
|
|
278
|
+
|
|
279
|
+
const dir = await sql<{ id: string }[]>`SELECT id FROM agents WHERE id = 'dir:felipe'`;
|
|
280
|
+
expect(dir.length).toBe(1);
|
|
281
|
+
|
|
282
|
+
const bare = await sql<{ state: string | null; auto_resume: boolean }[]>`
|
|
283
|
+
SELECT state, auto_resume FROM agents WHERE id = 'felipe'
|
|
284
|
+
`;
|
|
285
|
+
expect(bare[0].state).toBe('archived');
|
|
286
|
+
expect(bare[0].auto_resume).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('idempotent: re-running the migration touches zero new rows', async () => {
|
|
290
|
+
const sql = await getConnection();
|
|
291
|
+
await sql`
|
|
292
|
+
INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume, reports_to)
|
|
293
|
+
VALUES ('felipe', 'felipe', NULL, 'felipe', '/home/genie/workspace/agents/felipe', now(), 'idle', true, NULL)
|
|
294
|
+
`;
|
|
295
|
+
|
|
296
|
+
await applyMigrationManually();
|
|
297
|
+
const firstAuditCount = await sql<{ cnt: number }[]>`
|
|
298
|
+
SELECT count(*)::int AS cnt FROM audit_events
|
|
299
|
+
WHERE actor = 'migration:053_master_backfill_and_shadow_cleanup'
|
|
300
|
+
`;
|
|
301
|
+
|
|
302
|
+
await applyMigrationManually();
|
|
303
|
+
const secondAuditCount = await sql<{ cnt: number }[]>`
|
|
304
|
+
SELECT count(*)::int AS cnt FROM audit_events
|
|
305
|
+
WHERE actor = 'migration:053_master_backfill_and_shadow_cleanup'
|
|
306
|
+
`;
|
|
307
|
+
|
|
308
|
+
expect(secondAuditCount[0].cnt).toBe(firstAuditCount[0].cnt);
|
|
309
|
+
});
|
|
310
|
+
});
|