feat(graph): record + surface suppressed-repeat activity at repeat time (Feature A) (#43)
Codex review fix. The earlier approach synthesized a 'suppressed' beat from every dismissed OUTCOME, which is wrong: a dismissal is not a later suppressed repeat, and the old dismissal timestamp sank below the 12-row activity cap (invisible). Now the negative-feedback loop is recorded WHEN IT HAPPENS. When shouldIntervene() returns false specifically because a signature was dismissed before (agent/podman.ts), the agent writes a durable SuppressionDoc to a new `suppressions` collection, timestamped at the repeat. graph/live.ts materializes those into kind:'suppressed' activity (recent -> surfaces at the top). The suppressed collision is never written to `collisions` (agent returns before recordCollision), so this is its own record. Verified end-to-end: a transient demo-pod suppression renders at the TOP of the activity stream; cleaned up after. shared build + backend/frontend typecheck + eslint pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
recordObservation,
|
recordObservation,
|
||||||
recordCollision,
|
recordCollision,
|
||||||
recordIntervention,
|
recordIntervention,
|
||||||
|
recordSuppression,
|
||||||
updateInterventionStatus,
|
updateInterventionStatus,
|
||||||
} from '../memory/store.js';
|
} from '../memory/store.js';
|
||||||
import { getGitStates, type GitState } from '../memory/db.js';
|
import { getGitStates, type GitState } from '../memory/db.js';
|
||||||
@@ -144,7 +145,22 @@ export class PodMan {
|
|||||||
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
|
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
|
||||||
collision.severity = 'critical';
|
collision.severity = 'critical';
|
||||||
}
|
}
|
||||||
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
|
if (!shouldIntervene(collision, prior)) {
|
||||||
|
// Feature A — make the negative-feedback loop VISIBLE. If we stayed quiet
|
||||||
|
// *specifically* because this signature was DISMISSED before, record a
|
||||||
|
// durable suppressed-repeat event (timestamped now, at the repeat) so the
|
||||||
|
// activity stream shows the learning instead of nothing.
|
||||||
|
if (prior?.priorOutcome && !prior.priorOutcome.accepted) {
|
||||||
|
void recordSuppression(
|
||||||
|
collision,
|
||||||
|
prior.priorOutcome.interventionId,
|
||||||
|
prior.priorOutcome.recordedAt,
|
||||||
|
).catch((err) =>
|
||||||
|
console.error(`[memory] suppression record failed: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return; // Loop B: policy gate
|
||||||
|
}
|
||||||
|
|
||||||
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
|
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
|
||||||
await recordCollision(collision);
|
await recordCollision(collision);
|
||||||
|
|||||||
@@ -457,6 +457,31 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Suppressed repeats: PodMan stayed quiet on a recurring collision because
|
||||||
|
// the signature was dismissed before — the negative-feedback loop made visible
|
||||||
|
// (Feature A). Stamped at repeat time, so it sorts as recent activity.
|
||||||
|
const suppressionDocs = await c.suppressions
|
||||||
|
.find({ podId })
|
||||||
|
.sort({ suppressedAt: -1 })
|
||||||
|
.limit(20)
|
||||||
|
.toArray();
|
||||||
|
for (const s of suppressionDocs) {
|
||||||
|
const sFile = normalizeFile(s.file);
|
||||||
|
if (!isFilePath(sFile)) continue;
|
||||||
|
const sEngs = (s.engineers ?? []).join(' + ') || 'teammates';
|
||||||
|
pushActivity(
|
||||||
|
activity,
|
||||||
|
{
|
||||||
|
id: `suppressed:${s.id}`,
|
||||||
|
at: s.suppressedAt,
|
||||||
|
kind: 'suppressed',
|
||||||
|
title: `Suppressed — ${shortLabel(sFile)} repeat silenced`,
|
||||||
|
detail: `${sEngs} on ${sFile} recurred, but it was dismissed before — PodMan stayed quiet.`,
|
||||||
|
},
|
||||||
|
activityIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prune test-artifact engineers, then anything left orphaned by that.
|
// Prune test-artifact engineers, then anything left orphaned by that.
|
||||||
const dropNode = (id: string) => {
|
const dropNode = (id: string) => {
|
||||||
b.nodes.delete(id);
|
b.nodes.delete(id);
|
||||||
|
|||||||
@@ -31,12 +31,29 @@ export async function closeMemory(): Promise<void> {
|
|||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A durable record that PodMan stayed quiet on a recurring collision because
|
||||||
|
* its signature was previously dismissed — the negative-feedback loop made
|
||||||
|
* auditable. Timestamped at the repeat; materialized as `suppressed` activity
|
||||||
|
* in graph/live.ts. The suppressed collision is never written to `collisions`
|
||||||
|
* (the agent returns before recordCollision), so this is its own record. */
|
||||||
|
export interface SuppressionDoc {
|
||||||
|
id: string;
|
||||||
|
podId: string;
|
||||||
|
collisionId: string;
|
||||||
|
file: string;
|
||||||
|
engineers: string[];
|
||||||
|
priorInterventionId?: string;
|
||||||
|
priorDismissedAt?: string;
|
||||||
|
suppressedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PodCollections {
|
export interface PodCollections {
|
||||||
pods: Collection<Pod>;
|
pods: Collection<Pod>;
|
||||||
observations: Collection<EngineerContext>;
|
observations: Collection<EngineerContext>;
|
||||||
collisions: Collection<Collision>;
|
collisions: Collection<Collision>;
|
||||||
interventions: Collection<Intervention>;
|
interventions: Collection<Intervention>;
|
||||||
outcomes: Collection<InterventionOutcome>;
|
outcomes: Collection<InterventionOutcome>;
|
||||||
|
suppressions: Collection<SuppressionDoc>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collections(): Promise<PodCollections> {
|
export async function collections(): Promise<PodCollections> {
|
||||||
@@ -47,6 +64,7 @@ export async function collections(): Promise<PodCollections> {
|
|||||||
collisions: db.collection<Collision>('collisions'),
|
collisions: db.collection<Collision>('collisions'),
|
||||||
interventions: db.collection<Intervention>('interventions'),
|
interventions: db.collection<Intervention>('interventions'),
|
||||||
outcomes: db.collection<InterventionOutcome>('outcomes'),
|
outcomes: db.collection<InterventionOutcome>('outcomes'),
|
||||||
|
suppressions: db.collection<SuppressionDoc>('suppressions'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +132,7 @@ export async function initMemory(): Promise<void> {
|
|||||||
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
||||||
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
||||||
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
||||||
|
['suppressions.podId', () => c.suppressions.createIndex({ podId: 1, suppressedAt: -1 })],
|
||||||
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
||||||
[
|
[
|
||||||
'hermes_jobs.session',
|
'hermes_jobs.session',
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature A — record that PodMan stayed quiet on a recurring collision because
|
||||||
|
* its signature was previously dismissed. Timestamped at the repeat (now), so
|
||||||
|
* the negative-feedback beat surfaces as recent `suppressed` activity rather
|
||||||
|
* than being re-synthesized from the old dismissal row.
|
||||||
|
*/
|
||||||
|
export async function recordSuppression(
|
||||||
|
collision: Collision,
|
||||||
|
priorInterventionId?: string,
|
||||||
|
priorDismissedAt?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await persist('suppression', async () =>
|
||||||
|
(await collections()).suppressions.insertOne({
|
||||||
|
id: `supp_${Date.now()}`,
|
||||||
|
podId: collision.podId,
|
||||||
|
collisionId: collision.id,
|
||||||
|
file: collision.file,
|
||||||
|
engineers: collision.engineers,
|
||||||
|
priorInterventionId,
|
||||||
|
priorDismissedAt,
|
||||||
|
suppressedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function hasRecentInterventionForCollision(
|
export async function hasRecentInterventionForCollision(
|
||||||
collision: Collision,
|
collision: Collision,
|
||||||
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
|
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
|
|||||||
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
||||||
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
||||||
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
||||||
|
| `suppressions` | `suppressed` activity beats — a dismissed signature recurred and PodMan stayed quiet (negative-feedback made visible; written at repeat time by the agent) |
|
||||||
|
|
||||||
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const ACTIVITY_TAG: Record<PodGraphActivityKind, { color: string; label:
|
|||||||
outcome: { color: GREEN, label: 'OUTCOME' },
|
outcome: { color: GREEN, label: 'OUTCOME' },
|
||||||
learned: { color: VIOLET, label: 'LEARNED' },
|
learned: { color: VIOLET, label: 'LEARNED' },
|
||||||
agent: { color: BLUE, label: 'AGENT' },
|
agent: { color: BLUE, label: 'AGENT' },
|
||||||
|
suppressed: { color: VIOLET, label: 'SUPPRESSED' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type PodGraphActivityKind =
|
|||||||
| 'intervention'
|
| 'intervention'
|
||||||
| 'outcome'
|
| 'outcome'
|
||||||
| 'learned'
|
| 'learned'
|
||||||
|
| 'suppressed'
|
||||||
| 'agent';
|
| 'agent';
|
||||||
|
|
||||||
export interface PodGraphActivity {
|
export interface PodGraphActivity {
|
||||||
|
|||||||
Reference in New Issue
Block a user