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
13 changes: 10 additions & 3 deletions .agentworkforce/features/manifest.yaml
Original file line number Diff line number Diff line change
@@ -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.
#
Expand Down Expand Up @@ -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, src/index.ts
verify_tier: 6

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- id: pr-babysitter-opt-in
name: Event-Driven PR Babysitter
api: babysitter.enabled
Expand Down
31 changes: 31 additions & 0 deletions docs/pr-session-replay.md
Original file line number Diff line number Diff line change
@@ -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
<!-- trajectory: work_unit_id=AgentWorkforce/factory#260 work_unit_surface=github session_ref=<relay-session-uuid> -->
```

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 <session_ref>` 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.
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions src/orchestrator/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
`<!-- trajectory: work_unit_id=AR-52 work_unit_surface=linear session_ref=${sessionRef} -->`,
)
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(
Expand Down Expand Up @@ -13205,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,
Expand All @@ -13217,6 +13227,11 @@ describe('FactoryLoop', () => {
fleet.emitAgentExit('ar-92-impl-pear', 'crash')
await vi.waitFor(() => expect(publishInputs).toHaveLength(1))

expect(publishInputs[0]?.sessionRef).toBe(attestationSessionRef)
expect(publishInputs[0]?.body).toContain(
'<!-- trajectory: work_unit_id=factory:uuid-92 work_unit_surface=factory session_ref=missing -->',
)
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.
Expand Down Expand Up @@ -15937,6 +15952,9 @@ describe('FactoryLoop', () => {
baseRef: 'main',
title: '58: GitHub factory issue 58',
})
expect(publishInputs[0]?.body).toContain(
'<!-- trajectory: work_unit_id=AgentWorkforce/pear#58 work_unit_surface=github session_ref=missing -->',
)
expect(factory.status().counters.githubPullRequestsPublished).toBe(1)
})

Expand Down
38 changes: 35 additions & 3 deletions src/orchestrator/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -7844,6 +7850,7 @@ export class FactoryLoop implements Factory {
opts: { reconcileExisting?: boolean } = {},
): Promise<GithubPublishPullRequestResult | undefined> {
const key = `${issueKey(record.issue)}:${implementer.spec.repo}`
const trajectorySessionRef = canonicalTrajectorySessionRef(implementer.sessionRef)
const expectedHeadRef = implementer.spec.branch
if (!expectedHeadRef) {
throw new Error(`Refusing to publish ${record.issue.key}: implementer has no Factory-derived branch`)
Expand Down Expand Up @@ -7919,7 +7926,7 @@ export class FactoryLoop implements Factory {
expectedHeadRef,
baseRef,
title: `${issue.key}: ${issue.title}`,
body: githubPullRequestBody(issue, implementer.spec.preview),
body: githubPullRequestBody(issue, implementer.spec.preview, trajectorySessionRef),
...(implementer.sessionRef ? { sessionRef: implementer.sessionRef } : {}),
})
const published = result.author
Expand Down Expand Up @@ -17924,8 +17931,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}`
Expand All @@ -17935,8 +17946,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 '<name>' already exists". Detect it from
// the structured payload or the message so resume can treat it as terminal
Expand Down
87 changes: 87 additions & 0 deletions src/trajectory.test.ts
Original file line number Diff line number Diff line change
@@ -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(
`<!-- trajectory: work_unit_id=AgentWorkforce/factory#260 work_unit_surface=github session_ref=${sessionRef} -->`,
)
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)
})
})
64 changes: 64 additions & 0 deletions src/trajectory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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 =
/<!-- trajectory: work_unit_id=([^\s>]+) work_unit_surface=(linear|github|factory) session_ref=([^\s>]+) -->/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
}

/** 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}`)
}
const sessionRef = canonicalTrajectorySessionRef(pointer.sessionRef) ?? MISSING_TRAJECTORY_SESSION_REF
return `<!-- trajectory: work_unit_id=${pointer.workUnitId} work_unit_surface=${pointer.workUnitSurface} session_ref=${sessionRef} -->`
}

/**
* 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<TrajectoryPointer> | undefined {
const pointers = new Map<string, Required<TrajectoryPointer>>()
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
}

/** Return only the session UUID from the one unambiguous resolver input pointer. */
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()
}