diff --git a/docs/workspace-issues-and-scheduling.md b/docs/workspace-issues-and-scheduling.md index 65e7ba3b2..5f28d3ae7 100644 --- a/docs/workspace-issues-and-scheduling.md +++ b/docs/workspace-issues-and-scheduling.md @@ -161,8 +161,12 @@ When a user opens an Inbox result, the frontend supplies its OpenAlice-owned Claude/Codex/opencode/Pi session id and resumes the original conversation in an interactive PTY. Later opens from Inbox, Automation, or the Workspace sidebar reuse the Session indexed by `resumeId`; native ids never cross the product -protocol. `taskId` remains one execution, while `parentTaskId` records direct -turn lineage. Runs and their logs are retained rather than silently pruned. +protocol. New identities use one stable, human-readable key such as +`resume-calm-amber-river-a1b2c3`: the petname makes product surfaces legible and +the six-character base36 tail supplies global entropy. Existing UUID identities +remain valid and are never renamed. `taskId` remains one execution, while +`parentTaskId` records direct turn lineage. Runs and their logs are retained +rather than silently pruned. Scheduling never bypasses trading approval. A headless agent may research or stage a trade, but execution remains behind UTA/Trading-as-Git permission and diff --git a/src/server/inbox-origin.spec.ts b/src/server/inbox-origin.spec.ts index fa22173b4..a4dc4c4f4 100644 --- a/src/server/inbox-origin.spec.ts +++ b/src/server/inbox-origin.spec.ts @@ -32,9 +32,14 @@ describe('resolveInboxOrigin — headless (Phase 1)', () => { it('omits issueId when the run had none (manual/external dispatch)', () => { const origin = resolveInboxOrigin({ run: 'run-8' }, () => - svc({ headless: { 'run-8': { taskId: 'run-8', agent: 'opencode' } } }) as any, + svc({ headless: { 'run-8': { taskId: 'run-8', resumeId: 'resume-8', agent: 'opencode' } } }) as any, ) - expect(origin).toEqual({ kind: 'headless', runId: 'run-8', agent: 'opencode' }) + expect(origin).toEqual({ + kind: 'headless', + runId: 'run-8', + resumeId: 'resume-8', + agent: 'opencode', + }) }) it('undefined for a missing/blank run header (no session either)', () => { @@ -93,11 +98,24 @@ describe('resolveInboxOrigin — precedence + both-absent', () => { it('a resolvable run header wins over a present session header', () => { const origin = resolveInboxOrigin({ run: 'run-7', session: 'sess-1', wsId: 'ws1' }, () => svc({ - headless: { 'run-7': { taskId: 'run-7', issueId: 'macro', agent: 'claude' } }, + headless: { + 'run-7': { + taskId: 'run-7', + resumeId: 'resume-7', + issueId: 'macro', + agent: 'claude', + }, + }, sessions: { ws1: { 'sess-1': { id: 'sess-1', wsId: 'ws1', agent: 'codex' } } }, }) as any, ) - expect(origin).toEqual({ kind: 'headless', runId: 'run-7', issueId: 'macro', agent: 'claude' }) + expect(origin).toEqual({ + kind: 'headless', + runId: 'run-7', + resumeId: 'resume-7', + issueId: 'macro', + agent: 'claude', + }) }) it('falls through to the session header when the run id is unknown', () => { diff --git a/src/workspaces/headless-task-registry.spec.ts b/src/workspaces/headless-task-registry.spec.ts index 5d6f43b9b..cd79d0d14 100644 --- a/src/workspaces/headless-task-registry.spec.ts +++ b/src/workspaces/headless-task-registry.spec.ts @@ -20,28 +20,42 @@ const noopLogger = { let dir: string let path: string +let resumeSequence: number beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'htr-')) path = join(dir, 'tasks.json') + resumeSequence = 0 }) afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) +type CreateInput = Parameters[0] + +function createTask( + registry: HeadlessTaskRegistry, + input: Omit & { resumeId?: string }, +) { + return registry.create({ + ...input, + resumeId: input.resumeId ?? `resume-test-${++resumeSequence}`, + }) +} + describe('HeadlessTaskRegistry', () => { it('create → running record, listed newest-first', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const a = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'do A', startedAt: 1 }) - const b = await reg.create({ wsId: 'w2', agent: 'pi', prompt: 'do B', startedAt: 2 }) + const a = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'do A', startedAt: 1 }) + const b = await createTask(reg, { wsId: 'w2', agent: 'pi', prompt: 'do B', startedAt: 2 }) expect(a.status).toBe('running') - expect(a.resumeId).toMatch(/^[0-9a-f-]{36}$/) + expect(a.resumeId).toBe('resume-test-1') expect(reg.list().map((t) => t.taskId)).toEqual([b.taskId, a.taskId]) // newest-first expect(reg.runningCount()).toBe(2) }) it('complete updates status; get returns it; runningCount drops', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const a = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) + const a = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) await reg.complete(a.taskId, { status: 'done', exitCode: 0, durationMs: 5, finishedAt: 2 }) expect(reg.get(a.taskId)?.status).toBe('done') expect(reg.get(a.taskId)?.exitCode).toBe(0) @@ -50,8 +64,8 @@ describe('HeadlessTaskRegistry', () => { it('list filters by wsId / status / limit', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const a = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) - await reg.create({ wsId: 'w2', agent: 'pi', prompt: 'y', startedAt: 2 }) + const a = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) + await createTask(reg, { wsId: 'w2', agent: 'pi', prompt: 'y', startedAt: 2 }) await reg.complete(a.taskId, { status: 'done' }) expect(reg.list({ wsId: 'w2' }).length).toBe(1) expect(reg.list({ status: 'done' }).map((t) => t.taskId)).toEqual([a.taskId]) @@ -60,8 +74,8 @@ describe('HeadlessTaskRegistry', () => { it('records issueId when an issue fired the run; omits it for manual runs', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const fired = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1, issueId: 'daily-scan' }) - const manual = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'y', startedAt: 2 }) + const fired = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1, issueId: 'daily-scan' }) + const manual = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'y', startedAt: 2 }) expect(fired.issueId).toBe('daily-scan') // Manual runs leave the field absent (not undefined-valued) so the JSON stays clean. expect('issueId' in manual).toBe(false) @@ -72,23 +86,23 @@ describe('HeadlessTaskRegistry', () => { it('list filters by issueId (the issue detail Activity feed join)', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const a = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1, issueId: 'iss-a' }) - const b = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'y', startedAt: 2, issueId: 'iss-a' }) - await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'z', startedAt: 3, issueId: 'iss-b' }) - await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'm', startedAt: 4 }) // manual, no issueId + const a = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1, issueId: 'iss-a' }) + const b = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'y', startedAt: 2, issueId: 'iss-a' }) + await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'z', startedAt: 3, issueId: 'iss-b' }) + await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'm', startedAt: 4 }) // manual, no issueId // newest-first, only iss-a's runs. expect(reg.list({ wsId: 'w1', issueId: 'iss-a' }).map((t) => t.taskId)).toEqual([b.taskId, a.taskId]) }) it('stores the full task prompt (not truncated — collapsible in the UI)', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const a = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'x'.repeat(1000), startedAt: 1 }) + const a = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'x'.repeat(1000), startedAt: 1 }) expect(a.prompt.length).toBe(1000) }) it('persists completed records across reload', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const a = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) + const a = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) await reg.complete(a.taskId, { status: 'done', finishedAt: 2 }) const reg2 = await HeadlessTaskRegistry.load(path, noopLogger) expect(reg2.get(a.taskId)?.status).toBe('done') @@ -97,7 +111,7 @@ describe('HeadlessTaskRegistry', () => { it('serializes concurrent registry writes without losing records', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) const created = await Promise.all( - Array.from({ length: 24 }, (_, index) => reg.create({ + Array.from({ length: 24 }, (_, index) => createTask(reg, { wsId: `w${index % 3}`, agent: index % 2 ? 'pi' : 'codex', prompt: `task ${index}`, @@ -115,9 +129,9 @@ describe('HeadlessTaskRegistry', () => { it('pages newest-first with a stable task cursor', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const oldest = await reg.create({ wsId: 'w1', agent: 'claude', prompt: 'oldest', startedAt: 1 }) - const middle = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'middle', startedAt: 2 }) - const newest = await reg.create({ wsId: 'w1', agent: 'pi', prompt: 'newest', startedAt: 3 }) + const oldest = await createTask(reg, { wsId: 'w1', agent: 'claude', prompt: 'oldest', startedAt: 1 }) + const middle = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'middle', startedAt: 2 }) + const newest = await createTask(reg, { wsId: 'w1', agent: 'pi', prompt: 'newest', startedAt: 3 }) expect(reg.list({ limit: 2 }).map((task) => task.taskId)).toEqual([newest.taskId, middle.taskId]) expect(reg.list({ cursor: middle.taskId, limit: 2 }).map((task) => task.taskId)).toEqual([oldest.taskId]) @@ -128,7 +142,7 @@ describe('HeadlessTaskRegistry', () => { it('reconcile-on-boot flips a leftover running task → interrupted', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) // stays running + await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'x', startedAt: 1 }) // stays running const reloaded = await HeadlessTaskRegistry.load(path, noopLogger) expect(reloaded.runningCount()).toBe(0) expect(reloaded.list()[0]?.status).toBe('interrupted') @@ -136,7 +150,7 @@ describe('HeadlessTaskRegistry', () => { it('setAgentSessionId records the id mid-run and persists across reload', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const a = await reg.create({ wsId: 'w1', agent: 'claude', prompt: 'x', startedAt: 1 }) + const a = await createTask(reg, { wsId: 'w1', agent: 'claude', prompt: 'x', startedAt: 1 }) await reg.setAgentSessionId(a.taskId, '414d6b8c-95b4-4e01-8ffc-4b6332da17d4') expect(reg.get(a.taskId)?.agentSessionId).toBe('414d6b8c-95b4-4e01-8ffc-4b6332da17d4') const reloaded = await HeadlessTaskRegistry.load(path, noopLogger) @@ -147,7 +161,7 @@ describe('HeadlessTaskRegistry', () => { const logsDir = join(dir, 'logs') await mkdir(logsDir, { recursive: true }) const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const first = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'old', startedAt: 1 }) + const first = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'old', startedAt: 1 }) await reg.complete(first.taskId, { status: 'done' }) const firstLogs = headlessLogPaths(logsDir, first.taskId) await writeFile(firstLogs.stdout, 'old stdout') @@ -155,7 +169,7 @@ describe('HeadlessTaskRegistry', () => { await writeFile(firstLogs.structured, '{}') // Cross the historical 200-record cap. Runs are durable product history. for (let i = 0; i < 200; i++) { - const t = await reg.create({ wsId: 'w1', agent: 'codex', prompt: `t${i}`, startedAt: 2 + i }) + const t = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: `t${i}`, startedAt: 2 + i }) await reg.complete(t.taskId, { status: 'done' }) } const reloaded = await HeadlessTaskRegistry.load(path, noopLogger) @@ -168,9 +182,9 @@ describe('HeadlessTaskRegistry', () => { it('keeps one resumeId across executions and records direct lineage', async () => { const reg = await HeadlessTaskRegistry.load(path, noopLogger) - const first = await reg.create({ wsId: 'w1', agent: 'codex', prompt: 'first', startedAt: 1 }) + const first = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'first', startedAt: 1 }) await reg.complete(first.taskId, { status: 'done' }) - const second = await reg.create({ + const second = await createTask(reg, { wsId: 'w1', agent: 'codex', prompt: 'follow-up', startedAt: 2, resumeId: first.resumeId, parentTaskId: first.taskId, }) diff --git a/src/workspaces/headless-task-registry.ts b/src/workspaces/headless-task-registry.ts index a06dacf7d..f9da966c8 100644 --- a/src/workspaces/headless-task-registry.ts +++ b/src/workspaces/headless-task-registry.ts @@ -132,8 +132,8 @@ export class HeadlessTaskRegistry { agent: string prompt: string startedAt: number - /** Reuse when continuing a conversation; fresh runs get a new UUID. */ - resumeId?: string + /** Product identity allocated by ResumeRegistry; reused across continued turns. */ + resumeId: string /** Previous execution in the same resume chain, when continuing. */ parentTaskId?: string /** Set only when an issue fired this run (scheduled scan); omitted for manual/external runs. */ @@ -141,7 +141,7 @@ export class HeadlessTaskRegistry { }): Promise { const rec: HeadlessTaskRecord = { taskId: randomUUID(), - resumeId: input.resumeId ?? randomUUID(), + resumeId: input.resumeId, ...(input.parentTaskId ? { parentTaskId: input.parentTaskId } : {}), wsId: input.wsId, agent: input.agent, diff --git a/src/workspaces/petname-id.spec.ts b/src/workspaces/petname-id.spec.ts index 0b9ca04bc..2ab8276d0 100644 --- a/src/workspaces/petname-id.spec.ts +++ b/src/workspaces/petname-id.spec.ts @@ -31,4 +31,11 @@ describe('petname ids', () => { expect(id).toBe('chat-clear-copper-harbor') }) + + it('adds fixed-width base36 entropy for large namespaces', () => { + expect(generatePetnameId('resume', { + randomInt: sequence(0, 0, 0, 10, 11, 12, 13, 14, 15), + randomSuffixLength: 6, + })).toBe('resume-calm-amber-river-abcdef') + }) }) diff --git a/src/workspaces/petname-id.ts b/src/workspaces/petname-id.ts index 5899b71b7..1a65b0178 100644 --- a/src/workspaces/petname-id.ts +++ b/src/workspaces/petname-id.ts @@ -12,6 +12,7 @@ import { randomInt as cryptoRandomInt } from 'node:crypto' const MAX_ATTEMPTS = 128 const PREFIX_MAX = 24 const DEFAULT_FALLBACK_PREFIX = 'item' +const RANDOM_SUFFIX_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz' const ADJECTIVES = [ 'calm', @@ -125,6 +126,8 @@ export interface PetnameOptions { readonly isTaken?: (id: string) => boolean readonly maxAttempts?: number readonly randomInt?: RandomInt + /** Append fixed-width base36 entropy when the id lives in a large namespace. */ + readonly randomSuffixLength?: number } export function normalizeIdPrefix(input: string, fallback = DEFAULT_FALLBACK_PREFIX): string { @@ -145,15 +148,26 @@ export function generatePetnameId(prefix: string, opts: PetnameOptions = {}): st const safePrefix = normalizeIdPrefix(prefix, opts.fallbackPrefix) for (let i = 0; i < attempts; i += 1) { - const id = [ + const parts = [ safePrefix, pick(ADJECTIVES, randomInt), pick(MATERIALS, randomInt), pick(PLACES, randomInt), - ].join('-') + ] + if (opts.randomSuffixLength !== undefined) { + parts.push(randomSuffix(opts.randomSuffixLength, randomInt)) + } + const id = parts.join('-') if (!isTaken(id)) return id } + // A caller that requested explicit entropy also requested a stable format. + // Its namespace is large enough that exhausting every retry is an error, + // rather than a reason to silently switch to another suffix shape. + if (opts.randomSuffixLength !== undefined) { + throw new Error(`could not allocate a petname id for prefix "${safePrefix}"`) + } + // Extremely unlikely unless the candidate space is exhausted or tests force // collisions. Keep the fallback readable enough while guaranteeing progress. for (let i = 0; i < attempts; i += 1) { @@ -173,3 +187,13 @@ export function generatePetnameId(prefix: string, opts: PetnameOptions = {}): st function pick(items: readonly T[], randomInt: RandomInt): T { return items[randomInt(items.length)]! } + +function randomSuffix(length: number, randomInt: RandomInt): string { + if (!Number.isSafeInteger(length) || length < 1) { + throw new Error('randomSuffixLength must be a positive integer') + } + return Array.from( + { length }, + () => RANDOM_SUFFIX_ALPHABET[randomInt(RANDOM_SUFFIX_ALPHABET.length)]!, + ).join('') +} diff --git a/src/workspaces/resume-id.spec.ts b/src/workspaces/resume-id.spec.ts new file mode 100644 index 000000000..346c0c278 --- /dev/null +++ b/src/workspaces/resume-id.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import type { RandomInt } from './petname-id.js' +import { generateResumeId } from './resume-id.js' + +function sequence(...values: number[]): RandomInt { + let index = 0 + return (exclusiveMax: number) => (values[index++] ?? 0) % exclusiveMax +} + +describe('resume ids', () => { + it('combines a readable petname with a six-character random tail', () => { + expect(generateResumeId({ + randomInt: sequence(0, 0, 0, 10, 11, 12, 13, 14, 15), + })).toBe('resume-calm-amber-river-abcdef') + }) + + it('retries the complete id when a candidate is already taken', () => { + const taken = new Set(['resume-calm-amber-river-000000']) + expect(generateResumeId({ + randomInt: sequence( + 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, + ), + isTaken: (candidate) => taken.has(candidate), + })).toBe('resume-clear-copper-harbor-111111') + }) +}) diff --git a/src/workspaces/resume-id.ts b/src/workspaces/resume-id.ts new file mode 100644 index 000000000..80893aff5 --- /dev/null +++ b/src/workspaces/resume-id.ts @@ -0,0 +1,23 @@ +import { generatePetnameId, type RandomInt } from './petname-id.js' + +const RESUME_SUFFIX_LENGTH = 6 + +export interface ResumeIdOptions { + readonly isTaken?: (id: string) => boolean + readonly randomInt?: RandomInt +} + +/** + * Allocate the single, product-owned identity for a resumable conversation. + * + * The petname keeps URLs and logs recognizable; the fixed base36 suffix adds + * enough entropy for the global, long-lived resume namespace. Existing UUID + * resume ids remain valid and are never rewritten. + */ +export function generateResumeId(opts: ResumeIdOptions = {}): string { + return generatePetnameId('resume', { + randomSuffixLength: RESUME_SUFFIX_LENGTH, + ...(opts.isTaken ? { isTaken: opts.isTaken } : {}), + ...(opts.randomInt ? { randomInt: opts.randomInt } : {}), + }) +} diff --git a/src/workspaces/resume-registry.spec.ts b/src/workspaces/resume-registry.spec.ts index 1882d6405..4c22d6204 100644 --- a/src/workspaces/resume-registry.spec.ts +++ b/src/workspaces/resume-registry.spec.ts @@ -21,6 +21,7 @@ describe('ResumeRegistry', () => { it('maps one product resumeId to a backend-only native session id', async () => { const registry = await ResumeRegistry.load(path, noopLogger) const created = await registry.ensure({ wsId: 'ws-1', agent: 'claude', now: 1 }) + expect(created.resumeId).toMatch(/^resume-[a-z]+-[a-z]+-[a-z]+-[0-9a-z]{6}$/) await registry.bindAgentSessionId(created.resumeId, 'native-claude-session') const reloaded = await ResumeRegistry.load(path, noopLogger) @@ -37,4 +38,17 @@ describe('ResumeRegistry', () => { await expect(registry.ensure({ resumeId: created.resumeId, wsId: 'ws-2', agent: 'pi' })) .rejects.toThrow(/belongs to ws-1\/pi/) }) + + it('keeps legacy UUID identities valid without rewriting them', async () => { + const registry = await ResumeRegistry.load(path, noopLogger) + const legacyId = '550e8400-e29b-41d4-a716-446655440000' + const record = await registry.ensure({ + resumeId: legacyId, + wsId: 'ws-legacy', + agent: 'codex', + }) + + expect(record.resumeId).toBe(legacyId) + expect((await ResumeRegistry.load(path, noopLogger)).get(legacyId)?.resumeId).toBe(legacyId) + }) }) diff --git a/src/workspaces/resume-registry.ts b/src/workspaces/resume-registry.ts index 8c4b4c249..0a4df6ecb 100644 --- a/src/workspaces/resume-registry.ts +++ b/src/workspaces/resume-registry.ts @@ -5,11 +5,11 @@ * the backend boundary. This registry is the translation table between that * stable product identity and the current CLI-specific conversation id. */ -import { randomUUID } from 'node:crypto' import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import type { Logger } from './logger.js' +import { generateResumeId } from './resume-id.js' export interface ResumeIdentityRecord { readonly resumeId: string @@ -81,7 +81,9 @@ export class ResumeRegistry { latestTaskId?: string now?: number }): Promise { - const resumeId = input.resumeId ?? randomUUID() + const resumeId = input.resumeId ?? generateResumeId({ + isTaken: (candidate) => this.records.has(candidate), + }) const existing = this.records.get(resumeId) if (existing) { if (existing.wsId !== input.wsId || existing.agent !== input.agent) { diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index d4bb77a93..9b309ba58 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -990,17 +990,24 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions } let rec: HeadlessTaskRecord; try { + // ResumeRegistry is the sole allocator for product conversation ids. + // HeadlessTaskRegistry only records executions against that identity. + const identity = await resumeRegistry.ensure({ + ...(resumeId ? { resumeId } : {}), + wsId: ws.id, + agent: adapter.id, + }); rec = await headlessTasks.create({ wsId: ws.id, agent: adapter.id, prompt, startedAt: Date.now(), - ...(resumeId ? { resumeId } : {}), + resumeId: identity.resumeId, ...(parentTaskId ? { parentTaskId } : {}), ...(issueId ? { issueId } : {}), }); await resumeRegistry.ensure({ - resumeId: rec.resumeId, + resumeId: identity.resumeId, wsId: ws.id, agent: adapter.id, latestTaskId: rec.taskId, diff --git a/src/workspaces/session-registry.spec.ts b/src/workspaces/session-registry.spec.ts index ac78b43b5..84a338c31 100644 --- a/src/workspaces/session-registry.spec.ts +++ b/src/workspaces/session-registry.spec.ts @@ -33,6 +33,7 @@ const LEGACY_UUID_WS = '4894ef8b-66e1-4a41-a222-ba564e51a8c0' function rec(over: Partial = {}): SessionRecord { return { id: 'claude-calm-amber-river', + resumeId: 'resume-calm-amber-river-a1b2c3', wsId: WS, agent: 'claude', name: 'c1', diff --git a/ui/src/components/workspace/ResumeCta.tsx b/ui/src/components/workspace/ResumeCta.tsx index e221550aa..1e7f2e507 100644 --- a/ui/src/components/workspace/ResumeCta.tsx +++ b/ui/src/components/workspace/ResumeCta.tsx @@ -77,7 +77,7 @@ export function ResumeCta(props: ResumeCtaProps): ReactElement { {r.resumeId && ( <>
Transcript
-
{r.resumeId.slice(0, 8)}
+
{r.resumeId}
)} diff --git a/ui/src/components/workspace/Sidebar.tsx b/ui/src/components/workspace/Sidebar.tsx index 05f54ffba..6563ec4ae 100644 --- a/ui/src/components/workspace/Sidebar.tsx +++ b/ui/src/components/workspace/Sidebar.tsx @@ -631,10 +631,9 @@ export function SessionRow(props: SessionRowProps): ReactElement { const isPaused = s.state === 'paused'; // Title: the captured first message (seeded sessions), else the sticky name. const display = s.title?.trim() || s.name; - const tidShort = s.resumeId ? s.resumeId.slice(0, 8) : null; const metaParts: string[] = [`agent ${s.agent}`]; if (s.pid !== null) metaParts.push(`pid ${s.pid}`); - if (tidShort) metaParts.push(tidShort); + if (s.resumeId) metaParts.push(s.resumeId); if (isPaused) metaParts.push(t('workspace.paused')); const meta = metaParts.join(' · '); // Full message on hover when it's been truncated, then the technical meta.