Files
podman/backend/src/agent/podman.ts
T
Ramis 6a6fbca9e0 fix: stop agent crashing on Mongo errors during live loop
A Mongo auth/connection failure in getGitStates escaped uncaught and
killed the agent process on the first screen frame, so collision
detection never ran. Make git-state fusion best-effort (degrade to
vision-only) and wrap the whole onScreenFrame loop so no per-frame
Gemini/GitHub/Mongo error can crash the long-running agent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaCFWMkYQmTcuPsxaaACft
2026-06-27 20:31:38 -07:00

114 lines
4.4 KiB
TypeScript

import { RoomEvent, type Room } from '@livekit/rtc-node';
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
import { analyzeFrame } from '../vision/gemini.js';
import { detectCollisions } from '../collision/detector.js';
import { getGithubState } from '../github/client.js';
import {
recordObservation,
recordCollision,
recordIntervention,
updateInterventionStatus,
} from '../memory/store.js';
import { getGitStates } from '../memory/db.js';
import { recallSimilar } from '../memory/vectors.js';
import { shouldIntervene, preferredAction } from '../memory/policy.js';
import { speak } from '../voice/live.js';
import { publishHermesMessage } from '../action/hermes.js';
export class PodMan {
private contexts = new Map<string, EngineerContext>();
private encoder = new TextEncoder();
constructor(
private room: Room,
private podId: string,
) {}
async start(): Promise<void> {
// Tier-2 optional ground-truth + engineer ACKs arrive over the data channel.
this.room.on(RoomEvent.DataReceived, (payload) => {
try {
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
if (msg.type === 'GIT_REPORT') {
const c = this.contexts.get(msg.report.engineerId);
if (c)
c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
}
if (msg.type === 'ACK') void updateInterventionStatus(msg.interventionId, msg.status);
} catch {
/* ignore malformed */
}
});
}
async onScreenFrame(engineerId: string, jpeg: Buffer): Promise<void> {
// Whole-frame guard: a Gemini/GitHub/Mongo failure on one frame must degrade
// gracefully, never crash the long-running live agent loop.
try {
const ctx = await analyzeFrame(engineerId, this.podId, jpeg);
this.contexts.set(engineerId, ctx);
await recordObservation(ctx);
// Fuse git ground truth: engineer_states written by scripts/podman-agent.mjs.
// Keyed by name (matches --name arg), same as LiveKit participant identity.
const gitStates = await getGitStates(this.podId);
for (const [id, c] of this.contexts) {
const git = gitStates.get(id);
if (git && git.changedFiles.length > 0) c.hasUnpushedChanges = true;
}
const github = await getGithubState(); // cached
const collisions = detectCollisions([...this.contexts.values()], github);
for (const collision of collisions) await this.handle(collision);
} catch (err) {
console.warn(`[agent] frame from ${engineerId} skipped: ${(err as Error).message}`);
}
}
private async handle(collision: Collision): Promise<void> {
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
if (prior) collision.severity = 'critical';
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
await recordCollision(collision);
const action = preferredAction(collision, prior);
const names = collision.engineers.join(' and ');
const message =
`${names} are both editing ${collision.file}` +
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
(prior?.priorOutcome?.accepted
? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.`
: prior
? ` I've seen this conflict pattern before.`
: '');
const intervention: Intervention = {
id: `int_${Date.now()}`,
collisionId: collision.id,
podId: this.podId,
kind: 'card',
message,
suggestedAction: {
kind: action,
params: {
file: collision.file,
summary: message,
engineers: collision.engineers,
},
},
status: 'pending',
createdAt: new Date().toISOString(),
};
await recordIntervention(intervention);
const data: DataMessage = { type: 'COLLISION', collision, intervention };
await this.room.localParticipant?.publishData(this.encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
await publishHermesMessage(this.room, collision, intervention);
if (collision.severity === 'critical') await speak(this.room, message);
}
}