Files
openchamber/packages/web/server/lib/plan-gate/runtime.js
T
bot-hermes 9a0c67081e fix: wire plan-gate activation and add express.json to steer/plan-gate routes
- Wire planGateRuntime.activate() into session creation path when planGate is true
  (Bug 1: sessions map stayed empty, plan/status always returned none)
- Add express.json({ limit: '1mb' }) to session-steer and plan-gate POST routes
  (Bug 2: req.body was undefined, POST with JSON body returned 400)
- Pass planGateRuntime dependency to createOpenChamberSessionService
- Add route-level and integration tests for both fixes
2026-09-07 23:41:36 +00:00

341 lines
12 KiB
JavaScript

// Plan-first gate: the agent must emit its implementation plan and get approval
// before touching files. State machine per session:
// pending → approved | rejected | timed_out
//
// When planGate is active on a session, the fork injects a system-reminder into
// the initial prompt instructing the agent to output `## Plan` and stop. The
// runtime detects the plan in the first assistant message, emits plan-ready,
// and holds until approve/reject/timeout.
import express from 'express';
import { GOAL_OBJECTIVE_CHAR_LIMIT } from '../session-goal/objectives.js';
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
const asList = (value) => (Array.isArray(value) ? value : []);
const FETCH_TIMEOUT_MS = 15_000;
const LANDED_TIMEOUT_MS = 5_000;
const LANDED_POLL_MS = 150;
const DEFAULT_PLAN_GATE_TIMEOUT_MS = 5 * 60 * 1000;
const PLAN_MARKER = '## Plan';
const extractMessageUpdate = (payload) => {
if (payload.type !== 'message.updated') return null;
const properties = asRecord(payload.properties) ?? {};
const info = asRecord(properties.info);
if (!info) return null;
const sessionId = asNonEmptyString(info.sessionID);
if (!sessionId) return null;
return { sessionId, info };
};
const extractAssistantText = (info) => {
const parts = asList(info?.parts);
return parts
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
.filter(Boolean)
.join('\n')
.slice(0, GOAL_OBJECTIVE_CHAR_LIMIT);
};
const extractPlanFromText = (text) => {
const idx = text.indexOf(PLAN_MARKER);
if (idx < 0) return null;
return text.slice(idx).trim();
};
const extractCardID = (info) => {
const metadata = asRecord(info?.metadata);
const namespace = asRecord(metadata?.openchamber);
return asNonEmptyString(namespace?.cardID) || '';
};
export function createPlanGateRuntime({
globalEventHub,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
broadcastGlobalUiEvent,
fetchImpl = fetch,
planGateTimeoutMs = DEFAULT_PLAN_GATE_TIMEOUT_MS,
}) {
/** sessionId → { state, plan, directory, cardID, timeout } */
const sessions = new Map();
let stopped = false;
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
const base = buildOpenCodeUrl(fetchPath, '');
const params = new URLSearchParams(query || {});
if (directory) params.set('directory', directory);
const search = params.toString();
const url = search ? `${base}?${search}` : base;
const response = await fetchImpl(url, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...getOpenCodeAuthHeaders(),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`OpenCode ${method} ${fetchPath} failed with ${response.status}`);
}
return response.json().catch(() => null);
};
const latestUserMessageID = async (sessionId, directory) => {
const messages = asList(await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
directory,
query: { limit: '5' },
}).catch(() => null));
let latest = null;
for (const msg of messages) {
const info = asRecord(msg?.info);
if (info?.role !== 'user') continue;
if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info;
}
return asNonEmptyString(latest?.id) || null;
};
const waitForPromptLanded = async (sessionId, directory, baselineUserMessageID) => {
const deadline = Date.now() + LANDED_TIMEOUT_MS;
for (;;) {
const latest = await latestUserMessageID(sessionId, directory);
if (!latest) return true;
if (latest !== baselineUserMessageID) return true;
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, LANDED_POLL_MS));
}
};
const clearSessionTimeout = (sessionId) => {
const session = sessions.get(sessionId);
if (session?.timeout) {
clearTimeout(session.timeout);
session.timeout = null;
}
};
const armTimeout = (sessionId) => {
clearSessionTimeout(sessionId);
const session = sessions.get(sessionId);
if (!session || session.state !== 'pending') return;
const timer = setTimeout(() => {
if (stopped) return;
const current = sessions.get(sessionId);
if (!current || current.state !== 'pending') return;
current.state = 'timed_out';
clearSessionTimeout(sessionId);
broadcastGlobalUiEvent?.({
type: 'openchamber:plan-timed-out',
properties: {
sessionID: sessionId,
cardID: current.cardID || '',
ts: Date.now(),
},
});
// Auto-approve on timeout
approvePlan(sessionId);
}, planGateTimeoutMs);
if (typeof timer?.unref === 'function') timer.unref();
session.timeout = timer;
};
const approvePlan = async (sessionId, feedback = '') => {
const session = sessions.get(sessionId);
if (!session || session.state !== 'pending') return null;
clearSessionTimeout(sessionId);
session.state = 'approved';
const directory = session.directory;
const cardID = session.cardID;
broadcastGlobalUiEvent?.({
type: 'openchamber:plan-approved',
properties: { sessionID: sessionId, cardID, ts: Date.now() },
});
try {
const promptText = feedback
? `Plan approved. Additional context: ${feedback}\n\nProceed with implementation.`
: 'Plan approved. Proceed with implementation.';
const baseline = await latestUserMessageID(sessionId, directory);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
directory,
method: 'POST',
body: {
parts: [{ type: 'text', text: promptText, synthetic: true }],
},
});
// Fire-and-forget: the prompt was accepted by OpenCode. Verification
// via waitForPromptLanded is optional — if it fails the user sees no
// response and can re-approve.
waitForPromptLanded(sessionId, directory, baseline).catch(() => {});
} catch (error) {
console.warn('[plan-gate] approve delivery failed:', error?.message || error);
}
sessions.delete(sessionId);
return { state: 'approved' };
};
const rejectPlan = async (sessionId, feedback) => {
const session = sessions.get(sessionId);
if (!session || session.state !== 'pending') return null;
clearSessionTimeout(sessionId);
session.state = 'rejected';
const directory = session.directory;
const cardID = session.cardID;
broadcastGlobalUiEvent?.({
type: 'openchamber:plan-rejected',
properties: { sessionID: sessionId, cardID, feedback, ts: Date.now() },
});
try {
const promptText = `Plan rejected. Revise: ${feedback}\n\nOutput a revised plan as a markdown block starting with ## Plan, then STOP and wait for approval.`;
const baseline = await latestUserMessageID(sessionId, directory);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
directory,
method: 'POST',
body: {
parts: [{ type: 'text', text: promptText, synthetic: true }],
},
});
waitForPromptLanded(sessionId, directory, baseline).catch(() => {});
// Reset to pending so the revised plan can be detected
session.state = 'pending';
session.plan = null;
armTimeout(sessionId);
} catch (error) {
console.warn('[plan-gate] reject delivery failed:', error?.message || error);
}
return { state: 'rejected' };
};
const getStatus = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) return { state: 'none', plan: null, ts: null };
return {
state: session.state,
plan: session.plan,
cardID: session.cardID || '',
ts: session.ts,
};
};
const activate = (sessionId, directory, cardID = '') => {
if (sessions.has(sessionId)) {
clearSessionTimeout(sessionId);
}
sessions.set(sessionId, {
state: 'pending',
plan: null,
directory,
cardID,
ts: Date.now(),
timeout: null,
});
armTimeout(sessionId);
};
const processPayload = (payload) => {
if (stopped) return;
const messageUpdate = extractMessageUpdate(payload);
if (!messageUpdate) return;
const { sessionId, info } = messageUpdate;
const session = sessions.get(sessionId);
if (!session || session.state !== 'pending') return;
if (info.role !== 'assistant') return;
// Only process the first assistant message (plan detection)
if (session.plan !== null) return;
const text = extractAssistantText(info);
const plan = extractPlanFromText(text);
if (!plan) return;
session.plan = plan;
clearSessionTimeout(sessionId);
const cardID = session.cardID || extractCardID(info);
if (cardID && !session.cardID) session.cardID = cardID;
broadcastGlobalUiEvent?.({
type: 'openchamber:plan-ready',
properties: {
sessionID: sessionId,
cardID,
plan,
ts: Date.now(),
},
});
};
const start = () => {
const unsubscribe = globalEventHub.subscribeEvent((event) => {
const raw = event?.payload;
const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
processPayload(payload);
});
return () => { unsubscribe(); };
};
const stop = () => {
stopped = true;
for (const session of sessions.values()) {
clearSessionTimeout(session.id);
}
sessions.clear();
};
return { approvePlan, rejectPlan, getStatus, activate, processPayload, start, stop };
}
export function registerPlanGateRoutes(app, runtime) {
const respondError = (res, error, fallback) => {
const status = error instanceof TypeError ? 400 : (Number.isFinite(error?.status) ? error.status : 500);
res.status(status).json({ error: error?.message ?? fallback });
};
app.post('/api/openchamber/session/:sessionID/plan/approve', express.json({ limit: '1mb' }), async (req, res) => {
try {
const sessionId = asNonEmptyString(req.params?.sessionID);
if (!sessionId) return res.status(400).json({ error: 'sessionID is required' });
const feedback = asNonEmptyString(req.body?.feedback) || '';
const result = await runtime.approvePlan(sessionId, feedback);
if (!result) return res.status(404).json({ error: 'No pending plan for this session' });
return res.json(result);
} catch (error) {
return respondError(res, error, 'Failed to approve plan');
}
});
app.post('/api/openchamber/session/:sessionID/plan/reject', express.json({ limit: '1mb' }), async (req, res) => {
try {
const sessionId = asNonEmptyString(req.params?.sessionID);
const feedback = asNonEmptyString(req.body?.feedback);
if (!sessionId) return res.status(400).json({ error: 'sessionID is required' });
if (!feedback) return res.status(400).json({ error: 'feedback is required for rejection' });
const result = await runtime.rejectPlan(sessionId, feedback);
if (!result) return res.status(404).json({ error: 'No pending plan for this session' });
return res.json(result);
} catch (error) {
return respondError(res, error, 'Failed to reject plan');
}
});
app.get('/api/openchamber/session/:sessionID/plan/status', (req, res) => {
try {
const sessionId = asNonEmptyString(req.params?.sessionID);
if (!sessionId) return res.status(400).json({ error: 'sessionID is required' });
return res.json(runtime.getStatus(sessionId));
} catch (error) {
return respondError(res, error, 'Failed to get plan status');
}
});
}