diff --git a/backend/src/agent/podman.ts b/backend/src/agent/podman.ts index 7bab328..b2def92 100644 --- a/backend/src/agent/podman.ts +++ b/backend/src/agent/podman.ts @@ -7,6 +7,7 @@ import { recordObservation, recordCollision, recordIntervention, + hasRecentInterventionForCollision, updateInterventionStatus, } from '../memory/store.js'; import { getGitStates } from '../memory/db.js'; @@ -66,6 +67,7 @@ export class PodMan { 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 + if (await hasRecentInterventionForCollision(collision)) return; await recordCollision(collision); const action = preferredAction(collision, prior); diff --git a/backend/src/memory/db.ts b/backend/src/memory/db.ts index 5b64c73..2a83cd0 100644 --- a/backend/src/memory/db.ts +++ b/backend/src/memory/db.ts @@ -57,6 +57,8 @@ export interface GitState { gitUpdatedAt: Date | null; } +const GIT_STATE_TTL_MS = Number(process.env.GIT_STATE_TTL_MS ?? '120000'); + /** Fetch latest git state per engineer for a pod from the engineer_states collection. * Returns a map keyed by engineer name (matches --name arg used in podman-agent.mjs). */ export async function getGitStates(podId: string): Promise> { @@ -71,7 +73,17 @@ export async function getGitStates(podId: string): Promise }>('engineer_states'); const docs = await col.find({ podId }).toArray(); const map = new Map(); + const now = Date.now(); for (const doc of docs) { + const updatedAt = doc.gitUpdatedAt ? new Date(doc.gitUpdatedAt) : null; + if ( + updatedAt && + !Number.isNaN(updatedAt.getTime()) && + GIT_STATE_TTL_MS > 0 && + now - updatedAt.getTime() > GIT_STATE_TTL_MS + ) { + continue; + } map.set(doc.name, { changedFiles: doc.changedFiles ?? [], branch: doc.branch ?? null, diff --git a/backend/src/memory/policy.ts b/backend/src/memory/policy.ts index a39046c..b8be7bf 100644 --- a/backend/src/memory/policy.ts +++ b/backend/src/memory/policy.ts @@ -16,7 +16,7 @@ export function shouldIntervene(collision: Collision, prior: RecalledCollision | const cooldown = cooldownMs(); const last = lastNudgeByPod.get(collision.podId) ?? 0; - if (cooldown > 0 && Date.now() - last < cooldown && collision.severity !== 'critical') { + if (cooldown > 0 && Date.now() - last < cooldown) { return false; } diff --git a/backend/src/memory/store.ts b/backend/src/memory/store.ts index a314a17..8125385 100644 --- a/backend/src/memory/store.ts +++ b/backend/src/memory/store.ts @@ -8,6 +8,15 @@ import type { import { collections } from './db.js'; import { enrichCollisionMemory } from './vectors.js'; +function comparableFile(raw?: string): string { + return (raw ?? '') + .trim() + .replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '') + .split(/[\\/]/) + .pop() + ?.toLowerCase() ?? ''; +} + /** * Continual-learning memory: persist observations, collisions, interventions, * and outcomes to MongoDB so later sessions get sharper. MongoDB is mandatory — @@ -41,6 +50,31 @@ export async function recordIntervention(intervention: Intervention): Promise { + if (windowMs <= 0) return false; + const c = await collections(); + const since = new Date(Date.now() - windowMs).toISOString(); + const recent = await c.collisions + .find({ podId: collision.podId, detectedAt: { $gte: since } }) + .sort({ detectedAt: -1 }) + .limit(100) + .toArray(); + + const targetFile = comparableFile(collision.file); + for (const match of recent) { + if (match.id === collision.id || comparableFile(match.file) !== targetFile) continue; + const existing = await c.interventions.findOne({ + collisionId: match.id, + createdAt: { $gte: since }, + }); + if (existing) return true; + } + return false; +} + export async function updateInterventionStatus( interventionId: string, status: InterventionStatus, diff --git a/backend/src/server.ts b/backend/src/server.ts index 6448eb4..2fac766 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,7 +6,13 @@ import { WebSocketServer } from 'ws'; import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk'; import { env } from './env.js'; import { createSyncPr } from './github/client.js'; -import { recordCollision, recordIntervention, recordOutcome, memoryStats } from './memory/store.js'; +import { + recordCollision, + recordIntervention, + recordOutcome, + hasRecentInterventionForCollision, + memoryStats, +} from './memory/store.js'; import { closeMemory, initMemory } from './memory/db.js'; import { listPods, @@ -231,6 +237,9 @@ app.post('/api/pods/:id/hermes/notify', async (req, res) => { : undefined; try { + if (body.force !== true && (await hasRecentInterventionForCollision(collision))) { + return res.status(202).json({ ok: true, collision, intervention, livekit: 'suppressed' }); + } await recordCollision(collision); await recordIntervention(intervention); if (body.dryRun === true) { diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index 73c5efe..ff63fb3 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -544,8 +544,8 @@ export function PodView({