From 8af2f7bba59cf82e9178768f943208bc25d2696a Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 18 Aug 2026 22:27:07 +0200 Subject: [PATCH 1/3] feat(factory): emit completed-session replay pointers --- .agentworkforce/features/manifest.yaml | 13 +++- docs/pr-session-replay.md | 31 +++++++++ src/index.ts | 12 ++++ src/orchestrator/factory.test.ts | 16 +++++ src/orchestrator/factory.ts | 41 ++++++++++-- src/trajectory.test.ts | 87 ++++++++++++++++++++++++++ src/trajectory.ts | 62 ++++++++++++++++++ 7 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 docs/pr-session-replay.md create mode 100644 src/trajectory.test.ts create mode 100644 src/trajectory.ts diff --git a/.agentworkforce/features/manifest.yaml b/.agentworkforce/features/manifest.yaml index 1e8ee08..53f4b2d 100644 --- a/.agentworkforce/features/manifest.yaml +++ b/.agentworkforce/features/manifest.yaml @@ -1,15 +1,15 @@ version: '1.1' -updated: '2026-08-17' +updated: '2026-08-18' catalog: category_count: 25 - feature_count: 320 + feature_count: 321 tier_counts: 1: 49 2: 127 3: 12 4: 52 5: 65 - 6: 15 + 6: 16 # Every user-facing feature in @agent-relay/factory, categorized and scored. # @@ -864,6 +864,13 @@ categories: location: src/orchestrator/factory.ts verify_tier: 5 + - id: pr-session-replay-pointer + name: Completed-Session Replay Pointer + api: trajectoryPointerFromBody() + description: Stamp Factory-opened PRs with the ruled three-key trajectory marker from Relay's existing opaque session_ref while leaving replay availability and the workspace retention boundary to the authenticated live resolver + location: src/trajectory.ts, src/orchestrator/factory.ts + verify_tier: 6 + - id: pr-babysitter-opt-in name: Event-Driven PR Babysitter api: babysitter.enabled diff --git a/docs/pr-session-replay.md b/docs/pr-session-replay.md new file mode 100644 index 0000000..d84c510 --- /dev/null +++ b/docs/pr-session-replay.md @@ -0,0 +1,31 @@ +# Completed-session replay pointers on Factory PRs + +Every pull request Factory opens ends with the ruled, reference-only trajectory +marker: + +```html + +``` + +The three keys are the contract. `session_ref` is the existing opaque UUID +emitted by Relay; Factory does not mint a replay id or add a fourth key. Linear +work uses its issue key, GitHub work uses `owner/repo#number`, and work without a +provider ticket uses a Factory-synthesized work-unit id. + +Factory deliberately does not write `relay session replay ` or a +retention claim into the PR body. Replay availability changes after publication +as the workspace's pricing-tier retention window advances. An authenticated +resolver reads this marker, obtains the workspace's live `retained-since` or +never-prune boundary, and only then renders the copyable replay command. If the +conversation has aged out, the resolver must show incomplete coverage and must +not render it as replayable. + +When Factory has no canonical non-nil session UUID, the marker says +`session_ref=missing`. The SDK parser does not return that marker as resolver +input. Parsing a UUID does not itself establish replay availability. Factory +also strips inherited trajectory markers from issue text before +appending its single canonical marker. + +The PR carries only a reference. Conversation payload and access control remain +at resolution time, and replay of completed work stays distinct from attaching +to or relocating a running session. diff --git a/src/index.ts b/src/index.ts index 466c004..69b3f06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,18 @@ export type { TemplateIssue, TemplateRoute, } from './dispatch/templates' +export { + canonicalTrajectorySessionRef, + MISSING_TRAJECTORY_SESSION_REF, + renderTrajectoryPointer, + stripTrajectoryPointers, + trajectoryPointerFromBody, + trajectorySessionRefFromBody, +} from './trajectory' +export type { + TrajectoryPointer, + TrajectoryWorkUnitSurface, +} from './trajectory' export * from './featuremap/index' export * from './feature-guardian/index.js' export { diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 7a34045..a0a6436 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -12377,6 +12377,8 @@ describe('FactoryLoop', () => { '/github/repos/AgentWorkforce/pear/meta.json': { default_branch: 'main' }, }, githubWrite) const fleet = new OrderedPreviewFleetClient() + const sessionRef = '0198b179-c6c2-7e63-9177-4ef52f56c192' + fleet.setSessionRef('ar-52-impl-pear', sessionRef) const factory = createFactory(config({ preview: { provider: 'tailscale-serve', @@ -12432,7 +12434,13 @@ describe('FactoryLoop', () => { baseRef: 'main', title: 'AR-52: [factory-e2e] Fix factory issue 52', body: expect.stringContaining('Live preview: https://factory-node.tailnet.ts.net:10052/'), + sessionRef, }]) + expect(publishInputs[0]?.body).toContain( + ``, + ) + expect(publishInputs[0]?.body).not.toContain('relay session replay') + expect(publishInputs[0]?.body).not.toMatch(/retained-since|never-prune|replay available/iu) await vi.waitFor(() => expect(fleet.previewRemovals).toHaveLength(1)) await vi.waitFor(() => expect(fleet.releases).toHaveLength(2)) expect(fleet.terminalEvents.indexOf(`state:${done}`)).toBeLessThan( @@ -13217,6 +13225,11 @@ describe('FactoryLoop', () => { fleet.emitAgentExit('ar-92-impl-pear', 'crash') await vi.waitFor(() => expect(publishInputs).toHaveLength(1)) + expect(publishInputs[0]).not.toHaveProperty('sessionRef') + expect(publishInputs[0]?.body).toContain( + '', + ) + expect(publishInputs[0]?.body).not.toContain('relay session replay') expect(factory.status().counters.implementerPrsPublishedOnExit).toBe(1) expect(fleet.resumes).toEqual([]) // published instead of respawning // Published PR -> completed -> both agents released, issue no longer in flight. @@ -15937,6 +15950,9 @@ describe('FactoryLoop', () => { baseRef: 'main', title: '58: GitHub factory issue 58', }) + expect(publishInputs[0]?.body).toContain( + '', + ) expect(factory.status().counters.githubPullRequestsPublished).toBe(1) }) diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 4d1d36c..7932cc6 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -128,6 +128,12 @@ import { } from '../observability/events' import { boundedRunCostTotal, CostLedger, type RunCostTotal, type UnpricedModelCostRecord } from '../cost/ledger' import { createTicketDispatchDelivery, type TicketDispatchDelivery } from '../delivery/ticket-dispatch' +import { + canonicalTrajectorySessionRef, + renderTrajectoryPointer, + stripTrajectoryPointers, + type TrajectoryWorkUnitSurface, +} from '../trajectory' import { FleetControlPlaneCircuit, FleetControlPlaneCircuitOpenError, @@ -7844,6 +7850,8 @@ export class FactoryLoop implements Factory { opts: { reconcileExisting?: boolean } = {}, ): Promise { const key = `${issueKey(record.issue)}:${implementer.spec.repo}` + const trajectorySessionRef = canonicalTrajectorySessionRef(implementer.sessionRef) + ?? canonicalTrajectorySessionRef(process.env.RELAY_ATTEST_SESSION_ID) const expectedHeadRef = implementer.spec.branch if (!expectedHeadRef) { throw new Error(`Refusing to publish ${record.issue.key}: implementer has no Factory-derived branch`) @@ -7919,8 +7927,8 @@ export class FactoryLoop implements Factory { expectedHeadRef, baseRef, title: `${issue.key}: ${issue.title}`, - body: githubPullRequestBody(issue, implementer.spec.preview), - ...(implementer.sessionRef ? { sessionRef: implementer.sessionRef } : {}), + body: githubPullRequestBody(issue, implementer.spec.preview, trajectorySessionRef), + ...(trajectorySessionRef ? { sessionRef: trajectorySessionRef } : {}), }) const published = result.author ? result @@ -17924,8 +17932,12 @@ const normalizeGithubRepo = (repo: string, defaultOwner?: string): string => { return `${owner}/${repo}` } -const githubPullRequestBody = (issue: LinearIssue, preview?: PreviewReference): string => [ - issue.description, +const githubPullRequestBody = ( + issue: LinearIssue, + preview: PreviewReference | undefined, + sessionRef: string | undefined, +): string => [ + stripTrajectoryPointers(issue.description), '', isGithubIssue(issue) && /^\d+$/u.test(issue.key) ? `Fixes #${issue.key}` @@ -17935,8 +17947,29 @@ const githubPullRequestBody = (issue: LinearIssue, preview?: PreviewReference): `Live preview: ${preview.url}`, 'Access: Tailscale tailnet membership and the tailnet grants/ACLs are required; this URL is not public.', ] : []), + '', + renderTrajectoryPointer({ + ...trajectoryWorkUnitForIssue(issue), + sessionRef, + }), ].join('\n').trim() +const trajectoryWorkUnitForIssue = ( + issue: LinearIssue, +): { workUnitId: string; workUnitSurface: TrajectoryWorkUnitSurface } => { + const github = githubIssueSourceRef(issue) + if (github) { + return { + workUnitId: `${github.owner}/${github.repo}#${github.number}`, + workUnitSurface: 'github', + } + } + if (isRealLinearIssue(issue)) { + return { workUnitId: issue.key, workUnitSurface: 'linear' } + } + return { workUnitId: `factory:${issue.uuid}`, workUnitSurface: 'factory' } +} + // The broker rejects re-registering a name it never released on exit // (relay#1116-family) with a 500 "agent '' already exists". Detect it from // the structured payload or the message so resume can treat it as terminal diff --git a/src/trajectory.test.ts b/src/trajectory.test.ts new file mode 100644 index 0000000..038faca --- /dev/null +++ b/src/trajectory.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { + canonicalTrajectorySessionRef, + renderTrajectoryPointer, + stripTrajectoryPointers, + trajectoryPointerFromBody, + trajectorySessionRefFromBody, +} from './trajectory' + +describe('trajectory replay pointer', () => { + const sessionRef = '0198b179-c6c2-7e63-9177-4ef52f56c192' + + it('renders and parses the ruled three-key pointer for a canonical resolver input', () => { + const rendered = renderTrajectoryPointer({ + workUnitId: 'AgentWorkforce/factory#260', + workUnitSurface: 'github', + sessionRef, + }) + + expect(rendered).toBe( + ``, + ) + expect(trajectoryPointerFromBody(rendered)).toEqual({ + workUnitId: 'AgentWorkforce/factory#260', + workUnitSurface: 'github', + sessionRef, + }) + expect(trajectorySessionRefFromBody(rendered)).toBe(sessionRef) + }) + + it.each([ + undefined, + '', + 'unknown-session-v3b', + 'missing', + 'ar-260-impl-factory', + '00000000-0000-0000-0000-000000000000', + 'unsafe --> comment', + ])('does not render unavailable input %j as replayable', (unavailableRef) => { + const rendered = renderTrajectoryPointer({ + workUnitId: 'AR-260', + workUnitSurface: 'linear', + sessionRef: unavailableRef, + }) + + expect(canonicalTrajectorySessionRef(unavailableRef)).toBeUndefined() + expect(rendered).toContain('session_ref=missing -->') + expect(trajectoryPointerFromBody(rendered)).toBeUndefined() + expect(rendered).not.toContain('relay session replay') + }) + + it('never bakes a replay availability or retention claim into the PR body', () => { + const rendered = renderTrajectoryPointer({ + workUnitId: 'AR-260', + workUnitSurface: 'linear', + sessionRef, + }) + + expect(rendered).not.toContain('relay session replay') + expect(rendered).not.toMatch(/retained|retention|expires|available/iu) + }) + + it('refuses conflicting pointers and strips inherited markers', () => { + const first = renderTrajectoryPointer({ + workUnitId: 'AR-1', + workUnitSurface: 'linear', + sessionRef, + }) + const second = renderTrajectoryPointer({ + workUnitId: 'AR-1', + workUnitSurface: 'linear', + sessionRef: '0198b179-c6c2-7e63-9177-4ef52f56c197', + }) + + expect(trajectoryPointerFromBody(`${first}\n${second}`)).toBeUndefined() + expect(stripTrajectoryPointers(`body\n\n${first}\n${second}`)).toBe('body') + }) + + it('rejects an unsafe work-unit token before emitting an HTML comment', () => { + expect(() => renderTrajectoryPointer({ + workUnitId: 'AR-1 --> leaked', + workUnitSurface: 'linear', + sessionRef, + })).toThrow(/comment-safe token/u) + }) +}) diff --git a/src/trajectory.ts b/src/trajectory.ts new file mode 100644 index 0000000..eee3452 --- /dev/null +++ b/src/trajectory.ts @@ -0,0 +1,62 @@ +export type TrajectoryWorkUnitSurface = 'linear' | 'github' | 'factory' + +export interface TrajectoryPointer { + workUnitId: string + workUnitSurface: TrajectoryWorkUnitSurface + sessionRef?: string +} + +export const MISSING_TRAJECTORY_SESSION_REF = 'missing' + +const TRAJECTORY_POINTER_PATTERN = + //gu +const AI_HIST_SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu +const NIL_SESSION_UUID = '00000000-0000-0000-0000-000000000000' +const POINTER_TOKEN = /^[^\s>]+$/u + +/** + * Accept only the opaque UUID emitted by Relay for an ai-hist session. Factory + * does not resolve it or infer current replay availability; the authenticated + * replay resolver owns that live, retention-aware decision. + */ +export function canonicalTrajectorySessionRef(value: string | undefined): string | undefined { + const normalized = value?.trim() + if (!normalized || !AI_HIST_SESSION_UUID.test(normalized) || normalized.toLowerCase() === NIL_SESSION_UUID) { + return undefined + } + return normalized +} + +export function renderTrajectoryPointer(pointer: TrajectoryPointer): string { + if (!POINTER_TOKEN.test(pointer.workUnitId)) { + throw new Error(`Trajectory work unit id must be a comment-safe token: ${pointer.workUnitId}`) + } + const sessionRef = canonicalTrajectorySessionRef(pointer.sessionRef) ?? MISSING_TRAJECTORY_SESSION_REF + return `` +} + +/** + * Returns one unambiguous resolver input pointer. Parsing proves identity + * shape, not live replay availability; clients must resolve workspace retention. + */ +export function trajectoryPointerFromBody(body: string): Required | undefined { + const pointers = new Map>() + for (const match of body.matchAll(TRAJECTORY_POINTER_PATTERN)) { + const sessionRef = canonicalTrajectorySessionRef(match[3]) + const workUnitId = match[1] + const workUnitSurface = match[2] as TrajectoryWorkUnitSurface | undefined + if (!sessionRef || !workUnitId || !workUnitSurface) continue + const pointer = { workUnitId, workUnitSurface, sessionRef } + pointers.set(`${workUnitId}:${workUnitSurface}:${sessionRef}`, pointer) + } + return pointers.size === 1 ? [...pointers.values()][0] : undefined +} + +export function trajectorySessionRefFromBody(body: string): string | undefined { + return trajectoryPointerFromBody(body)?.sessionRef +} + +/** Remove inherited pointers before Factory appends its single canonical one. */ +export function stripTrajectoryPointers(body: string): string { + return body.replace(TRAJECTORY_POINTER_PATTERN, '').replace(/\n{3,}/gu, '\n\n').trim() +} From 629904a30aa561df020224ae0e3051ec08947f8d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 18 Aug 2026 22:32:10 +0200 Subject: [PATCH 2/3] fix(factory): keep trajectory refs agent-scoped --- src/orchestrator/factory.test.ts | 4 +++- src/orchestrator/factory.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index a0a6436..5071961 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -13213,6 +13213,8 @@ describe('FactoryLoop', () => { '/github/repos/AgentWorkforce/pear/meta.json': { default_branch: 'main' }, }, githubWrite) const fleet = new FakeFleetClient() + const attestationSessionRef = 'session-impl-92' + fleet.setSessionRef('ar-92-impl-pear', attestationSessionRef) const factory = createFactory(config(), { mount, fleet, @@ -13225,7 +13227,7 @@ describe('FactoryLoop', () => { fleet.emitAgentExit('ar-92-impl-pear', 'crash') await vi.waitFor(() => expect(publishInputs).toHaveLength(1)) - expect(publishInputs[0]).not.toHaveProperty('sessionRef') + expect(publishInputs[0]?.sessionRef).toBe(attestationSessionRef) expect(publishInputs[0]?.body).toContain( '', ) diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 7932cc6..e0a6450 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -7851,7 +7851,6 @@ export class FactoryLoop implements Factory { ): Promise { const key = `${issueKey(record.issue)}:${implementer.spec.repo}` const trajectorySessionRef = canonicalTrajectorySessionRef(implementer.sessionRef) - ?? canonicalTrajectorySessionRef(process.env.RELAY_ATTEST_SESSION_ID) const expectedHeadRef = implementer.spec.branch if (!expectedHeadRef) { throw new Error(`Refusing to publish ${record.issue.key}: implementer has no Factory-derived branch`) @@ -7928,7 +7927,7 @@ export class FactoryLoop implements Factory { baseRef, title: `${issue.key}: ${issue.title}`, body: githubPullRequestBody(issue, implementer.spec.preview, trajectorySessionRef), - ...(trajectorySessionRef ? { sessionRef: trajectorySessionRef } : {}), + ...(implementer.sessionRef ? { sessionRef: implementer.sessionRef } : {}), }) const published = result.author ? result From 3a413cc040eb3b82346b9d3568e9b9f71ff1d263 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 18 Aug 2026 22:38:15 +0200 Subject: [PATCH 3/3] docs(factory): track replay pointer SDK exports --- .agentworkforce/features/manifest.yaml | 2 +- src/trajectory.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.agentworkforce/features/manifest.yaml b/.agentworkforce/features/manifest.yaml index 53f4b2d..6d28816 100644 --- a/.agentworkforce/features/manifest.yaml +++ b/.agentworkforce/features/manifest.yaml @@ -868,7 +868,7 @@ categories: name: Completed-Session Replay Pointer api: trajectoryPointerFromBody() description: Stamp Factory-opened PRs with the ruled three-key trajectory marker from Relay's existing opaque session_ref while leaving replay availability and the workspace retention boundary to the authenticated live resolver - location: src/trajectory.ts, src/orchestrator/factory.ts + location: src/trajectory.ts, src/orchestrator/factory.ts, src/index.ts verify_tier: 6 - id: pr-babysitter-opt-in diff --git a/src/trajectory.ts b/src/trajectory.ts index eee3452..8739b40 100644 --- a/src/trajectory.ts +++ b/src/trajectory.ts @@ -27,6 +27,7 @@ export function canonicalTrajectorySessionRef(value: string | undefined): string return normalized } +/** Render the ruled three-key HTML marker without claiming replay availability. */ export function renderTrajectoryPointer(pointer: TrajectoryPointer): string { if (!POINTER_TOKEN.test(pointer.workUnitId)) { throw new Error(`Trajectory work unit id must be a comment-safe token: ${pointer.workUnitId}`) @@ -52,6 +53,7 @@ export function trajectoryPointerFromBody(body: string): Required