@automagik/genie 4.260405.13 → 4.260405.15
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/.claude-plugin/marketplace.json +1 -1
- package/.genie/wishes/daily-metrics-agent/WISH.md +1 -1
- package/Makefile +6 -2
- package/dist/genie.js +3 -3
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/lib/executor-registry.ts +8 -0
- package/src/lib/protocol-router.ts +15 -1
- package/src/lib/spawn-command.test.ts +83 -0
- package/src/lib/spawn-command.ts +118 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "genie",
|
|
13
|
-
"version": "4.260405.
|
|
13
|
+
"version": "4.260405.15",
|
|
14
14
|
"source": "./plugins/genie",
|
|
15
15
|
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project."
|
|
16
16
|
}
|
package/Makefile
CHANGED
|
@@ -34,9 +34,13 @@ tauri: tauri-app tauri-dmg ## Build Tauri .app + .dmg
|
|
|
34
34
|
tauri-app: ## Build Tauri .app only (skip DMG)
|
|
35
35
|
cd $(TAURI_APP_DIR) && cargo tauri build --bundles app
|
|
36
36
|
|
|
37
|
-
tauri-dmg: ## Package .app into .dmg
|
|
37
|
+
tauri-dmg: ## Package .app into .dmg with Applications symlink
|
|
38
38
|
@test -d "$(TAURI_BUNDLE)/macos/Genie.app" || (echo "Run 'make tauri-app' first" && exit 1)
|
|
39
|
-
|
|
39
|
+
@rm -rf /tmp/genie-dmg-stage && mkdir -p /tmp/genie-dmg-stage
|
|
40
|
+
cp -R "$(TAURI_BUNDLE)/macos/Genie.app" /tmp/genie-dmg-stage/
|
|
41
|
+
ln -s /Applications /tmp/genie-dmg-stage/Applications
|
|
42
|
+
hdiutil create -volname "Genie" -srcfolder /tmp/genie-dmg-stage -ov -format UDZO "$(TAURI_DMG)"
|
|
43
|
+
@rm -rf /tmp/genie-dmg-stage
|
|
40
44
|
@echo "✓ $(TAURI_DMG)"
|
|
41
45
|
|
|
42
46
|
tauri-dev:
|
package/dist/genie.js
CHANGED
|
@@ -456,7 +456,7 @@ ${bin} set-option -w pane-active-border-style "fg=$COLOR"
|
|
|
456
456
|
SELECT e.* FROM executors e
|
|
457
457
|
JOIN agents a ON a.current_executor_id = e.id
|
|
458
458
|
WHERE a.id = ${agentId}
|
|
459
|
-
`;return rows.length>0?rowToExecutor(rows[0]):null}async function updateExecutorState(id,state){let sql=await getConnection(),updates={state};if(state==="terminated"||state==="done"||state==="error")updates.ended_at=new Date().toISOString();await sql`UPDATE executors SET ${sql(updates)} WHERE id = ${id}`,recordAuditEvent("executor",id,"state_changed",process.env.GENIE_AGENT_NAME??"cli",{state}).catch(()=>{})}async function terminateExecutor(id){let sql=await getConnection(),now=new Date().toISOString();await sql`
|
|
459
|
+
`;return rows.length>0?rowToExecutor(rows[0]):null}async function updateExecutorState(id,state){let sql=await getConnection(),updates={state};if(state==="terminated"||state==="done"||state==="error")updates.ended_at=new Date().toISOString();if(await sql`UPDATE executors SET ${sql(updates)} WHERE id = ${id}`,recordAuditEvent("executor",id,"state_changed",process.env.GENIE_AGENT_NAME??"cli",{state}).catch(()=>{}),state==="running")recordAuditEvent("executor",id,"executor.ready",process.env.GENIE_AGENT_NAME??"cli",{state,readiness_source:"state_transition"}).catch(()=>{})}async function terminateExecutor(id){let sql=await getConnection(),now=new Date().toISOString();await sql`
|
|
460
460
|
UPDATE executors
|
|
461
461
|
SET state = 'terminated', ended_at = ${now}
|
|
462
462
|
WHERE id = ${id} AND state NOT IN ('terminated', 'done')
|
|
@@ -1132,7 +1132,7 @@ Stopping inbox watcher...`),stopInboxWatcher2(handle),process.exit(0)};process.o
|
|
|
1132
1132
|
${groupSection}`:"","",gitLog?`Last git log:
|
|
1133
1133
|
${gitLog}`:"","",gitStatus?`Uncommitted changes:
|
|
1134
1134
|
${gitStatus}`:"","","Pick up where you left off. Read the wish file for full context."].filter(Boolean).join(`
|
|
1135
|
-
`);await _deps.mailboxSend(repoPath,"genie",workerId,resumePrompt)}catch(err){let msg=err instanceof Error?err.message:String(err);console.warn(`[protocol-router] Resume context injection failed: ${msg}`)}}var execAsync,_deps;var init_protocol_router_spawn=__esm(()=>{init_agent_registry();init_claude_native_teams();init_db();init_executor_registry();init_mailbox();init_provider_adapters();init_registry();init_team_manager();init_tmux_wrapper();init_tmux();init_wish_state();execAsync=promisify2(exec2),_deps={findAnyGroupByAssignee,mailboxSend:send,writeNativeInbox}});async function waitForAgentReady(paneId,opts){let timeoutMs=opts?.timeoutMs??(process.env.GENIE_SPAWN_TIMEOUT_MS?Number(process.env.GENIE_SPAWN_TIMEOUT_MS):DEFAULT_SPAWN_TIMEOUT_MS),pollIntervalMs=opts?.pollIntervalMs??READINESS_POLL_INTERVAL_MS,start=Date.now();while(Date.now()-start<timeoutMs){try{let content=await capturePaneContent(paneId,50);if(content){let state=detectState(content);if(state.type==="idle"||state.type==="tool_use")return{ready:!0,elapsedMs:Date.now()-start}}}catch{}await new Promise((r)=>setTimeout(r,pollIntervalMs))}return{ready:!1,elapsedMs:Date.now()-start}}var DEFAULT_SPAWN_TIMEOUT_MS=30000,READINESS_POLL_INTERVAL_MS=2000;var init_spawn_command=__esm(()=>{init_orchestrator();init_tmux()});var exports_claude_sdk_stream={};__export(exports_claude_sdk_stream,{formatSdkMessage:()=>formatSdkMessage});function formatSdkMessage(msg,format){switch(format){case"text":return formatText(msg);case"json":return JSON.stringify(msg,null,2);case"ndjson":return JSON.stringify(msg)}}function formatText(msg){switch(msg.type){case"assistant":return formatAssistant(msg);case"stream_event":return formatStreamEvent(msg);case"result":return formatResult(msg);case"tool_progress":return`[${msg.tool_name}] progress... (${msg.elapsed_time_seconds}s)
|
|
1135
|
+
`);await _deps.mailboxSend(repoPath,"genie",workerId,resumePrompt)}catch(err){let msg=err instanceof Error?err.message:String(err);console.warn(`[protocol-router] Resume context injection failed: ${msg}`)}}var execAsync,_deps;var init_protocol_router_spawn=__esm(()=>{init_agent_registry();init_claude_native_teams();init_db();init_executor_registry();init_mailbox();init_provider_adapters();init_registry();init_team_manager();init_tmux_wrapper();init_tmux();init_wish_state();execAsync=promisify2(exec2),_deps={findAnyGroupByAssignee,mailboxSend:send,writeNativeInbox}});async function waitForAgentReady(paneId,opts){let timeoutMs=opts?.timeoutMs??(process.env.GENIE_SPAWN_TIMEOUT_MS?Number(process.env.GENIE_SPAWN_TIMEOUT_MS):DEFAULT_SPAWN_TIMEOUT_MS),pollIntervalMs=opts?.pollIntervalMs??READINESS_POLL_INTERVAL_MS,start=Date.now();while(Date.now()-start<timeoutMs){try{let content=await capturePaneContent(paneId,50);if(content){let state=detectState(content);if(state.type==="idle"||state.type==="tool_use")return{ready:!0,elapsedMs:Date.now()-start}}}catch{}await new Promise((r)=>setTimeout(r,pollIntervalMs))}return{ready:!1,elapsedMs:Date.now()-start}}async function waitForExecutorReady(executorId,opts){let timeoutMs=opts?.timeoutMs??DEFAULT_SPAWN_TIMEOUT_MS,start=Date.now();if(!await _pgDeps.isAvailable())return{ready:!1,elapsedMs:0};try{let executor=await _pgDeps.getExecutor(executorId);if(executor&&(executor.state==="running"||executor.state==="idle"))return{ready:!0,elapsedMs:Date.now()-start}}catch{return{ready:!1,elapsedMs:Date.now()-start}}let sql;try{sql=await _pgDeps.getConnection()}catch{return{ready:!1,elapsedMs:Date.now()-start}}return new Promise((resolve5)=>{let resolved=!1,listener=null,pollInterval=null,timeout=null,cleanup=async()=>{if(pollInterval)clearInterval(pollInterval);if(timeout)clearTimeout(timeout);if(listener)try{await listener.unlisten()}catch{}},finish=(ready)=>{if(resolved)return;resolved=!0,cleanup().then(()=>resolve5({ready,elapsedMs:Date.now()-start}))};timeout=setTimeout(()=>finish(!1),timeoutMs),sql.listen("genie_executor_state",(payload)=>{let parts=payload.split(":");if(parts.length<4)return;let[notifyExecId,,,newState]=parts;if(notifyExecId===executorId&&(newState==="running"||newState==="idle"))finish(!0)}).then((l)=>{if(listener=l,resolved)l.unlisten().catch(()=>{})}).catch(()=>{}),pollInterval=setInterval(async()=>{if(resolved)return;try{let executor=await _pgDeps.getExecutor(executorId);if(executor&&(executor.state==="running"||executor.state==="idle"))finish(!0)}catch{}},READINESS_POLL_INTERVAL_MS)})}var DEFAULT_SPAWN_TIMEOUT_MS=30000,READINESS_POLL_INTERVAL_MS=2000,_pgDeps;var init_spawn_command=__esm(()=>{init_db();init_executor_registry();init_orchestrator();init_tmux();_pgDeps={isAvailable,getConnection,getExecutor}});var exports_claude_sdk_stream={};__export(exports_claude_sdk_stream,{formatSdkMessage:()=>formatSdkMessage});function formatSdkMessage(msg,format){switch(format){case"text":return formatText(msg);case"json":return JSON.stringify(msg,null,2);case"ndjson":return JSON.stringify(msg)}}function formatText(msg){switch(msg.type){case"assistant":return formatAssistant(msg);case"stream_event":return formatStreamEvent(msg);case"result":return formatResult(msg);case"tool_progress":return`[${msg.tool_name}] progress... (${msg.elapsed_time_seconds}s)
|
|
1136
1136
|
`;case"tool_use_summary":return`[tool] ${msg.summary}
|
|
1137
1137
|
`;case"system":return formatSystem(msg);default:return null}}function formatAssistant(msg){let parts=[],content=msg.message?.content;if(Array.isArray(content)){for(let block of content)if(block.type==="text"&&block.text)parts.push(block.text);else if(block.type==="tool_use"){let toolBlock=block;parts.push(`[using ${toolBlock.name??"tool"}]`)}}if(msg.error)parts.push(`\x1B[31m[error: ${msg.error}]\x1B[0m`);return parts.length>0?parts.join(`
|
|
1138
1138
|
`):null}function formatStreamEvent(msg){let event=msg.event;if(event.type==="content_block_delta"){let delta=event.delta;if(delta.type==="text_delta"&&delta.text)return delta.text}return null}function formatResult(msg){if(msg.subtype==="success"){let success2=msg,lines=[`
|
|
@@ -1520,7 +1520,7 @@ Stopped following`),process.exit(0)}),await new Promise(()=>{});return}let conte
|
|
|
1520
1520
|
processed_bytes = ${progress.processedBytes},
|
|
1521
1521
|
errors = ${progress.errors},
|
|
1522
1522
|
updated_at = now()
|
|
1523
|
-
`}async function shouldSkipBackfill(sql){try{let existing=await sql`SELECT status FROM session_sync WHERE id = 'backfill'`;if(existing.length>0&&existing[0].status==="complete")return!0}catch{}try{let[{count}]=await sql`SELECT count(*)::int as count FROM sessions`;if(count>0){let existing=await sql`SELECT status FROM session_sync WHERE id = 'backfill'`;if(existing.length===0||existing[0].status==="complete")return!0}}catch{return!0}return!1}async function yieldToLiveWork(){while(liveWorkPending)await sleep2(LIVE_YIELD_POLL_MS)}async function getFileStartOffset(sql,file){let existing=await sql`SELECT last_ingested_offset FROM sessions WHERE id = ${file.sessionId}`;if(existing.length>0)return existing[0].last_ingested_offset??0;return 0}async function processBackfillFile(sql,file,progress,workerMap){let offset=await getFileStartOffset(sql,file);if(offset>=file.fileSize){progress.processedFiles++,progress.processedBytes+=file.fileSize;return}let currentOffset=offset;while(currentOffset<file.fileSize){await yieldToLiveWork();let result2=await ingestFile(sql,file.sessionId,file.jsonlPath,file.projectPath,currentOffset,{chunkSize:CHUNK_SIZE,parentSessionId:file.parentSessionId,isSubagent:file.isSubagent,fileSize:file.fileSize,mtime:file.mtime,workerMap});if(result2.newOffset<=currentOffset)break;progress.processedBytes+=result2.newOffset-currentOffset,currentOffset=result2.newOffset}progress.processedFiles++}async function processAllFiles(sql,allFiles,progress,workerMap){for(let file of allFiles){if(!running)break;await yieldToLiveWork();try{await processBackfillFile(sql,file,progress,workerMap)}catch(err){progress.errors++;let message=err instanceof Error?err.message:String(err);console.error(`[backfill] error on ${file.jsonlPath}: ${message}`)}if(progress.processedFiles%50===0)await updateSyncState(sql,progress);await sleep2(SLEEP_BETWEEN_FILES_MS)}}function resolveBackfillStatus(progress){if(!running)progress.status="paused",console.log(`[backfill] paused: ${progress.processedFiles}/${progress.totalFiles} files (will resume on next daemon start)`);else if(progress.errors>0&&progress.errors>=progress.totalFiles)progress.status="failed",console.error(`[backfill] failed: ${progress.errors}/${progress.totalFiles} files errored \u2014 will retry on next daemon start`);else progress.status="complete",console.log(`[backfill] complete: ${progress.processedFiles}/${progress.totalFiles} files, ${progress.errors} errors`)}async function startBackfill(sql){if(running)return;if(await shouldSkipBackfill(sql))return;running=!0,console.log("[backfill] starting session backfill...");try{let allFiles=await discoverAllJsonlFiles();allFiles.sort((a,b2)=>b2.mtime-a.mtime);let totalBytes=allFiles.reduce((sum,f)=>sum+f.fileSize,0),progress={totalFiles:allFiles.length,processedFiles:0,totalBytes,processedBytes:0,errors:0,status:"running"};await updateSyncState(sql,progress),console.log(`[backfill] discovered ${allFiles.length} files (${(totalBytes/1024/1024).toFixed(1)} MB)`);let workerMap=await buildWorkerMap(sql);await processAllFiles(sql,allFiles,progress,workerMap),resolveBackfillStatus(progress),await updateSyncState(sql,progress)}catch(err){let message=err instanceof Error?err.message:String(err);console.error(`[backfill] fatal error: ${message}`)}finally{running=!1}}function stopBackfill(){running=!1}async function getBackfillStatus(sql){try{let rows=await sql`SELECT * FROM session_sync WHERE id = 'backfill'`;if(rows.length===0)return null;let row=rows[0];return{totalFiles:row.total_files,processedFiles:row.processed_files,totalBytes:row.total_bytes,processedBytes:row.processed_bytes,errors:row.errors,status:row.status}}catch{return null}}var CHUNK_SIZE=65536,SLEEP_BETWEEN_FILES_MS=100,LIVE_YIELD_POLL_MS=200,running=!1;var init_session_backfill=__esm(()=>{init_session_capture()});var exports_protocol_router={};__export(exports_protocol_router,{sendMessage:()=>sendMessage2,getInbox:()=>getInbox,deliverToPane:()=>deliverToPane,_deps:()=>_deps2});async function waitForWorkerReady(paneId,timeoutMs=AUTO_SPAWN_READY_TIMEOUT_MS){let start=Date.now();while(Date.now()-start<timeoutMs){try{let content=await capturePaneContent(paneId,30);if(detectState(content).type==="idle")return!0}catch{}await new Promise((r)=>setTimeout(r,AUTO_SPAWN_POLL_INTERVAL_MS))}return!1}async function isExecutorCompleted(worker){if(!worker?.currentExecutorId)return!1;let executor=await getCurrentExecutor(worker.id);return executor!=null&&(executor.state==="done"||executor.state==="terminated")}async function isWorkerDead(w){if(w.currentExecutorId){let state=await getAgentEffectiveState(w.id);return state==="terminated"||state==="offline"}return w.state==="suspended"}async function resolveRecipient(recipientId){let allWorkers=await list(),byId=[],byRole=[],byTeamRole=[];for(let w of allWorkers){if(await isWorkerDead(w))continue;if(!await _deps2.isPaneAlive(w.paneId))continue;if(w.id===recipientId)byId.push(w);else if(w.role===recipientId)byRole.push(w);else if(`${w.team}:${w.role}`===recipientId)byTeamRole.push(w)}if(byId.length>0)return byId;if(byRole.length>0)return byRole;return byTeamRole}async function findLiveWorkerFuzzy(recipientId){let matches=await resolveRecipient(recipientId);return matches.length===1?matches[0]:null}async function ensureWorkerAlive(worker,recipientId){if(worker&&worker.state!=="suspended"&&await _deps2.isPaneAlive(worker.paneId))return{worker,respawned:!1};let live=await findLiveWorkerFuzzy(recipientId);if(live)return{worker:live,respawned:!1};if(await isExecutorCompleted(worker))return null;if(!process.env.TMUX)return null;let templates=await listTemplates(),candidates=[worker?.role,worker?.id,recipientId].filter((v)=>Boolean(v)),uniqueCandidates=[...new Set(candidates)],workerTeam=worker?.team,template=templates.find((t)=>{if(workerTeam&&t.team!==workerTeam)return!1;return uniqueCandidates.some((q)=>t.id===q||t.role===q||`${t.team}:${t.role}`===q)});if(!template)return null;let resumeSessionId=template.provider==="claude"&&worker?.claudeSessionId?worker.claudeSessionId:void 0;try{let lockResult=await(await getConnection()).begin(async(tx)=>{await tx`SELECT pg_advisory_xact_lock(hashtext(${recipientId}))`;let postLockLive=await findLiveWorkerFuzzy(recipientId);if(postLockLive)return{type:"existing",worker:postLockLive};if(await cleanupDeadWorkers(recipientId,workerTeam),worker)await unregister(worker.id);let{spawnWorkerFromTemplate:spawnWorkerFromTemplate2}=await Promise.resolve().then(() => (init_protocol_router_spawn(),exports_protocol_router_spawn));return{type:"spawned",...await spawnWorkerFromTemplate2(template,resumeSessionId)}});if(lockResult.type==="existing")return{worker:lockResult.worker,respawned:!1};if(await saveTemplate({...template,lastSpawnedAt:new Date().toISOString()}),await(_deps2.waitForWorkerReady??waitForWorkerReady)(lockResult.paneId),!await _deps2.isPaneAlive(lockResult.paneId))return await unregister(lockResult.worker.id),null;return{worker:lockResult.worker,respawned:!0}}catch(err){let msg=err instanceof Error?err.message:String(err);if(console.error(`[protocol-router] Spawn failed for "${recipientId}": ${msg}`),worker)await update(worker.id,{state:"error"}).catch(()=>{});return null}}async function cleanupDeadWorkers(recipientId,team){let allWorkers=await list();for(let w of allWorkers){if(team&&w.team!==team)continue;if(!(w.role===recipientId||w.id===recipientId))continue;if(await _deps2.isPaneAlive(w.paneId))continue;await unregister(w.id)}}async function deliverToWorker(repoPath,from,worker,body){let message=await send(repoPath,from,worker.id,body),delivered=!1;if(worker.nativeTeamEnabled&&worker.team&&worker.role)delivered=await writeToNativeInbox(worker,message);else delivered=await injectToTmuxPane(worker,message);if(!delivered&&worker.team){let agentName=worker.role||worker.id.split("-").slice(-1)[0]||worker.id;try{let nativeMsg=toNativeInboxMessage(message,worker.nativeColor??"blue");await writeNativeInbox(worker.team,agentName,nativeMsg),delivered=!0}catch{}}if(delivered)await markDelivered(repoPath,worker.id,message.id);else console.error(`[protocol-router] Delivery failed: all paths exhausted (worker=${worker.id}, pane=${worker.paneId}, msg="${body.slice(0,50)}")`);return{messageId:message.id,workerId:worker.id,delivered}}async function deliverViaNativeInbox(repoPath,from,to,body,teamName){let resolvedTeam=teamName??await discoverTeamName();if(!resolvedTeam)return null;let config=await loadConfig(resolvedTeam).catch(()=>null);if(!config)return null;let sanitizedTo=sanitizeTeamName(to),matchedMember=config.members?.find((m)=>m.name===to||m.name===sanitizedTo||m.agentId===`${to}@${resolvedTeam}`||m.agentId===`${sanitizedTo}@${resolvedTeam}`);if(!matchedMember)return null;let inboxName=matchedMember.name??to;try{let message=await send(repoPath,from,to,body),nativeMsg={from,text:body,summary:body.length>50?`${body.substring(0,50)}...`:body,timestamp:new Date().toISOString(),color:"blue",read:!1};return await writeNativeInbox(resolvedTeam,inboxName,nativeMsg),await markDelivered(repoPath,to,message.id),{messageId:message.id,workerId:to,delivered:!0}}catch{return null}}async function sendMessage2(repoPath,from,to,body,teamName){if(from===to)return{messageId:"",workerId:to,delivered:!0,reason:"Self-delivery suppressed"};let liveMatches=await resolveRecipient(to);if(liveMatches.length===1){if(!await _deps2.isPaneAlive(liveMatches[0].paneId)){let message=await send(repoPath,from,liveMatches[0].id,body);return console.error(`[protocol-router] Delivery failed: pane dead (worker=${liveMatches[0].id}, msg="${body.slice(0,50)}")`),{messageId:message.id,workerId:liveMatches[0].id,delivered:!1,reason:"Pane died before delivery"}}return deliverToWorker(repoPath,from,liveMatches[0],body)}if(liveMatches.length>1)return{messageId:"",workerId:to,delivered:!1,reason:`Worker "${to}" is ambiguous. Found ${liveMatches.length} live matches: ${liveMatches.map((m)=>m.id).join(", ")}. Use exact worker ID.`};let{resolve:resolve5}=await Promise.resolve().then(() => (init_agent_directory(),exports_agent_directory)),dirResolved=await resolve5(to),worker=await get(to);if(!worker){let allWorkers=await list();worker=allWorkers.find((w)=>w.role===to&&w.state==="suspended")??allWorkers.find((w)=>w.role===to)??null}if(dirResolved||worker){let alive=await ensureWorkerAlive(worker,to);if(alive){if(!await _deps2.isPaneAlive(alive.worker.paneId)){let message=await send(repoPath,from,alive.worker.id,body);return console.error(`[protocol-router] Delivery failed: pane dead after spawn (worker=${alive.worker.id}, msg="${body.slice(0,50)}")`),{messageId:message.id,workerId:alive.worker.id,delivered:!1,reason:"Pane died after spawn"}}return deliverToWorker(repoPath,from,alive.worker,body)}}let nativeResult=await deliverViaNativeInbox(repoPath,from,to,body,teamName);if(nativeResult)return nativeResult;return{messageId:"",workerId:to,delivered:!1,reason:`Worker "${to}" not found or not alive`}}async function writeToNativeInbox(worker,message){try{let nativeMsg=toNativeInboxMessage(message,worker.nativeColor??"blue"),agentName=worker.role??worker.id;return await writeNativeInbox(worker.team??"",agentName,nativeMsg),!0}catch{return!1}}async function injectToTmuxPane(worker,message){if(!worker.paneId)return!1;if(!/^%\d+$/.test(worker.paneId))return!1;if(!await _deps2.isPaneAlive(worker.paneId))return!1;try{let escaped=message.body.replace(/'/g,"'\\''");return await executeTmux2(`send-keys -t '${worker.paneId}' '${escaped}'`),await new Promise((resolve5)=>setTimeout(resolve5,200)),await executeTmux2(`send-keys -t '${worker.paneId}' Enter`),!0}catch{return!1}}async function deliverToPane(toWorker,messageId){let worker=await get(toWorker);if(!worker||!worker.paneId)return!1;if(!await _deps2.isPaneAlive(worker.paneId))return!1;let message=await getById(messageId);if(!message||message.deliveredAt)return!1;let injected=await injectToTmuxPane(worker,message);if(injected&&worker.repoPath)await markDelivered(worker.repoPath,worker.id,messageId);return injected}async function getInbox(repoPath,workerId){return inbox(repoPath,workerId)}var _deps2,AUTO_SPAWN_READY_TIMEOUT_MS=15000,AUTO_SPAWN_POLL_INTERVAL_MS=1000;var init_protocol_router=__esm(()=>{init_agent_registry();init_claude_native_teams();init_db();init_executor_registry();init_mailbox();init_orchestrator();init_tmux();_deps2={isPaneAlive,waitForWorkerReady:null}});var exports_scheduler_daemon={};__export(exports_scheduler_daemon,{startDaemon:()=>startDaemon,recoverOnStartup:()=>recoverOnStartup,reconcileOrphans:()=>reconcileOrphans,reconcileOrphanedRuns:()=>reconcileOrphanedRuns,reclaimExpiredLeases:()=>reclaimExpiredLeases,logToFile:()=>logToFile,fireTrigger:()=>fireTrigger,emitWorkerEvents:()=>emitWorkerEvents,collectMachineSnapshot:()=>collectMachineSnapshot,collectHeartbeats:()=>collectHeartbeats,claimDueTriggers:()=>claimDueTriggers,attemptAgentResume:()=>attemptAgentResume,_resetWorkerStatesForTesting:()=>_resetWorkerStatesForTesting});import{randomUUID as randomUUID5}from"crypto";import{appendFileSync as appendFileSync2,mkdirSync as mkdirSync10}from"fs";import{homedir as homedir22}from"os";import{join as join34}from"path";function getLogDir2(){return join34(process.env.GENIE_HOME??join34(homedir22(),".genie"),"logs")}function getLogFile(){return join34(getLogDir2(),"scheduler.log")}function logToFile(entry){let logDir=getLogDir2();mkdirSync10(logDir,{recursive:!0}),appendFileSync2(getLogFile(),`${JSON.stringify(entry)}
|
|
1523
|
+
`}async function shouldSkipBackfill(sql){try{let existing=await sql`SELECT status FROM session_sync WHERE id = 'backfill'`;if(existing.length>0&&existing[0].status==="complete")return!0}catch{}try{let[{count}]=await sql`SELECT count(*)::int as count FROM sessions`;if(count>0){let existing=await sql`SELECT status FROM session_sync WHERE id = 'backfill'`;if(existing.length===0||existing[0].status==="complete")return!0}}catch{return!0}return!1}async function yieldToLiveWork(){while(liveWorkPending)await sleep2(LIVE_YIELD_POLL_MS)}async function getFileStartOffset(sql,file){let existing=await sql`SELECT last_ingested_offset FROM sessions WHERE id = ${file.sessionId}`;if(existing.length>0)return existing[0].last_ingested_offset??0;return 0}async function processBackfillFile(sql,file,progress,workerMap){let offset=await getFileStartOffset(sql,file);if(offset>=file.fileSize){progress.processedFiles++,progress.processedBytes+=file.fileSize;return}let currentOffset=offset;while(currentOffset<file.fileSize){await yieldToLiveWork();let result2=await ingestFile(sql,file.sessionId,file.jsonlPath,file.projectPath,currentOffset,{chunkSize:CHUNK_SIZE,parentSessionId:file.parentSessionId,isSubagent:file.isSubagent,fileSize:file.fileSize,mtime:file.mtime,workerMap});if(result2.newOffset<=currentOffset)break;progress.processedBytes+=result2.newOffset-currentOffset,currentOffset=result2.newOffset}progress.processedFiles++}async function processAllFiles(sql,allFiles,progress,workerMap){for(let file of allFiles){if(!running)break;await yieldToLiveWork();try{await processBackfillFile(sql,file,progress,workerMap)}catch(err){progress.errors++;let message=err instanceof Error?err.message:String(err);console.error(`[backfill] error on ${file.jsonlPath}: ${message}`)}if(progress.processedFiles%50===0)await updateSyncState(sql,progress);await sleep2(SLEEP_BETWEEN_FILES_MS)}}function resolveBackfillStatus(progress){if(!running)progress.status="paused",console.log(`[backfill] paused: ${progress.processedFiles}/${progress.totalFiles} files (will resume on next daemon start)`);else if(progress.errors>0&&progress.errors>=progress.totalFiles)progress.status="failed",console.error(`[backfill] failed: ${progress.errors}/${progress.totalFiles} files errored \u2014 will retry on next daemon start`);else progress.status="complete",console.log(`[backfill] complete: ${progress.processedFiles}/${progress.totalFiles} files, ${progress.errors} errors`)}async function startBackfill(sql){if(running)return;if(await shouldSkipBackfill(sql))return;running=!0,console.log("[backfill] starting session backfill...");try{let allFiles=await discoverAllJsonlFiles();allFiles.sort((a,b2)=>b2.mtime-a.mtime);let totalBytes=allFiles.reduce((sum,f)=>sum+f.fileSize,0),progress={totalFiles:allFiles.length,processedFiles:0,totalBytes,processedBytes:0,errors:0,status:"running"};await updateSyncState(sql,progress),console.log(`[backfill] discovered ${allFiles.length} files (${(totalBytes/1024/1024).toFixed(1)} MB)`);let workerMap=await buildWorkerMap(sql);await processAllFiles(sql,allFiles,progress,workerMap),resolveBackfillStatus(progress),await updateSyncState(sql,progress)}catch(err){let message=err instanceof Error?err.message:String(err);console.error(`[backfill] fatal error: ${message}`)}finally{running=!1}}function stopBackfill(){running=!1}async function getBackfillStatus(sql){try{let rows=await sql`SELECT * FROM session_sync WHERE id = 'backfill'`;if(rows.length===0)return null;let row=rows[0];return{totalFiles:row.total_files,processedFiles:row.processed_files,totalBytes:row.total_bytes,processedBytes:row.processed_bytes,errors:row.errors,status:row.status}}catch{return null}}var CHUNK_SIZE=65536,SLEEP_BETWEEN_FILES_MS=100,LIVE_YIELD_POLL_MS=200,running=!1;var init_session_backfill=__esm(()=>{init_session_capture()});var exports_protocol_router={};__export(exports_protocol_router,{sendMessage:()=>sendMessage2,getInbox:()=>getInbox,deliverToPane:()=>deliverToPane,_deps:()=>_deps2});async function waitForWorkerReady(paneId,timeoutMs=AUTO_SPAWN_READY_TIMEOUT_MS){try{let executor=await findExecutorByPane(paneId);if(executor&&executor.state!=="terminated"&&executor.state!=="error"){if((await waitForExecutorReady(executor.id,{timeoutMs})).ready)return!0}}catch{}let start=Date.now();while(Date.now()-start<timeoutMs){try{let content=await capturePaneContent(paneId,30);if(detectState(content).type==="idle")return!0}catch{}await new Promise((r)=>setTimeout(r,AUTO_SPAWN_POLL_INTERVAL_MS))}return!1}async function isExecutorCompleted(worker){if(!worker?.currentExecutorId)return!1;let executor=await getCurrentExecutor(worker.id);return executor!=null&&(executor.state==="done"||executor.state==="terminated")}async function isWorkerDead(w){if(w.currentExecutorId){let state=await getAgentEffectiveState(w.id);return state==="terminated"||state==="offline"}return w.state==="suspended"}async function resolveRecipient(recipientId){let allWorkers=await list(),byId=[],byRole=[],byTeamRole=[];for(let w of allWorkers){if(await isWorkerDead(w))continue;if(!await _deps2.isPaneAlive(w.paneId))continue;if(w.id===recipientId)byId.push(w);else if(w.role===recipientId)byRole.push(w);else if(`${w.team}:${w.role}`===recipientId)byTeamRole.push(w)}if(byId.length>0)return byId;if(byRole.length>0)return byRole;return byTeamRole}async function findLiveWorkerFuzzy(recipientId){let matches=await resolveRecipient(recipientId);return matches.length===1?matches[0]:null}async function ensureWorkerAlive(worker,recipientId){if(worker&&worker.state!=="suspended"&&await _deps2.isPaneAlive(worker.paneId))return{worker,respawned:!1};let live=await findLiveWorkerFuzzy(recipientId);if(live)return{worker:live,respawned:!1};if(await isExecutorCompleted(worker))return null;if(!process.env.TMUX)return null;let templates=await listTemplates(),candidates=[worker?.role,worker?.id,recipientId].filter((v)=>Boolean(v)),uniqueCandidates=[...new Set(candidates)],workerTeam=worker?.team,template=templates.find((t)=>{if(workerTeam&&t.team!==workerTeam)return!1;return uniqueCandidates.some((q)=>t.id===q||t.role===q||`${t.team}:${t.role}`===q)});if(!template)return null;let resumeSessionId=template.provider==="claude"&&worker?.claudeSessionId?worker.claudeSessionId:void 0;try{let lockResult=await(await getConnection()).begin(async(tx)=>{await tx`SELECT pg_advisory_xact_lock(hashtext(${recipientId}))`;let postLockLive=await findLiveWorkerFuzzy(recipientId);if(postLockLive)return{type:"existing",worker:postLockLive};if(await cleanupDeadWorkers(recipientId,workerTeam),worker)await unregister(worker.id);let{spawnWorkerFromTemplate:spawnWorkerFromTemplate2}=await Promise.resolve().then(() => (init_protocol_router_spawn(),exports_protocol_router_spawn));return{type:"spawned",...await spawnWorkerFromTemplate2(template,resumeSessionId)}});if(lockResult.type==="existing")return{worker:lockResult.worker,respawned:!1};if(await saveTemplate({...template,lastSpawnedAt:new Date().toISOString()}),await(_deps2.waitForWorkerReady??waitForWorkerReady)(lockResult.paneId),!await _deps2.isPaneAlive(lockResult.paneId))return await unregister(lockResult.worker.id),null;return{worker:lockResult.worker,respawned:!0}}catch(err){let msg=err instanceof Error?err.message:String(err);if(console.error(`[protocol-router] Spawn failed for "${recipientId}": ${msg}`),worker)await update(worker.id,{state:"error"}).catch(()=>{});return null}}async function cleanupDeadWorkers(recipientId,team){let allWorkers=await list();for(let w of allWorkers){if(team&&w.team!==team)continue;if(!(w.role===recipientId||w.id===recipientId))continue;if(await _deps2.isPaneAlive(w.paneId))continue;await unregister(w.id)}}async function deliverToWorker(repoPath,from,worker,body){let message=await send(repoPath,from,worker.id,body),delivered=!1;if(worker.nativeTeamEnabled&&worker.team&&worker.role)delivered=await writeToNativeInbox(worker,message);else delivered=await injectToTmuxPane(worker,message);if(!delivered&&worker.team){let agentName=worker.role||worker.id.split("-").slice(-1)[0]||worker.id;try{let nativeMsg=toNativeInboxMessage(message,worker.nativeColor??"blue");await writeNativeInbox(worker.team,agentName,nativeMsg),delivered=!0}catch{}}if(delivered)await markDelivered(repoPath,worker.id,message.id);else console.error(`[protocol-router] Delivery failed: all paths exhausted (worker=${worker.id}, pane=${worker.paneId}, msg="${body.slice(0,50)}")`);return{messageId:message.id,workerId:worker.id,delivered}}async function deliverViaNativeInbox(repoPath,from,to,body,teamName){let resolvedTeam=teamName??await discoverTeamName();if(!resolvedTeam)return null;let config=await loadConfig(resolvedTeam).catch(()=>null);if(!config)return null;let sanitizedTo=sanitizeTeamName(to),matchedMember=config.members?.find((m)=>m.name===to||m.name===sanitizedTo||m.agentId===`${to}@${resolvedTeam}`||m.agentId===`${sanitizedTo}@${resolvedTeam}`);if(!matchedMember)return null;let inboxName=matchedMember.name??to;try{let message=await send(repoPath,from,to,body),nativeMsg={from,text:body,summary:body.length>50?`${body.substring(0,50)}...`:body,timestamp:new Date().toISOString(),color:"blue",read:!1};return await writeNativeInbox(resolvedTeam,inboxName,nativeMsg),await markDelivered(repoPath,to,message.id),{messageId:message.id,workerId:to,delivered:!0}}catch{return null}}async function sendMessage2(repoPath,from,to,body,teamName){if(from===to)return{messageId:"",workerId:to,delivered:!0,reason:"Self-delivery suppressed"};let liveMatches=await resolveRecipient(to);if(liveMatches.length===1){if(!await _deps2.isPaneAlive(liveMatches[0].paneId)){let message=await send(repoPath,from,liveMatches[0].id,body);return console.error(`[protocol-router] Delivery failed: pane dead (worker=${liveMatches[0].id}, msg="${body.slice(0,50)}")`),{messageId:message.id,workerId:liveMatches[0].id,delivered:!1,reason:"Pane died before delivery"}}return deliverToWorker(repoPath,from,liveMatches[0],body)}if(liveMatches.length>1)return{messageId:"",workerId:to,delivered:!1,reason:`Worker "${to}" is ambiguous. Found ${liveMatches.length} live matches: ${liveMatches.map((m)=>m.id).join(", ")}. Use exact worker ID.`};let{resolve:resolve5}=await Promise.resolve().then(() => (init_agent_directory(),exports_agent_directory)),dirResolved=await resolve5(to),worker=await get(to);if(!worker){let allWorkers=await list();worker=allWorkers.find((w)=>w.role===to&&w.state==="suspended")??allWorkers.find((w)=>w.role===to)??null}if(dirResolved||worker){let alive=await ensureWorkerAlive(worker,to);if(alive){if(!await _deps2.isPaneAlive(alive.worker.paneId)){let message=await send(repoPath,from,alive.worker.id,body);return console.error(`[protocol-router] Delivery failed: pane dead after spawn (worker=${alive.worker.id}, msg="${body.slice(0,50)}")`),{messageId:message.id,workerId:alive.worker.id,delivered:!1,reason:"Pane died after spawn"}}return deliverToWorker(repoPath,from,alive.worker,body)}}let nativeResult=await deliverViaNativeInbox(repoPath,from,to,body,teamName);if(nativeResult)return nativeResult;return{messageId:"",workerId:to,delivered:!1,reason:`Worker "${to}" not found or not alive`}}async function writeToNativeInbox(worker,message){try{let nativeMsg=toNativeInboxMessage(message,worker.nativeColor??"blue"),agentName=worker.role??worker.id;return await writeNativeInbox(worker.team??"",agentName,nativeMsg),!0}catch{return!1}}async function injectToTmuxPane(worker,message){if(!worker.paneId)return!1;if(!/^%\d+$/.test(worker.paneId))return!1;if(!await _deps2.isPaneAlive(worker.paneId))return!1;try{let escaped=message.body.replace(/'/g,"'\\''");return await executeTmux2(`send-keys -t '${worker.paneId}' '${escaped}'`),await new Promise((resolve5)=>setTimeout(resolve5,200)),await executeTmux2(`send-keys -t '${worker.paneId}' Enter`),!0}catch{return!1}}async function deliverToPane(toWorker,messageId){let worker=await get(toWorker);if(!worker||!worker.paneId)return!1;if(!await _deps2.isPaneAlive(worker.paneId))return!1;let message=await getById(messageId);if(!message||message.deliveredAt)return!1;let injected=await injectToTmuxPane(worker,message);if(injected&&worker.repoPath)await markDelivered(worker.repoPath,worker.id,messageId);return injected}async function getInbox(repoPath,workerId){return inbox(repoPath,workerId)}var _deps2,AUTO_SPAWN_READY_TIMEOUT_MS=15000,AUTO_SPAWN_POLL_INTERVAL_MS=1000;var init_protocol_router=__esm(()=>{init_agent_registry();init_claude_native_teams();init_db();init_executor_registry();init_mailbox();init_orchestrator();init_spawn_command();init_tmux();_deps2={isPaneAlive,waitForWorkerReady:null}});var exports_scheduler_daemon={};__export(exports_scheduler_daemon,{startDaemon:()=>startDaemon,recoverOnStartup:()=>recoverOnStartup,reconcileOrphans:()=>reconcileOrphans,reconcileOrphanedRuns:()=>reconcileOrphanedRuns,reclaimExpiredLeases:()=>reclaimExpiredLeases,logToFile:()=>logToFile,fireTrigger:()=>fireTrigger,emitWorkerEvents:()=>emitWorkerEvents,collectMachineSnapshot:()=>collectMachineSnapshot,collectHeartbeats:()=>collectHeartbeats,claimDueTriggers:()=>claimDueTriggers,attemptAgentResume:()=>attemptAgentResume,_resetWorkerStatesForTesting:()=>_resetWorkerStatesForTesting});import{randomUUID as randomUUID5}from"crypto";import{appendFileSync as appendFileSync2,mkdirSync as mkdirSync10}from"fs";import{homedir as homedir22}from"os";import{join as join34}from"path";function getLogDir2(){return join34(process.env.GENIE_HOME??join34(homedir22(),".genie"),"logs")}function getLogFile(){return join34(getLogDir2(),"scheduler.log")}function logToFile(entry){let logDir=getLogDir2();mkdirSync10(logDir,{recursive:!0}),appendFileSync2(getLogFile(),`${JSON.stringify(entry)}
|
|
1524
1524
|
`)}async function defaultSpawnCommand(command,env){return{pid:Bun.spawn(["sh","-c",command],{env:{...process.env,...env},stdio:["ignore","ignore","ignore"]}).pid}}function defaultJitter(maxMs){return Math.floor(Math.random()*maxMs)}function defaultSleep(ms){return new Promise((resolve5)=>setTimeout(resolve5,ms))}async function defaultIsPaneAlive(paneId){let{isPaneAlive:isPaneAlive2}=await Promise.resolve().then(() => (init_tmux(),exports_tmux));return isPaneAlive2(paneId)}async function defaultListWorkers(){let{list:list2}=await Promise.resolve().then(() => (init_agent_registry(),exports_agent_registry));return(await list2()).map((a)=>({id:a.id,paneId:a.paneId,repoPath:a.repoPath,state:a.state,team:a.team,wishSlug:a.wishSlug,groupNumber:a.groupNumber,autoResume:a.autoResume,resumeAttempts:a.resumeAttempts,maxResumeAttempts:a.maxResumeAttempts,lastResumeAttempt:a.lastResumeAttempt,claudeSessionId:a.claudeSessionId}))}async function defaultPublishEvent(subject,data,repoPath){let payload=data,{publishSubjectEvent:publishSubjectEvent2}=await Promise.resolve().then(() => (init_runtime_events(),exports_runtime_events));await publishSubjectEvent2(repoPath,subject,{timestamp:payload.timestamp,kind:payload.kind??"system",agent:payload.agent??"scheduler",team:payload.team,direction:payload.direction,peer:payload.peer,text:payload.text??subject,data:payload.data,source:payload.source??"registry"})}async function defaultCountTmuxSessions(){try{let{execSync:execSync9}=await import("child_process"),{genieTmuxCmd:genieTmuxCmd2}=await Promise.resolve().then(() => (init_tmux_wrapper(),exports_tmux_wrapper));return execSync9(`${genieTmuxCmd2("list-sessions")} 2>/dev/null`,{encoding:"utf-8"}).trim().split(`
|
|
1525
1525
|
`).filter(Boolean).length}catch{return 0}}async function defaultResumeAgent(agentId){try{let{execSync:execSync9}=await import("child_process");return execSync9(`genie agent resume ${agentId}`,{encoding:"utf-8",stdio:["pipe","pipe","pipe"]}),!0}catch{return!1}}async function defaultUpdateAgent(agentId,updates){let{update:update2}=await Promise.resolve().then(() => (init_agent_registry(),exports_agent_registry));await update2(agentId,updates)}function createDefaultDeps(){return{getConnection:async()=>{let{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db));return getConnection2()},spawnCommand:defaultSpawnCommand,log:logToFile,generateId:randomUUID5,now:()=>new Date,sleep:defaultSleep,jitter:defaultJitter,isPaneAlive:defaultIsPaneAlive,listWorkers:defaultListWorkers,countTmuxSessions:defaultCountTmuxSessions,publishEvent:defaultPublishEvent,resumeAgent:defaultResumeAgent,updateAgent:defaultUpdateAgent}}function resolveConfig(overrides){let envMax=process.env.GENIE_MAX_CONCURRENT,maxConcurrent=envMax?Number.parseInt(envMax,10):5;return{maxConcurrent:overrides?.maxConcurrent??(Number.isNaN(maxConcurrent)?5:maxConcurrent),pollIntervalMs:overrides?.pollIntervalMs??30000,maxJitterMs:overrides?.maxJitterMs??30000,jitterThreshold:overrides?.jitterThreshold??3,heartbeatIntervalMs:overrides?.heartbeatIntervalMs??60000,orphanCheckIntervalMs:overrides?.orphanCheckIntervalMs??300000,deadHeartbeatThreshold:overrides?.deadHeartbeatThreshold??2,leaseRecoveryIntervalMs:overrides?.leaseRecoveryIntervalMs??60000}}async function claimDueTriggers(deps,config,daemonId){let sql=await deps.getConnection(),now=deps.now(),leaseUntil=new Date(now.getTime()+300000),runningCount=(await sql`
|
|
1526
1526
|
SELECT count(*)::int AS cnt FROM runs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genie",
|
|
3
|
-
"version": "4.260405.
|
|
3
|
+
"version": "4.260405.15",
|
|
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"
|
|
@@ -145,6 +145,14 @@ export async function updateExecutorState(id: string, state: ExecutorState): Pro
|
|
|
145
145
|
recordAuditEvent('executor', id, 'state_changed', process.env.GENIE_AGENT_NAME ?? 'cli', {
|
|
146
146
|
state,
|
|
147
147
|
}).catch(() => {});
|
|
148
|
+
|
|
149
|
+
// Emit a dedicated ready event when executor reaches 'running' state
|
|
150
|
+
if (state === 'running') {
|
|
151
|
+
recordAuditEvent('executor', id, 'executor.ready', process.env.GENIE_AGENT_NAME ?? 'cli', {
|
|
152
|
+
state,
|
|
153
|
+
readiness_source: 'state_transition',
|
|
154
|
+
}).catch(() => {});
|
|
155
|
+
}
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
/** Terminate an executor: set state='terminated', ended_at=now(). */
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
import * as registry from './agent-registry.js';
|
|
17
17
|
import * as nativeTeams from './claude-native-teams.js';
|
|
18
18
|
import { getConnection } from './db.js';
|
|
19
|
-
import { getCurrentExecutor } from './executor-registry.js';
|
|
19
|
+
import { findExecutorByPane, getCurrentExecutor } from './executor-registry.js';
|
|
20
20
|
import * as mailbox from './mailbox.js';
|
|
21
21
|
import { detectState } from './orchestrator/index.js';
|
|
22
|
+
import { waitForExecutorReady } from './spawn-command.js';
|
|
22
23
|
import { capturePaneContent, executeTmux, isPaneAlive } from './tmux.js';
|
|
23
24
|
|
|
24
25
|
// ============================================================================
|
|
@@ -52,6 +53,19 @@ const AUTO_SPAWN_READY_TIMEOUT_MS = 15000;
|
|
|
52
53
|
const AUTO_SPAWN_POLL_INTERVAL_MS = 1000;
|
|
53
54
|
|
|
54
55
|
async function waitForWorkerReady(paneId: string, timeoutMs = AUTO_SPAWN_READY_TIMEOUT_MS): Promise<boolean> {
|
|
56
|
+
// Try PG-based readiness detection first (faster, cross-process)
|
|
57
|
+
try {
|
|
58
|
+
const executor = await findExecutorByPane(paneId);
|
|
59
|
+
if (executor && executor.state !== 'terminated' && executor.state !== 'error') {
|
|
60
|
+
const result = await waitForExecutorReady(executor.id, { timeoutMs });
|
|
61
|
+
if (result.ready) return true;
|
|
62
|
+
// PG readiness timed out — fall through to tmux scraping as safety net
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// PG unavailable — fall through to tmux scraping
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fallback: original tmux pane scraping
|
|
55
69
|
const start = Date.now();
|
|
56
70
|
while (Date.now() - start < timeoutMs) {
|
|
57
71
|
try {
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
DEFAULT_SPAWN_TIMEOUT_MS,
|
|
9
9
|
READINESS_POLL_INTERVAL_MS,
|
|
10
10
|
type WorkerProfile,
|
|
11
|
+
_pgDeps,
|
|
11
12
|
buildSpawnCommand,
|
|
12
13
|
waitForAgentReady,
|
|
14
|
+
waitForExecutorReady,
|
|
13
15
|
} from './spawn-command.js';
|
|
14
16
|
|
|
15
17
|
// ============================================================================
|
|
@@ -346,3 +348,84 @@ describe('waitForAgentReady', () => {
|
|
|
346
348
|
expect(mockCapturePaneContent).toHaveBeenCalledWith('%42', 50);
|
|
347
349
|
});
|
|
348
350
|
});
|
|
351
|
+
|
|
352
|
+
// ============================================================================
|
|
353
|
+
// PG-Based Readiness Detection — waitForExecutorReady
|
|
354
|
+
// ============================================================================
|
|
355
|
+
|
|
356
|
+
describe('waitForExecutorReady', () => {
|
|
357
|
+
// Save original deps for restore
|
|
358
|
+
const origIsAvailable = _pgDeps.isAvailable;
|
|
359
|
+
const origGetConnection = _pgDeps.getConnection;
|
|
360
|
+
const origGetExecutor = _pgDeps.getExecutor;
|
|
361
|
+
|
|
362
|
+
afterEach(() => {
|
|
363
|
+
// Restore real deps
|
|
364
|
+
_pgDeps.isAvailable = origIsAvailable;
|
|
365
|
+
_pgDeps.getConnection = origGetConnection;
|
|
366
|
+
_pgDeps.getExecutor = origGetExecutor;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('returns ready immediately if executor already in running state', async () => {
|
|
370
|
+
_pgDeps.isAvailable = async () => true;
|
|
371
|
+
_pgDeps.getExecutor = async () => ({ id: 'exec-1', state: 'running' });
|
|
372
|
+
|
|
373
|
+
const result = await waitForExecutorReady('exec-1', { timeoutMs: 500 });
|
|
374
|
+
|
|
375
|
+
expect(result.ready).toBe(true);
|
|
376
|
+
expect(result.elapsedMs).toBeLessThan(500);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('returns ready immediately if executor already in idle state', async () => {
|
|
380
|
+
_pgDeps.isAvailable = async () => true;
|
|
381
|
+
_pgDeps.getExecutor = async () => ({ id: 'exec-1', state: 'idle' });
|
|
382
|
+
|
|
383
|
+
const result = await waitForExecutorReady('exec-1', { timeoutMs: 500 });
|
|
384
|
+
|
|
385
|
+
expect(result.ready).toBe(true);
|
|
386
|
+
expect(result.elapsedMs).toBeLessThan(500);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('returns not ready if PG is unavailable (graceful degradation)', async () => {
|
|
390
|
+
_pgDeps.isAvailable = async () => false;
|
|
391
|
+
|
|
392
|
+
const result = await waitForExecutorReady('exec-1', { timeoutMs: 500 });
|
|
393
|
+
|
|
394
|
+
expect(result.ready).toBe(false);
|
|
395
|
+
expect(result.elapsedMs).toBe(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('times out if executor stays in spawning state', async () => {
|
|
399
|
+
_pgDeps.isAvailable = async () => true;
|
|
400
|
+
_pgDeps.getExecutor = async () => ({ id: 'exec-1', state: 'spawning' });
|
|
401
|
+
_pgDeps.getConnection = async () => ({
|
|
402
|
+
listen: async () => ({ unlisten: async () => {} }),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const result = await waitForExecutorReady('exec-1', { timeoutMs: 300 });
|
|
406
|
+
|
|
407
|
+
expect(result.ready).toBe(false);
|
|
408
|
+
expect(result.elapsedMs).toBeGreaterThanOrEqual(300);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('returns ready when executor transitions to running during poll', async () => {
|
|
412
|
+
_pgDeps.isAvailable = async () => true;
|
|
413
|
+
|
|
414
|
+
let callCount = 0;
|
|
415
|
+
_pgDeps.getExecutor = async () => {
|
|
416
|
+
callCount++;
|
|
417
|
+
// First call (initial check): spawning
|
|
418
|
+
// Second call (poll): running
|
|
419
|
+
if (callCount <= 1) return { id: 'exec-1', state: 'spawning' };
|
|
420
|
+
return { id: 'exec-1', state: 'running' };
|
|
421
|
+
};
|
|
422
|
+
_pgDeps.getConnection = async () => ({
|
|
423
|
+
listen: async () => ({ unlisten: async () => {} }),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await waitForExecutorReady('exec-1', { timeoutMs: 5000 });
|
|
427
|
+
|
|
428
|
+
expect(result.ready).toBe(true);
|
|
429
|
+
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
430
|
+
});
|
|
431
|
+
});
|
package/src/lib/spawn-command.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Also provides readiness detection for freshly-spawned agents via tmux pane inspection.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { getConnection, isAvailable } from './db.js';
|
|
9
|
+
import { getExecutor } from './executor-registry.js';
|
|
8
10
|
import { detectState } from './orchestrator/index.js';
|
|
9
11
|
import { capturePaneContent } from './tmux.js';
|
|
10
12
|
|
|
@@ -147,3 +149,119 @@ export async function waitForAgentReady(
|
|
|
147
149
|
|
|
148
150
|
return { ready: false, elapsedMs: Date.now() - start };
|
|
149
151
|
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// PG-Based Readiness Detection
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
/** Overridable deps for PG-based readiness — avoids mock.module leaking across test files in bun. */
|
|
158
|
+
export const _pgDeps = {
|
|
159
|
+
isAvailable: isAvailable as () => Promise<boolean>,
|
|
160
|
+
getConnection: getConnection as () => Promise<any>,
|
|
161
|
+
getExecutor: getExecutor as (id: string) => Promise<any>,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wait for an executor to become ready via PG LISTEN/NOTIFY.
|
|
166
|
+
* Subscribes to `genie_executor_state` channel and waits for the executor
|
|
167
|
+
* to transition from 'spawning' to 'running' or 'idle'.
|
|
168
|
+
* Falls back to polling the executors table every 2s (safety net).
|
|
169
|
+
*
|
|
170
|
+
* @param executorId - The executor ID to wait for.
|
|
171
|
+
* @param opts.timeoutMs - Max wait time (default: DEFAULT_SPAWN_TIMEOUT_MS = 30s).
|
|
172
|
+
* @returns ReadinessResult with ready flag and elapsed time.
|
|
173
|
+
*/
|
|
174
|
+
export async function waitForExecutorReady(
|
|
175
|
+
executorId: string,
|
|
176
|
+
opts?: { timeoutMs?: number },
|
|
177
|
+
): Promise<ReadinessResult> {
|
|
178
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS;
|
|
179
|
+
const start = Date.now();
|
|
180
|
+
|
|
181
|
+
// Graceful degradation: if PG is unavailable, return immediately
|
|
182
|
+
if (!(await _pgDeps.isAvailable())) {
|
|
183
|
+
return { ready: false, elapsedMs: 0 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// First check: is executor already in a ready state?
|
|
187
|
+
try {
|
|
188
|
+
const executor = await _pgDeps.getExecutor(executorId);
|
|
189
|
+
if (executor && (executor.state === 'running' || executor.state === 'idle')) {
|
|
190
|
+
return { ready: true, elapsedMs: Date.now() - start };
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
return { ready: false, elapsedMs: Date.now() - start };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Subscribe to PG NOTIFY on genie_executor_state channel
|
|
197
|
+
// biome-ignore lint/suspicious/noExplicitAny: postgres.js Sql type requires generics we don't need
|
|
198
|
+
let sql: any;
|
|
199
|
+
try {
|
|
200
|
+
sql = await _pgDeps.getConnection();
|
|
201
|
+
} catch {
|
|
202
|
+
return { ready: false, elapsedMs: Date.now() - start };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return new Promise<ReadinessResult>((resolve) => {
|
|
206
|
+
let resolved = false;
|
|
207
|
+
let listener: { unlisten: () => Promise<void> } | null = null;
|
|
208
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
209
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
210
|
+
|
|
211
|
+
const cleanup = async () => {
|
|
212
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
213
|
+
if (timeout) clearTimeout(timeout);
|
|
214
|
+
if (listener) {
|
|
215
|
+
try {
|
|
216
|
+
await listener.unlisten();
|
|
217
|
+
} catch {
|
|
218
|
+
/* best effort */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const finish = (ready: boolean) => {
|
|
224
|
+
if (resolved) return;
|
|
225
|
+
resolved = true;
|
|
226
|
+
cleanup().then(() => resolve({ ready, elapsedMs: Date.now() - start }));
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Timeout handler
|
|
230
|
+
timeout = setTimeout(() => finish(false), timeoutMs);
|
|
231
|
+
|
|
232
|
+
// LISTEN for executor state changes
|
|
233
|
+
sql
|
|
234
|
+
.listen('genie_executor_state', (payload: string) => {
|
|
235
|
+
// Payload format: executorId:agentId:oldState:newState
|
|
236
|
+
const parts = payload.split(':');
|
|
237
|
+
if (parts.length < 4) return;
|
|
238
|
+
const [notifyExecId, , , newState] = parts;
|
|
239
|
+
if (notifyExecId === executorId && (newState === 'running' || newState === 'idle')) {
|
|
240
|
+
finish(true);
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
.then((l: { unlisten: () => Promise<void> }) => {
|
|
244
|
+
listener = l;
|
|
245
|
+
// If already resolved before listener was set up, clean up immediately
|
|
246
|
+
if (resolved) {
|
|
247
|
+
l.unlisten().catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
.catch(() => {
|
|
251
|
+
// LISTEN failed — rely on polling only
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Safety-net polling every 2s (handles missed NOTIFYs)
|
|
255
|
+
pollInterval = setInterval(async () => {
|
|
256
|
+
if (resolved) return;
|
|
257
|
+
try {
|
|
258
|
+
const executor = await _pgDeps.getExecutor(executorId);
|
|
259
|
+
if (executor && (executor.state === 'running' || executor.state === 'idle')) {
|
|
260
|
+
finish(true);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
/* transient error — keep polling */
|
|
264
|
+
}
|
|
265
|
+
}, READINESS_POLL_INTERVAL_MS);
|
|
266
|
+
});
|
|
267
|
+
}
|