From a70ac873cdfee3607c258f211805e50523e76b2d Mon Sep 17 00:00:00 2001 From: zaridan <1617679+zaridan@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:24:27 -0700 Subject: [PATCH] fix(orchestration): scope Mission Control Shipped to the director's lifetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new Orcastrator's "Shipped" section showed shipped PRs from past sessions. Root cause: `.orcastrate/log.jsonl` is committed to the repo (intentional — it's the skill's cross-session decision log), so every new director worktree branched from `main` inherits the full accumulated log. Mission Control derived Shipped from that whole log with no scoping, so it surfaced every historical shipped outcome as if this director shipped it. Fix: thread an optional `sinceMs` into `parseOrchestrateLogOutcomes` — when set, skip `outcome` records whose `ts` parses before `sinceMs`, and exclude outcomes with a missing/unparseable `ts` (can't attribute them to this director → err toward not showing foreign work). Plan records stay unscoped (they only supply descriptions). `OrchestratorMissionControl` passes the director worktree's `createdAt`. Optional, so legacy callers and worktrees discovered on disk (no `createdAt`) keep prior behavior. Result: a brand-new director → empty Shipped; inherited entries filtered out; a director's own shipped outcomes still appear. Out of scope (unchanged): the orcastrate skill, the log format, the committing behavior, and the lineage-based Spawned-work section. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../OrchestratorMissionControl.tsx | 9 +++- .../lib/orcastrate-log-shipped-work.test.ts | 53 +++++++++++++++++++ .../src/lib/orcastrate-log-shipped-work.ts | 26 ++++++++- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx b/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx index ac99047c6c6..2259f6c9c59 100644 --- a/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx +++ b/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx @@ -131,6 +131,11 @@ export default function OrchestratorMissionControl({ const directorRepo = directorWorktree ? reposById.get(directorWorktree.repoId) : undefined const directorPath = directorWorktree?.path ?? null const directorConnectionId = directorRepo?.connectionId ?? undefined + // Why: the log is committed, so a new director worktree inherits prior + // sessions' outcomes. Scope Shipped to outcomes logged at/after this + // director's creation so foreign work doesn't leak in. Absent for worktrees + // discovered on disk → unscoped (legacy behavior, no createdAt to anchor to). + const directorCreatedAt = directorWorktree?.createdAt const [shippedItems, setShippedItems] = useState([]) useEffect(() => { if (!directorPath) { @@ -143,7 +148,7 @@ export default function OrchestratorMissionControl({ .readFile({ filePath, connectionId: directorConnectionId }) .then((result) => { if (!cancelled) { - setShippedItems(parseOrchestrateLogOutcomes(result.content)) + setShippedItems(parseOrchestrateLogOutcomes(result.content, directorCreatedAt)) } }) .catch(() => { @@ -155,7 +160,7 @@ export default function OrchestratorMissionControl({ return () => { cancelled = true } - }, [directorPath, directorConnectionId, worktreesByRepo]) + }, [directorPath, directorConnectionId, directorCreatedAt, worktreesByRepo]) // Why: resolve the director repo's `owner/repo` so a shipped branch can link to // its merged PR via GitHub head-ref search — reliable even after the branch is diff --git a/src/renderer/src/lib/orcastrate-log-shipped-work.test.ts b/src/renderer/src/lib/orcastrate-log-shipped-work.test.ts index befebcdf213..8a1b2086c86 100644 --- a/src/renderer/src/lib/orcastrate-log-shipped-work.test.ts +++ b/src/renderer/src/lib/orcastrate-log-shipped-work.test.ts @@ -63,6 +63,59 @@ describe('parseOrchestrateLogOutcomes', () => { }) }) +describe('parseOrchestrateLogOutcomes lifetime scoping', () => { + // Why: the log is committed, so a new director inherits prior sessions' + // outcomes. `sinceMs` scopes Shipped to the director's own lifetime. + const SINCE = Date.parse('2026-06-21T00:00:00Z') + const SCOPED_LOG = [ + JSON.stringify({ + type: 'plan', + id: 'p-old', + worktrees: [{ name: 'feat/old-shipped', becomes_pr: 'Prior session work' }] + }), + JSON.stringify({ + type: 'outcome', + plan_id: 'p-old', + ts: '2026-06-20T22:41:09Z', // before SINCE → inherited, excluded + results: [{ name: 'feat/old-shipped', tag: 'shipped' }] + }), + JSON.stringify({ + type: 'outcome', + plan_id: 'p-new', + ts: '2026-06-21T10:15:00Z', // at/after SINCE → this director's own work + results: [{ name: 'feat/new-shipped', tag: 'shipped' }] + }) + ].join('\n') + + it('keeps only outcomes logged at/after sinceMs', () => { + const items = parseOrchestrateLogOutcomes(SCOPED_LOG, SINCE) + expect(items.map((item) => item.name)).toEqual(['feat/new-shipped']) + expect(selectShippedWork(items).map((item) => item.name)).toEqual(['feat/new-shipped']) + }) + + it('returns empty for a brand-new director over an old-only log', () => { + const NOW = Date.parse('2026-06-24T00:00:00Z') + expect(parseOrchestrateLogOutcomes(SCOPED_LOG, NOW)).toEqual([]) + }) + + it('excludes outcomes with a missing or unparseable ts when scoping', () => { + const log = [ + JSON.stringify({ type: 'outcome', results: [{ name: 'no-ts', tag: 'shipped' }] }), + JSON.stringify({ + type: 'outcome', + ts: 'not-a-date', + results: [{ name: 'bad-ts', tag: 'shipped' }] + }) + ].join('\n') + expect(parseOrchestrateLogOutcomes(log, SINCE)).toEqual([]) + }) + + it('is unaffected by ts when sinceMs is omitted (legacy callers)', () => { + const items = parseOrchestrateLogOutcomes(SCOPED_LOG) + expect(items.map((item) => item.name)).toEqual(['feat/old-shipped', 'feat/new-shipped']) + }) +}) + describe('selectShippedWork', () => { it('keeps only shipped outcomes', () => { const shipped = selectShippedWork(parseOrchestrateLogOutcomes(LOG)) diff --git a/src/renderer/src/lib/orcastrate-log-shipped-work.ts b/src/renderer/src/lib/orcastrate-log-shipped-work.ts index 4b122ad7236..4ca2b268f8d 100644 --- a/src/renderer/src/lib/orcastrate-log-shipped-work.ts +++ b/src/renderer/src/lib/orcastrate-log-shipped-work.ts @@ -29,12 +29,33 @@ function asRecord(value: unknown): UnknownRecord | null { return value && typeof value === 'object' ? (value as UnknownRecord) : null } +/** + * True when an outcome's `ts` parses to a time at/after `sinceMs`. A missing or + * unparseable `ts` returns false so the outcome is excluded — it can't be + * attributed to the current director's lifetime. + */ +function isOutcomeWithinLifetime(ts: unknown, sinceMs: number): boolean { + if (typeof ts !== 'string') { + return false + } + const parsed = Date.parse(ts) + return !Number.isNaN(parsed) && parsed >= sinceMs +} + /** * Parse a director's `.orcastrate/log.jsonl` into its per-worktree outcomes, * joining each outcome to its plan description by name. Latest outcome per name * wins; unparseable lines are skipped. Order follows first appearance. + * + * When `sinceMs` is set, only outcome records whose `ts` parses to a time + * at/after `sinceMs` contribute. Why: the log is committed to the repo, so a + * new director worktree (branched from `main`) inherits every prior session's + * outcomes. Scoping to the director's own lifetime keeps foreign work out of + * its Shipped view. An outcome with a missing/unparseable `ts` can't be + * attributed to this director, so it's excluded too (err toward not showing + * foreign work). Plan records are unscoped — they only supply descriptions. */ -export function parseOrchestrateLogOutcomes(logText: string): ShippedWorkItem[] { +export function parseOrchestrateLogOutcomes(logText: string, sinceMs?: number): ShippedWorkItem[] { const descriptionByName = new Map() const tagByName = new Map() const order: string[] = [] @@ -65,6 +86,9 @@ export function parseOrchestrateLogOutcomes(logText: string): ShippedWorkItem[] } } } else if (record.type === 'outcome' && Array.isArray(record.results)) { + if (sinceMs !== undefined && !isOutcomeWithinLifetime(record.ts, sinceMs)) { + continue + } for (const raw of record.results) { const result = asRecord(raw) const name = result?.name