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
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ describe('Factory feature manifest contract', () => {
'./testing': '@agent-relay/factory/testing',
'./writeback': 'LinearWriteback / GithubWriteback / SlackWriteback',
'./node': 'createFactoryNodeDefinition()',
'./cli': '@agent-relay/factory/cli',
'./featuremap': '@agent-relay/factory/featuremap',
'./intake': '@agent-relay/factory/intake',
'./feature-guardian': '@agent-relay/factory/feature-guardian',
Expand Down
19 changes: 13 additions & 6 deletions .agentworkforce/features/manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
version: '1.1'
updated: '2026-08-07'
updated: '2026-08-17'
catalog:
category_count: 25
feature_count: 318
feature_count: 319
tier_counts:
1: 48
1: 49
2: 127
3: 11
4: 52
Expand Down Expand Up @@ -129,7 +129,7 @@ categories:
name: Factory Status
cli: factory status
api: Factory.status()
description: Print in-flight issues, queued issues, counters, Slack degradation state, in-flight dispatch claims, running version identity, and held agents (age, deadline, terminal state awaited) as JSON
description: Print in-flight issues, queued issues, counters, Slack degradation state, in-flight dispatch claims, running version identity, optional host-defined state-store identity, and held agents (age, deadline, terminal state awaited) as JSON
location: src/cli/fleet.ts, src/orchestrator/factory.ts
verify_tier: 2

Expand Down Expand Up @@ -706,6 +706,13 @@ categories:
location: README.md, src/ports/state.ts, src/state/file-state-store.ts, src/orchestrator/factory.ts
verify_tier: 4

- id: dispatch-document-state-store-port
name: Pluggable Document State Persistence
api: '@agent-relay/factory/cli / DocumentStateStore / WatchStateDocumentStore / CliStateStoreFactory'
description: Inject a serialized whole-document persistence port without reimplementing StateStore behavior, preserve the atomic file adapter as the default, and require an injected CLI adapter to pass its readiness gate before Factory construction
location: docs/document-state-store.md, package.json, src/state/document-store.ts, src/state/file-state-store.ts, src/cli/index.ts, src/cli/fleet.ts
verify_tier: 1

- id: dispatch-remote-publication-recovery
name: Remote PR Publication and Release Recovery
api: GithubConnectionWrite.publishPullRequest() / DispatchLifecycle
Expand Down Expand Up @@ -1457,8 +1464,8 @@ categories:

- id: api-state-stores
name: State Store API
api: InMemoryStateStore / FileStateStore
description: Persist batch, retry, critical delivery, human-loop, handoff, canonical-state, and discovery sweep lease and checkpoint records
api: InMemoryStateStore / DocumentStateStore / FileStateStore
description: Persist batch, retry, critical delivery, human-loop, handoff, canonical-state, and discovery sweep lease and checkpoint records through memory, an injectable document port, or the atomic file adapter
location: src/ports/state.ts, src/state/
verify_tier: 1

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,13 +555,21 @@ as local processes.

The supported topology for the **CLI control plane** is one Factory host per
workspace, with any number of relay execution nodes. Multiple Factory processes
on that host are fenced through the shared `FileStateStore` lock/lease.
that open the same state file do not mutate it concurrently: `FileStateStore`
holds a cross-process filesystem lock around the complete read/modify/write,
so a second process waits, reloads after it acquires the lock, and then publishes
through fsync plus atomic rename. The in-process operation queue alone is not
the cross-process fence.
Active/active CLI control planes on different hosts remain intentionally
unsupported: separate local state files cannot provide a truthful cross-host
fence.

### Hosting the control plane in Cloud

Embedded CLI hosts can inject durable coordination storage through the
host-neutral [`WatchStateDocumentStore` port](docs/document-state-store.md)
via `stateStoreFactory`.

`@agent-relay/factory/hosted` is the worker-safe control-plane entrypoint. It
contains no Node filesystem/process dependency and runs the complete sweep:

Expand Down
46 changes: 46 additions & 0 deletions docs/document-state-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Document state-store port

`DocumentStateStore` owns Factory's existing claim, lease, lifecycle,
conversation, babysitter, and discovery behavior. It delegates only persistence
and serialization to `WatchStateDocumentStore`:

```ts
interface WatchStateDocumentStore {
read(): Promise<WatchStateDocument>
write(document: WatchStateDocument): Promise<void>
runMutation<T>(operation: () => Promise<T>): Promise<T>
assertReady(): Promise<void>
}
```

`runMutation` must serialize a complete read/modify/write callback against all
other writers that share the backend. A compare-and-set backend may retry that
callback after a conflict. It must never translate an unreadable or
uninitialized backend into `{ version: 3, workspaces: {} }`.

`FileStateStore` remains the default. Its adapter keeps the existing advisory
file lock, private temporary file, file sync, atomic rename, parent-directory
sync, pretty JSON bytes, and missing-file behavior.

## Embedded CLI adapter

Hosts that need a different persistence implementation can use the public
`@agent-relay/factory/cli` entrypoint:

```ts
import { runFleetCli } from '@agent-relay/factory/cli'
import { DocumentStateStore } from '@agent-relay/factory'

const exitCode = await runFleetCli(process.argv.slice(2), {
stateStoreFactory: (config) => new DocumentStateStore({
batchSize: config.batchSize,
backend: 'host-defined-backend',
documentStore,
}),
})
```

The CLI invokes `assertReady()` on every injected store before it constructs
`Factory`. A failed readiness check exits nonzero and prevents discovery and
dispatch. If the adapter supplies a `backend` identifier, status JSON exposes
it as `stateStore.backend`; Factory does not interpret that host-defined value.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
"types": "./dist/node/factory.node.d.ts",
"import": "./dist/node/factory.node.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
},
"./featuremap": {
"types": "./dist/featuremap/index.d.ts",
"import": "./dist/featuremap/index.js"
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/dist-entrypoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('published dist entrypoints', () => {
it('are importable by Node ESM consumers', async () => {
const featuremap = await import('../../dist/featuremap/index.js')
const featureGuardian = await import('../../dist/feature-guardian/index.js')
const cli = await import('../../dist/cli/index.js')
const main = await import('../../dist/index.js')
const hosted = await import('../../dist/hosted/index.js')
const intake = await import('../../dist/intake/index.js')
Expand All @@ -23,6 +24,7 @@ describe('published dist entrypoints', () => {
expect(featuremap.validateFeatureManifestFile).toBeTypeOf('function')
expect(featureGuardian.defineFeatureGuardianAgent).toBeTypeOf('function')
expect(featureGuardian.runGuardianConversationTurn).toBeTypeOf('function')
expect(cli.runFleetCli).toBeTypeOf('function')
expect(main.FactoryConfigSchema).toBeDefined()
expect(main.createFactory).toBeTypeOf('function')
expect(main.createFleet).toBeTypeOf('function')
Expand Down
78 changes: 77 additions & 1 deletion src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
} from '../index'
import { FactoryConfigSchema, LiveDispatchStateChangedError, stateResolutionFromIds } from '../index'
import { MountAuthScopeError, mountAuthRemediation } from '../mount/mount-auth-error'
import { FileStateStore } from '../state/file-state-store'
import { DocumentStateStore, FileStateStore } from '../state/file-state-store'
import { FakeFleetClient, FakeMountClient, withDeadline } from '../testing'
import type { GithubConnectionRead, GithubConnectionWrite, GithubIssueLookup, LocalMountOptions, SpawnInput, SpawnResult } from '../ports'
import type { HarnessDriverClientLike } from '../fleet/internal-fleet-client'
Expand Down Expand Up @@ -60,6 +60,20 @@ const staleVersionInfo = {
versionsBehind: 38,
}

const testDocumentStateStore = (options: {
backend?: string
assertReady?: () => Promise<void>
} = {}): DocumentStateStore => new DocumentStateStore({
batchSize: 2,
...(options.backend ? { backend: options.backend } : {}),
documentStore: {
read: async () => ({ version: 3, workspaces: {} }),
write: async () => {},
runMutation: async (operation) => await operation(),
assertReady: options.assertReady ?? (async () => {}),
},
})

const fakeHarnessClient = (): HarnessDriverClientLike => ({
async spawnPty(input) {
return { name: input.name, sessionId: 'session' }
Expand Down Expand Up @@ -2625,12 +2639,14 @@ describe('fleet CLI runtime', () => {
on: vi.fn(),
dispose: vi.fn(),
} as unknown as Factory
const assertReady = vi.fn(async () => {})

const code = await runFleetCli([
'status',
'--config',
configPath,
], {
stateStoreFactory: () => testDocumentStateStore({ backend: 'test-durable', assertReady }),
fleet: new FakeFleetClient(),
mount: new FakeMountClient(),
createFactory: () => factory,
Expand All @@ -2640,9 +2656,11 @@ describe('fleet CLI runtime', () => {
})

expect(code).toBe(0)
expect(assertReady).toHaveBeenCalledTimes(1)
expect(JSON.parse(output.text())).toMatchObject({
...factoryStatus,
...staleVersionInfo,
stateStore: { backend: 'test-durable' },
heldAgents: [{
name: 'ar-252-impl-factory',
issue: { key: '252' },
Expand All @@ -2660,6 +2678,36 @@ describe('fleet CLI runtime', () => {
}
})

it('does not eagerly read file state for a status command', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-file-state-compat-'))
try {
const registryPath = join(root, 'registry.json')
const configPath = await writeConfig(root, { loop: { registryPath } })
await writeFile(join(root, 'github-issue-comment-watches.json'), 'not json')
const output = buffer()
const factory = {
status: vi.fn(() => ({ inFlight: [], queued: [], counters: {} })),
} as unknown as Factory

const code = await runFleetCli(['status', '--config', configPath], {
fleet: new FakeFleetClient(),
mount: new FakeMountClient(),
createFactory: () => factory,
stdout: output,
stderr: buffer(),
})

expect(code).toBe(0)
expect(JSON.parse(output.text())).toMatchObject({
inFlight: [],
queued: [],
counters: {},
})
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('surfaces degraded readiness reconciliation from the live daemon heartbeat', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-reconcile-status-'))
try {
Expand Down Expand Up @@ -3454,6 +3502,34 @@ describe('fleet CLI runtime', () => {
}
})

it('fails closed before Factory construction when an injected state backend is unreadable', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-durable-state-gate-'))
try {
const configPath = await writeConfig(root, { issueSource: 'github' })
const createFactorySpy = vi.fn()
const assertReady = vi.fn(async () => {
throw new Error('injected durable state is unreachable')
})
const errors = buffer()

const code = await runFleetCli(['run-once', '--dry-run', '--config', configPath], {
stateStoreFactory: () => testDocumentStateStore({ assertReady }),
fleet: new FakeFleetClient(),
mount: new FakeMountClient(),
createFactory: createFactorySpy as typeof createFactory,
stdout: buffer(),
stderr: errors,
})

expect(code).toBe(1)
expect(assertReady).toHaveBeenCalledTimes(1)
expect(createFactorySpy).not.toHaveBeenCalled()
expect(errors.text()).toContain('injected durable state is unreachable')
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('derives the workspace from the cloud session when config omits workspaceId and forwards the cloud UUID alias', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-start-derive-'))
try {
Expand Down
Loading