Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# PROGRESS — session-correlation work split (2026-05-04)

**Original target:** AUR-115 — *Cross-agent session correlation (Claude session → Cursor session stitched)*
**Status:** plan superseded — **do not implement AUR-115 directly**.
**This branch:** `misha/aur-276-session-correlation-telemetry` — implements AUR-276 (telemetry half).

The original 4-hour plan (full architecture + schema + correlator + API + React UI) drafted earlier today was paused after a GBrain query surfaced **AUR-183** — the same feature, cancelled 2026-04-15 for the same milestone (M7 v1.0). The cancellation rationale was that without user-labeled confirmation pairs, false-positive rate is ~10–15% and the UI can't be trusted. The new plan's stricter rule (exact path + 30-min window + same git branch + same workspace root) likely lowers FP — but still ships an unverified flagship feature with no measurement.

## New plan — 2-ticket split

| Ticket | Scope | Effort | Gate |
|---|---|---|---|
| **AUR-276** | Session-correlation telemetry. Correlator + V3 schema + `session_link_candidates` table + dev-only TUI badge. **No API field, no React UI.** | ~1–1.5 hr | None — ships into v1.0 |
| **AUR-277** | Stitched-sessions UI. `/api/sessions/:id` link field + React sidebar block. Optional hedge UI + thumbs-up/down feedback if FP is in the 5–15% band. | ~2–3 hr | Blocked: AUR-276 done **+** ≥10 candidate pairs accumulated in self-use **+** Michael manually classifies them **+** measured FP <15% (or redesign) |

## Why split, not just shrink

- The original plan's correlator + schema work is reusable as-is — it produces candidate pairs either way.
- The cancelled AUR-183 explicitly said "revisit in v1.1 once v1.0 is out." Shipping unobserved UI in v1.0 ignores that call. Shipping silent telemetry in v1.0 *enables* it.
- 1 hr of telemetry now buys honest measurement before any UI commitment. Asymmetric: small cost, big optionality.

## What carries forward from the old PROGRESS.md plan

The architecture sections of the original plan are still correct and reusable inside AUR-276:

- §3.1 Data flow (sink wrapper composition).
- §3.2 Key types (`RecentWriteEntry`, but rename `SessionLink` → `SessionLinkCandidate`).
- §3.3 Schema migration `applyV3` — but rename table `session_links` → `session_link_candidates`.
- §3.4 Workspace + branch resolution (lazy 60-s branch cache; null-gate semantics).
- §3.5 Failure scenarios.
- §4 Code-quality decisions (especially: keep `recent-writes.ts` as-is, new `src/correlate/` dir, swallow-error parity with `wrapSinkWithStore`).
- §5 Test coverage diagram (drop the React + API rows; everything else applies).
- §10 Worktree parallelization (Lane A store + Lane B correlator unchanged).

## What changes from the old plan

- Drop API-route work (was step 4) → moves to AUR-277.
- Drop React Session-view sidebar (was step 5) → moves to AUR-277.
- Drop integration test that asserts the API returns links → AUR-277.
- Add: dev-only TUI candidate-count badge (env-var gated, e.g. `AGENTWATCH_DEBUG_LINKS=1`).
- Add: a one-shot CLI like `agentwatch link-candidates --session <id>` (or just `--all`) so Michael can dump candidates to manually classify them. JSON output, no formatting.

## Pre-coding assumption to verify (carried forward)

Spot-check from earlier today: top-level lines of recent Claude JSONL in this very repo's project dir reported `(no cwd)`. The plan assumed `obj.cwd` is reliably present. **Before coding AUR-276,** sample 5–10 recent JSONL files and confirm which line shapes carry `cwd`. If only `session_start`-shaped lines do, the correlator must capture cwd at that line and reuse it for downstream `file_write` events on that session — which is fine, but worth verifying first.

## Linear cross-links

- **AUR-115** — moved to Backlog, labels `ai-refinement, blocked`, related to AUR-183/276/277. Description rewritten to point here.
- **AUR-183** — cancelled 2026-04-15. The reason this split exists.
- **AUR-276** — telemetry, v1.0, ~1–1.5 hr.
- **AUR-277** — UI, v1.1, blocked on AUR-276 + validation gate.

## Next step

Nothing to code yet. When Michael decides to start AUR-276:

1. `git checkout main && git pull`
2. `git checkout -b misha/aur-276-session-correlation-telemetry`
3. Update Linear AUR-276 → In Progress, kickoff comment linking this PROGRESS.md.
4. Execute Lane A (store + V3 migration) and Lane B (correlator + branch cache) in parallel worktrees per the original plan §10.
5. Wire up Step 3 (sink wrapper + `details.cwd` in adapters).
6. Skip the old Step 4 + Step 5 — those are AUR-277.
7. Add: dev-only TUI badge + `agentwatch link-candidates` CLI.
8. PR per repo convention (no Claude footer).
9. After ship: this PROGRESS.md is rotated to `~/IdeaProjects/knowledge-base/decisions/2026-05-04-agentwatch-session-correlation-split.md` (short ADR).

The current branch `agent/aur-218-sandbox-docker` is unrelated to this work — it carries the AUR-218 commit (`c2ff389`). PROGRESS.md sits on this branch only because it's where today's planning happened; it should follow into the next branch via `git checkout -b ... && git add PROGRESS.md` or be committed here first depending on Michael's preference.
5 changes: 5 additions & 0 deletions src/adapters/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,10 @@ export function translateClaudeLine(
evType === "shell_exec" && toolUse.cmd
? detectAgentCall(toolUse.cmd)
: null;
// AUR-276: file_write events carry cwd so the session-correlation
// linker can resolve the workspace root + branch without a
// round-trip back to the adapter.
const cwd = typeof o.cwd === "string" ? o.cwd : undefined;
return {
id: nextId(),
ts,
Expand All @@ -357,6 +361,7 @@ export function translateClaudeLine(
cost,
model,
...(agentCall ? { agentCall } : {}),
...(evType === "file_write" && cwd ? { cwd } : {}),
},
};
}
Expand Down
6 changes: 6 additions & 0 deletions src/adapters/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,11 @@ export function translateSession(
const toolUse = extractToolUse(content);
if (toolUse) {
const type = inferToolType(toolUse.name);
// AUR-276: file_write events carry cwd so the session-correlation
// linker can resolve the workspace root + branch. cwd was captured
// on the session_start line and is held in the sessionCwd map for
// every event in this session.
const cwd = type === "file_write" ? sessionCwd.get(sessionId) : undefined;
return base(type, {
tool: `openclaw:${subAgent}:${toolUse.name}`,
path: toolUse.path,
Expand All @@ -485,6 +490,7 @@ export function translateSession(
...(usage ? { usage } : {}),
...(precomputedCost != null ? { cost: precomputedCost } : {}),
...(model ? { model } : {}),
...(cwd ? { cwd } : {}),
},
});
}
Expand Down
132 changes: 132 additions & 0 deletions src/correlate/branch-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it } from "vitest";
import { _resetBranchCache, resolveWorkspace } from "./branch-cache.js";

beforeEach(() => {
_resetBranchCache();
});

describe("resolveWorkspace — null cwd inputs", () => {
it("returns null/null for an undefined cwd without shelling out", () => {
let branchCalls = 0;
let commonDirCalls = 0;
const r = resolveWorkspace(undefined, {
branchOf: () => {
branchCalls++;
return "main";
},
commonDirOf: () => {
commonDirCalls++;
return null;
},
});
expect(r).toEqual({ workspaceRoot: null, gitBranch: null });
expect(branchCalls).toBe(0);
expect(commonDirCalls).toBe(0);
});

it("returns null/null for an empty-string cwd without shelling out", () => {
let branchCalls = 0;
let commonDirCalls = 0;
const r = resolveWorkspace("", {
branchOf: () => {
branchCalls++;
return "main";
},
commonDirOf: () => {
commonDirCalls++;
return null;
},
});
expect(r).toEqual({ workspaceRoot: null, gitBranch: null });
expect(branchCalls).toBe(0);
expect(commonDirCalls).toBe(0);
});
});

describe("resolveWorkspace — caching behaviour", () => {
it("re-uses both cached values within the TTL — no shell-outs on hit", () => {
let branchCalls = 0;
let commonDirCalls = 0;
const branchOf = (): string | null => {
branchCalls++;
return "main";
};
const commonDirOf = (): string | null => {
commonDirCalls++;
return "/repo/.git";
};
const now = (): number => 1_000_000;
const a = resolveWorkspace("/repo/a", { branchOf, commonDirOf, now });
const b = resolveWorkspace("/repo/a", { branchOf, commonDirOf, now });
expect(branchCalls).toBe(1);
expect(commonDirCalls).toBe(1); // regression guard: no shell-out on hit
expect(a).toEqual(b);
expect(a.gitBranch).toBe("main");
expect(a.workspaceRoot).toBe("/repo/.git");
});

it("re-shells when the cache entry is older than the TTL", () => {
let branchCalls = 0;
const branchOf = (): string | null => {
branchCalls++;
return branchCalls === 1 ? "main" : "feature";
};
const commonDirOf = (): string | null => "/repo/.git";
let t = 1_000_000;
const now = (): number => t;
const first = resolveWorkspace("/repo/b", { branchOf, commonDirOf, now });
expect(first.gitBranch).toBe("main");
t += 60_001; // just past the 60 s TTL
const second = resolveWorkspace("/repo/b", { branchOf, commonDirOf, now });
expect(second.gitBranch).toBe("feature");
expect(branchCalls).toBe(2);
});

it("caches a null branch the same way as a real branch", () => {
let branchCalls = 0;
const branchOf = (): string | null => {
branchCalls++;
return null;
};
const now = (): number => 2_000_000;
const a = resolveWorkspace("/repo/c", {
branchOf,
commonDirOf: () => null,
now,
});
const b = resolveWorkspace("/repo/c", {
branchOf,
commonDirOf: () => null,
now,
});
expect(a.gitBranch).toBeNull();
expect(b.gitBranch).toBeNull();
expect(branchCalls).toBe(1);
});

it("does NOT collapse linked worktrees that share a common-dir but differ on branch", () => {
// Two worktrees of the same repo, different branches. The previous
// (codex-flagged) keying-by-common-dir would cross-poison them; this
// version keys by cwd so each worktree has its own cache entry.
const commonDirOf = (): string | null => "/repo/.git"; // shared
const branchOf = (cwd: string): string | null =>
cwd === "/repo/main-worktree" ? "main" : "feature";
const now = (): number => 5_000_000;
const a = resolveWorkspace("/repo/main-worktree", {
branchOf,
commonDirOf,
now,
});
const b = resolveWorkspace("/repo/feature-worktree", {
branchOf,
commonDirOf,
now,
});
expect(a.gitBranch).toBe("main");
expect(b.gitBranch).toBe("feature");
// Both still resolve to the SAME workspaceRoot — the matcher gates
// on (workspaceRoot, branch) together, and on-same-branch worktrees
// SHOULD collapse, while different-branch ones diverge on branch.
expect(a.workspaceRoot).toBe(b.workspaceRoot);
});
});
94 changes: 94 additions & 0 deletions src/correlate/branch-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { getCurrentBranch, gitCommonDir } from "../git/correlate.js";

/** AUR-276: cache git-branch + git-common-dir lookups per *worktree*
* (cwd) for `TTL_MS`. Both lookups shell out to `git`, which is cheap
* individually (~3–10 ms each) but hot-path file_write events fire
* often enough that a tight cache pays — the previous version called
* `gitCommonDir` *before* the cache check, which defeated the cache
* for the common-dir lookup on every event.
*
* Why the cache key is `cwd` (not `gitCommonDir(cwd)`):
* Linked worktrees of the same repo share a `.git` common-dir but
* point at different branches. Keying by common-dir would collapse
* them, so a write from worktree-A on `main` would poison the cache
* for a write from worktree-B on `feature` for the next 60 seconds —
* injecting wrong-branch attribution into exactly the telemetry data
* AUR-276 exists to measure. Branch is per-worktree; the cache must
* be per-worktree too.
*
* The returned `workspaceRoot` is still `gitCommonDir(cwd) ?? cwd`,
* so two worktrees of the same repo on the same branch DO collapse
* into one workspace from the matcher's point of view (which is what
* we want: same repo + same branch = same task).
*
* TTL is intentionally short (60 s): humans switch branches and then
* immediately run an agent on the new branch — we want stale entries
* to expire fast enough that the next file_write picks up the switch,
* but not so fast that we re-spawn git for every burst of writes.
*
* Cache misses on a non-git dir, missing-git-on-PATH, or detached HEAD
* all return `null` and cache that null result for the same TTL — no
* point re-shelling-out to fail again 5 ms later.
*/

const TTL_MS = 60_000;

interface CacheEntry {
workspaceRoot: string | null;
branch: string | null;
refreshedMs: number;
}

const cache = new Map<string, CacheEntry>();

interface BranchCacheDeps {
/** Override for tests. Defaults to `getCurrentBranch` (shells out to git). */
branchOf?: (cwd: string) => string | null;
/** Override for tests. Defaults to `gitCommonDir` (shells out to git). */
commonDirOf?: (cwd: string) => string | null;
/** Override for tests. Defaults to `Date.now`. */
now?: () => number;
}

export interface ResolvedWorkspace {
/** Canonicalized workspace root (gitCommonDir-resolved if possible).
* Two worktrees of the same repo collapse to the same value here —
* the matcher then gates on this + the per-worktree branch. `null`
* when the input cwd was null/empty or git couldn't resolve it. */
workspaceRoot: string | null;
/** Current branch at the *worktree* (cwd), not the common-dir, so
* sibling worktrees on different branches don't share a value.
* `null` for non-git, detached HEAD, or any git failure. */
gitBranch: string | null;
}

/** Resolve `(workspaceRoot, gitBranch)` for the given cwd, with a
* 60-second cache around the git invocations. Pure-data return; the
* caller decides what to do with nulls (the AUR-276 linker uses null
* as a "do not match" gate). */
export function resolveWorkspace(
cwd: string | null | undefined,
deps: BranchCacheDeps = {},
): ResolvedWorkspace {
if (!cwd) return { workspaceRoot: null, gitBranch: null };
const branchOf = deps.branchOf ?? getCurrentBranch;
const commonDirOf = deps.commonDirOf ?? gitCommonDir;
const now = deps.now ?? Date.now;
const t = now();
// Cache lookup BEFORE any subprocess. The previous version paid for
// gitCommonDir on every call — this version pays for nothing on hits.
const cached = cache.get(cwd);
if (cached && t - cached.refreshedMs < TTL_MS) {
return { workspaceRoot: cached.workspaceRoot, gitBranch: cached.branch };
}
// Miss: resolve both, cache once.
const workspaceRoot = commonDirOf(cwd) ?? cwd;
const branch = branchOf(cwd);
cache.set(cwd, { workspaceRoot, branch, refreshedMs: t });
return { workspaceRoot, gitBranch: branch };
}

/** Test-only: drop every cached entry. */
export function _resetBranchCache(): void {
cache.clear();
}
Loading
Loading