Files
podman/backend/src/agent/podman.ts
T
Ramis a853ef7709 feat: fuse engineer_states git truth into collision detector
Add getGitStates(podId) to db.ts — queries the engineer_states collection
written by scripts/podman-agent.mjs and returns a map keyed by engineer name.

In PodMan.onScreenFrame(), fuse before detectCollisions(): if an engineer has
changedFiles in engineer_states, force hasUnpushedChanges=true on their context.
This overrides Gemini vision's unreliable pixel-based guess with deterministic
local git state — preventing the money-moment collision from silently not firing.

Key assumption: LiveKit participant identity matches the --name arg used when
running the git watcher (e.g. identity "alice" → --name alice).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaCFWMkYQmTcuPsxaaACft
2026-06-27 16:53:13 -07:00

85 lines
3.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 } 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';
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;
}
} catch { /* ignore malformed */ }
});
}
async onScreenFrame(engineerId: string, jpeg: Buffer): Promise<void> {
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);
}
private async handle(collision: Collision): Promise<void> {
const prior = await recallSimilar(collision); // Loop A: 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 ? ` 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 },
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 speak(this.room, message); // gemini-3.1-flash-live voice into the room
}
}