fix(agent): re-alert persistent + new-pair conflicts (README always surfaces)
The edge-trigger single-shot keyed conflicts by file basename only and re-armed solely when the conflict left a detection cycle. README is dirty for someone in the pod the whole session, so its key never re-armed: it alerted once (ram+karti at restart) and stayed muted even as the colliding pair changed to karti+Ramis Hasanli. Transient files (research, new files) churned out and back, so only those kept firing. - conflictKey now includes the sorted engineer pair, so a different pair on the same file re-alerts immediately. - activeConflicts is now Map<key, lastAlertedAt>; a persistent conflict re-alerts every CONFLICT_REALERT_MS (default 20s) instead of going silent forever, without spamming once-per-frame. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuV8W8oNYRsDWKoqK8Mkqc
This commit is contained in:
+23
-11
@@ -35,6 +35,12 @@ function canonicalName(raw?: string): string {
|
|||||||
return (raw ?? '').trim().toLowerCase();
|
return (raw ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long a still-present conflict stays muted after we voice it, before it
|
||||||
|
* re-alerts. Edge-trigger alone permanently muted files that stay dirty for
|
||||||
|
* the whole session (e.g. README everyone tests on). This bounds that: voice
|
||||||
|
* once, then re-alert at most every interval while it persists. */
|
||||||
|
const CONFLICT_REALERT_MS = Number(process.env.CONFLICT_REALERT_MS ?? '20000');
|
||||||
|
|
||||||
/** Git ground truth: do ALL involved engineers currently have the collided file
|
/** Git ground truth: do ALL involved engineers currently have the collided file
|
||||||
* in their changedFiles? Computed at detection time while git state is fresh. */
|
* in their changedFiles? Computed at detection time while git state is fresh. */
|
||||||
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
|
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
|
||||||
@@ -50,13 +56,15 @@ function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, Git
|
|||||||
export class PodMan {
|
export class PodMan {
|
||||||
private contexts = new Map<string, EngineerContext>();
|
private contexts = new Map<string, EngineerContext>();
|
||||||
/**
|
/**
|
||||||
* Conflicts we have already voiced and that are still unresolved, keyed by
|
* Conflicts we have already voiced, keyed by file + engineer pair (see
|
||||||
* file (see conflictKey). Edge-triggered alerting: speak once when a conflict
|
* conflictKey), mapped to the time we last voiced them. Edge-triggered:
|
||||||
* appears, stay quiet while it persists. A conflict is re-armed (deleted
|
* speak once when a conflict appears. Re-armed (deleted here) by
|
||||||
* here) by onScreenFrame as soon as a detection cycle no longer sees it, so a
|
* onScreenFrame as soon as a detection cycle no longer sees it, so a
|
||||||
* resolved-then-recurring conflict alerts again.
|
* resolved-then-recurring conflict alerts again. Additionally, a conflict
|
||||||
|
* that *persists* re-alerts every CONFLICT_REALERT_MS so a perpetually-dirty
|
||||||
|
* file (README) doesn't go silent forever after the first alert.
|
||||||
*/
|
*/
|
||||||
private activeConflicts = new Set<string>();
|
private activeConflicts = new Map<string, number>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private room: Room,
|
private room: Room,
|
||||||
@@ -107,7 +115,7 @@ export class PodMan {
|
|||||||
// Re-arm: any conflict we previously voiced that is no longer present has
|
// Re-arm: any conflict we previously voiced that is no longer present has
|
||||||
// resolved, so allow it to alert again if it recurs.
|
// resolved, so allow it to alert again if it recurs.
|
||||||
const current = new Set(collisions.map((c) => this.conflictKey(c)));
|
const current = new Set(collisions.map((c) => this.conflictKey(c)));
|
||||||
for (const key of this.activeConflicts) {
|
for (const key of this.activeConflicts.keys()) {
|
||||||
if (!current.has(key)) this.activeConflicts.delete(key);
|
if (!current.has(key)) this.activeConflicts.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,12 +137,16 @@ export class PodMan {
|
|||||||
* basename.
|
* basename.
|
||||||
*/
|
*/
|
||||||
private conflictKey(collision: Collision): string {
|
private conflictKey(collision: Collision): string {
|
||||||
return `${collision.overlapKind ?? 'file'}:${comparableBasename(collision.file)}`;
|
const who = [...collision.engineers].map(canonicalName).sort().join('+');
|
||||||
|
return `${collision.overlapKind ?? 'file'}:${comparableBasename(collision.file)}:${who}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handle(collision: Collision): Promise<void> {
|
private async handle(collision: Collision): Promise<void> {
|
||||||
const key = this.conflictKey(collision);
|
const key = this.conflictKey(collision);
|
||||||
if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved
|
const lastAlerted = this.activeConflicts.get(key);
|
||||||
|
// Edge-trigger + time-based re-arm: stay quiet right after voicing, but
|
||||||
|
// re-alert a persistent conflict once CONFLICT_REALERT_MS has elapsed.
|
||||||
|
if (lastAlerted !== undefined && Date.now() - lastAlerted < CONFLICT_REALERT_MS) return;
|
||||||
|
|
||||||
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
||||||
// Only escalate to critical (which triggers the spoken alert) when the
|
// Only escalate to critical (which triggers the spoken alert) when the
|
||||||
@@ -155,7 +167,7 @@ export class PodMan {
|
|||||||
// suppressed-repeat per recurrence, not once per frame; it re-arms via
|
// suppressed-repeat per recurrence, not once per frame; it re-arms via
|
||||||
// the resolution sweep in onScreenFrame. Awaited like recordCollision so
|
// the resolution sweep in onScreenFrame. Awaited like recordCollision so
|
||||||
// the durable learning proof is reliably written.
|
// the durable learning proof is reliably written.
|
||||||
this.activeConflicts.add(key);
|
this.activeConflicts.set(key, Date.now());
|
||||||
await recordSuppression(
|
await recordSuppression(
|
||||||
collision,
|
collision,
|
||||||
prior.priorOutcome.interventionId,
|
prior.priorOutcome.interventionId,
|
||||||
@@ -165,7 +177,7 @@ export class PodMan {
|
|||||||
return; // Loop B: policy gate
|
return; // Loop B: policy gate
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
|
this.activeConflicts.set(key, Date.now()); // claim + timestamp; re-armed on resolution or after CONFLICT_REALERT_MS
|
||||||
await recordCollision(collision);
|
await recordCollision(collision);
|
||||||
const action = preferredAction(collision, prior);
|
const action = preferredAction(collision, prior);
|
||||||
const shortFile = collision.file.split('/').pop() ?? collision.file;
|
const shortFile = collision.file.split('/').pop() ?? collision.file;
|
||||||
|
|||||||
Reference in New Issue
Block a user