From 6bbedaab8738ea0a0c7dacf33da1e7b2d0043246 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 19 May 2026 08:05:48 +0100 Subject: [PATCH] Require Void moderation launch handshake --- scripts/run-repo-face-heartbeats.ts | 102 +++++++++++++++++++++++++--- state/evidence.jsonl | 1 + 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 4f43ed1..4734f6e 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -166,6 +166,21 @@ async function main(): Promise { heat: participant.heat, effectiveSpeed: participant.effectiveSpeed, }); + } else if (turn.failureReason) { + participant.currentLoad = 0; + state.history.push({ + type: "turn_failed_to_start", + identityId: participant.identityId, + participantKind: participant.participantKind, + turnKind: participant.turnKind, + activeJobId: turn.activeJobId, + requestMessageId: turn.requestMessageId, + queuedAt, + initiativeClock: state.initiativeClock, + reason: turn.failureReason, + heat: participant.heat, + effectiveSpeed: participant.effectiveSpeed, + }); } } } finally { @@ -228,12 +243,12 @@ async function queueParticipantTurn(input: { config: ReturnType; storage: Awaited>; queuedAt: string; -}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string }> { +}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> { switch (input.participant.turnKind) { case "repo_face_rumination": return queueRepoFaceTurn(input); case "void_moderation": - return startVoidModerationTurn(input.queuedAt); + return startVoidModerationTurn(input.queuedAt, input.config.storageRoot); } } @@ -412,38 +427,105 @@ interface DiscordApiMessage { }; } -function startVoidModerationTurn( +async function startVoidModerationTurn( queuedAt: string, -): { created: boolean; activeJobId?: string; requestMessageId?: string } { + storageRoot: string, +): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> { const runnerScript = resolve(process.cwd(), "scripts", "run-void-moderator-rumination.ps1"); + const lockPath = resolve(storageRoot, "status", "moderation-rumination.lock"); + const statusPath = resolve(storageRoot, "status", "moderation-rumination.json"); + const launchedAt = Date.now(); + const launchCommand = [ + `$arguments = @(${[ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + runnerScript, + ].map(toPowerShellSingleQuotedString).join(", ")});`, + `Start-Process -FilePath ${toPowerShellSingleQuotedString("powershell.exe")} -ArgumentList $arguments -WorkingDirectory ${toPowerShellSingleQuotedString(process.cwd())} -WindowStyle Hidden;`, + ].join(" "); const child = spawn( "powershell.exe", [ "-NoProfile", "-NonInteractive", - "-WindowStyle", - "Hidden", "-ExecutionPolicy", "Bypass", - "-File", - runnerScript, + "-Command", + launchCommand, ], { cwd: process.cwd(), - detached: true, stdio: "ignore", windowsHide: true, }, ); child.unref(); + const handshake = await waitForVoidModerationHandshake({ + lockPath, + statusPath, + launchedAt, + timeoutMs: 60000, + }); + + if (!handshake.started) { + return { + created: false, + activeJobId: child.pid ? `launcher-process:${child.pid}` : undefined, + requestMessageId: `agent-heartbeat:void:${queuedAt}`, + failureReason: handshake.reason, + }; + } return { created: true, - activeJobId: child.pid ? `process:${child.pid}` : `process:void-moderation:${queuedAt}`, + activeJobId: `process:void-moderation:${queuedAt}`, requestMessageId: `agent-heartbeat:void:${queuedAt}`, }; } +function toPowerShellSingleQuotedString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +async function waitForVoidModerationHandshake(input: { + lockPath: string; + statusPath: string; + launchedAt: number; + timeoutMs: number; +}): Promise<{ started: true } | { started: false; reason: string }> { + const deadline = Date.now() + input.timeoutMs; + while (Date.now() < deadline) { + if (await wasTouchedAfter(input.lockPath, input.launchedAt)) { + return { started: true }; + } + if (await wasTouchedAfter(input.statusPath, input.launchedAt)) { + return { started: true }; + } + await sleep(250); + } + + return { + started: false, + reason: "void_moderation_launch_handshake_missing", + }; +} + +async function wasTouchedAfter(path: string, timestampMs: number): Promise { + try { + const info = await stat(path); + return info.mtimeMs >= timestampMs - 500; + } catch { + return false; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + async function listExistingActiveTurns( databaseDsn: string, stateStorageBackend: "file" | "postgres", diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 04f4add..a77bf39 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -157,3 +157,4 @@ {"ts":"2026-05-18T18:00:31Z","type":"implementation","status":"ok","note":"Agent CTB roster now includes built-in Void as a system_agent participant alongside repo Faces. The scheduler dispatches repo Faces as repo-face-rumination owner-Codex jobs and dispatches Void by starting the existing moderation rumination runner, with moderation-rumination.lock treated as Void's active turn. Live state now shows participants void and nibu; the old independent Void Moderator Rumination task was disabled so CTB owns Void moderation timing after the currently running pass clears."} {"ts":"2026-05-18T18:27:18Z","type":"implementation","status":"ok","note":"CTB pulse cadence can now run at one-minute granularity: config schema and install-repo-face-heartbeat-task.ps1 no longer enforce the old five-minute floor. Local stream mode .env is set to REPO_FACE_HEARTBEAT_INTERVAL_MINUTES=1; reinstall with -RunNow succeeded, LastTaskResult 0, and the immediate pulse advanced CTB and queued Nibu job 0dd3a436."} {"ts":"2026-05-18T19:35:52Z","type":"implementation","status":"ok","note":"Repo Face heartbeat projection now attaches live Discord channel context plus the Face's typed self-state, and heartbeat prompts must answer directed agent requests visible in recent room context instead of asking what the job is."} +{"ts":"2026-05-19T07:04:25Z","type":"implementation","status":"ok","note":"Repo Face CTB now requires a real Void moderation launch handshake before marking Void queued. Direct spawn failures are recorded as turn_failed_to_start instead of fake completed turns; PowerShell Start-Process launch reached the moderation lock and current Void runner is active."}