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
8 changes: 6 additions & 2 deletions docs/workspace-issues-and-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions src/server/inbox-origin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
62 changes: 38 additions & 24 deletions src/workspaces/headless-task-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeadlessTaskRegistry['create']>[0]

function createTask(
registry: HeadlessTaskRegistry,
input: Omit<CreateInput, 'resumeId'> & { 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)
Expand All @@ -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])
Expand All @@ -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)
Expand All @@ -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')
Expand All @@ -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}`,
Expand All @@ -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])
Expand All @@ -128,15 +142,15 @@ 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')
})

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)
Expand All @@ -147,15 +161,15 @@ 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')
await writeFile(firstLogs.stderr, 'old stderr')
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)
Expand All @@ -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,
})
Expand Down
6 changes: 3 additions & 3 deletions src/workspaces/headless-task-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,16 @@ 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. */
issueId?: string
}): Promise<HeadlessTaskRecord> {
const rec: HeadlessTaskRecord = {
taskId: randomUUID(),
resumeId: input.resumeId ?? randomUUID(),
resumeId: input.resumeId,
...(input.parentTaskId ? { parentTaskId: input.parentTaskId } : {}),
wsId: input.wsId,
agent: input.agent,
Expand Down
7 changes: 7 additions & 0 deletions src/workspaces/petname-id.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
28 changes: 26 additions & 2 deletions src/workspaces/petname-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -173,3 +187,13 @@ export function generatePetnameId(prefix: string, opts: PetnameOptions = {}): st
function pick<T>(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('')
}
28 changes: 28 additions & 0 deletions src/workspaces/resume-id.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
23 changes: 23 additions & 0 deletions src/workspaces/resume-id.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
})
}
14 changes: 14 additions & 0 deletions src/workspaces/resume-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
})
})
Loading
Loading