@citadel-labs/citadel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -0
- package/dist/__tests__/append-bead.test.d.ts +2 -0
- package/dist/__tests__/append-bead.test.d.ts.map +1 -0
- package/dist/__tests__/append-bead.test.js +88 -0
- package/dist/__tests__/append-bead.test.js.map +1 -0
- package/dist/__tests__/blocked-outposts.test.d.ts +2 -0
- package/dist/__tests__/blocked-outposts.test.d.ts.map +1 -0
- package/dist/__tests__/blocked-outposts.test.js +142 -0
- package/dist/__tests__/blocked-outposts.test.js.map +1 -0
- package/dist/__tests__/bugfixes.test.d.ts +2 -0
- package/dist/__tests__/bugfixes.test.d.ts.map +1 -0
- package/dist/__tests__/bugfixes.test.js +503 -0
- package/dist/__tests__/bugfixes.test.js.map +1 -0
- package/dist/__tests__/citizen-tribute.test.d.ts +2 -0
- package/dist/__tests__/citizen-tribute.test.d.ts.map +1 -0
- package/dist/__tests__/citizen-tribute.test.js +106 -0
- package/dist/__tests__/citizen-tribute.test.js.map +1 -0
- package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.d.ts +2 -0
- package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.d.ts.map +1 -0
- package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.js +65 -0
- package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.js.map +1 -0
- package/dist/__tests__/cli-e2e/full-lifecycle.test.d.ts +2 -0
- package/dist/__tests__/cli-e2e/full-lifecycle.test.d.ts.map +1 -0
- package/dist/__tests__/cli-e2e/full-lifecycle.test.js +157 -0
- package/dist/__tests__/cli-e2e/full-lifecycle.test.js.map +1 -0
- package/dist/__tests__/cli-e2e/helpers.d.ts +28 -0
- package/dist/__tests__/cli-e2e/helpers.d.ts.map +1 -0
- package/dist/__tests__/cli-e2e/helpers.js +76 -0
- package/dist/__tests__/cli-e2e/helpers.js.map +1 -0
- package/dist/__tests__/cli-e2e/init-outpost-status.test.d.ts +2 -0
- package/dist/__tests__/cli-e2e/init-outpost-status.test.d.ts.map +1 -0
- package/dist/__tests__/cli-e2e/init-outpost-status.test.js +79 -0
- package/dist/__tests__/cli-e2e/init-outpost-status.test.js.map +1 -0
- package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.d.ts +2 -0
- package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.d.ts.map +1 -0
- package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.js +158 -0
- package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.js.map +1 -0
- package/dist/__tests__/courier.test.d.ts +2 -0
- package/dist/__tests__/courier.test.d.ts.map +1 -0
- package/dist/__tests__/courier.test.js +97 -0
- package/dist/__tests__/courier.test.js.map +1 -0
- package/dist/__tests__/e2e-smoke.test.d.ts +2 -0
- package/dist/__tests__/e2e-smoke.test.d.ts.map +1 -0
- package/dist/__tests__/e2e-smoke.test.js +137 -0
- package/dist/__tests__/e2e-smoke.test.js.map +1 -0
- package/dist/__tests__/fo-broadcast.test.d.ts +2 -0
- package/dist/__tests__/fo-broadcast.test.d.ts.map +1 -0
- package/dist/__tests__/fo-broadcast.test.js +134 -0
- package/dist/__tests__/fo-broadcast.test.js.map +1 -0
- package/dist/__tests__/fo-command-processor.test.d.ts +2 -0
- package/dist/__tests__/fo-command-processor.test.d.ts.map +1 -0
- package/dist/__tests__/fo-command-processor.test.js +86 -0
- package/dist/__tests__/fo-command-processor.test.js.map +1 -0
- package/dist/__tests__/fo-escalation.test.d.ts +2 -0
- package/dist/__tests__/fo-escalation.test.d.ts.map +1 -0
- package/dist/__tests__/fo-escalation.test.js +126 -0
- package/dist/__tests__/fo-escalation.test.js.map +1 -0
- package/dist/__tests__/fo-nudge-watcher.test.d.ts +2 -0
- package/dist/__tests__/fo-nudge-watcher.test.d.ts.map +1 -0
- package/dist/__tests__/fo-nudge-watcher.test.js +154 -0
- package/dist/__tests__/fo-nudge-watcher.test.js.map +1 -0
- package/dist/__tests__/fo-nudge-wiring.test.d.ts +2 -0
- package/dist/__tests__/fo-nudge-wiring.test.d.ts.map +1 -0
- package/dist/__tests__/fo-nudge-wiring.test.js +31 -0
- package/dist/__tests__/fo-nudge-wiring.test.js.map +1 -0
- package/dist/__tests__/fo-relay.test.d.ts +2 -0
- package/dist/__tests__/fo-relay.test.d.ts.map +1 -0
- package/dist/__tests__/fo-relay.test.js +90 -0
- package/dist/__tests__/fo-relay.test.js.map +1 -0
- package/dist/__tests__/fo-task-report.test.d.ts +2 -0
- package/dist/__tests__/fo-task-report.test.d.ts.map +1 -0
- package/dist/__tests__/fo-task-report.test.js +81 -0
- package/dist/__tests__/fo-task-report.test.js.map +1 -0
- package/dist/__tests__/fo-webhook.test.d.ts +2 -0
- package/dist/__tests__/fo-webhook.test.d.ts.map +1 -0
- package/dist/__tests__/fo-webhook.test.js +70 -0
- package/dist/__tests__/fo-webhook.test.js.map +1 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +763 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/multi-outpost-dep.test.d.ts +2 -0
- package/dist/__tests__/multi-outpost-dep.test.d.ts.map +1 -0
- package/dist/__tests__/multi-outpost-dep.test.js +173 -0
- package/dist/__tests__/multi-outpost-dep.test.js.map +1 -0
- package/dist/__tests__/nudge.test.d.ts +2 -0
- package/dist/__tests__/nudge.test.d.ts.map +1 -0
- package/dist/__tests__/nudge.test.js +103 -0
- package/dist/__tests__/nudge.test.js.map +1 -0
- package/dist/__tests__/outpost-registry.test.d.ts +2 -0
- package/dist/__tests__/outpost-registry.test.d.ts.map +1 -0
- package/dist/__tests__/outpost-registry.test.js +72 -0
- package/dist/__tests__/outpost-registry.test.js.map +1 -0
- package/dist/__tests__/process-registry.test.d.ts +2 -0
- package/dist/__tests__/process-registry.test.d.ts.map +1 -0
- package/dist/__tests__/process-registry.test.js +108 -0
- package/dist/__tests__/process-registry.test.js.map +1 -0
- package/dist/__tests__/session-log.test.d.ts +2 -0
- package/dist/__tests__/session-log.test.d.ts.map +1 -0
- package/dist/__tests__/session-log.test.js +60 -0
- package/dist/__tests__/session-log.test.js.map +1 -0
- package/dist/__tests__/spawn-citizen.test.d.ts +2 -0
- package/dist/__tests__/spawn-citizen.test.d.ts.map +1 -0
- package/dist/__tests__/spawn-citizen.test.js +48 -0
- package/dist/__tests__/spawn-citizen.test.js.map +1 -0
- package/dist/__tests__/timeout-watchdog.test.d.ts +2 -0
- package/dist/__tests__/timeout-watchdog.test.d.ts.map +1 -0
- package/dist/__tests__/timeout-watchdog.test.js +81 -0
- package/dist/__tests__/timeout-watchdog.test.js.map +1 -0
- package/dist/__tests__/worktree-manager.test.d.ts +2 -0
- package/dist/__tests__/worktree-manager.test.d.ts.map +1 -0
- package/dist/__tests__/worktree-manager.test.js +98 -0
- package/dist/__tests__/worktree-manager.test.js.map +1 -0
- package/dist/cli/aliases.d.ts +10 -0
- package/dist/cli/aliases.d.ts.map +1 -0
- package/dist/cli/aliases.js +56 -0
- package/dist/cli/aliases.js.map +1 -0
- package/dist/cli/command.d.ts +3 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +63 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +29 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +57 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/outpost.d.ts +3 -0
- package/dist/cli/outpost.d.ts.map +1 -0
- package/dist/cli/outpost.js +65 -0
- package/dist/cli/outpost.js.map +1 -0
- package/dist/cli/reset.d.ts +3 -0
- package/dist/cli/reset.d.ts.map +1 -0
- package/dist/cli/reset.js +67 -0
- package/dist/cli/reset.js.map +1 -0
- package/dist/cli/session.d.ts +3 -0
- package/dist/cli/session.d.ts.map +1 -0
- package/dist/cli/session.js +112 -0
- package/dist/cli/session.js.map +1 -0
- package/dist/cli/start.d.ts +3 -0
- package/dist/cli/start.d.ts.map +1 -0
- package/dist/cli/start.js +105 -0
- package/dist/cli/start.js.map +1 -0
- package/dist/cli/status.d.ts +3 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +128 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/tribute.d.ts +3 -0
- package/dist/cli/tribute.d.ts.map +1 -0
- package/dist/cli/tribute.js +160 -0
- package/dist/cli/tribute.js.map +1 -0
- package/dist/cli/worktree.d.ts +3 -0
- package/dist/cli/worktree.d.ts.map +1 -0
- package/dist/cli/worktree.js +64 -0
- package/dist/cli/worktree.js.map +1 -0
- package/dist/core/append-bead.d.ts +13 -0
- package/dist/core/append-bead.d.ts.map +1 -0
- package/dist/core/append-bead.js +68 -0
- package/dist/core/append-bead.js.map +1 -0
- package/dist/core/blocked-outposts.d.ts +15 -0
- package/dist/core/blocked-outposts.d.ts.map +1 -0
- package/dist/core/blocked-outposts.js +77 -0
- package/dist/core/blocked-outposts.js.map +1 -0
- package/dist/core/citizen-bootstrap.d.ts +17 -0
- package/dist/core/citizen-bootstrap.d.ts.map +1 -0
- package/dist/core/citizen-bootstrap.js +118 -0
- package/dist/core/citizen-bootstrap.js.map +1 -0
- package/dist/core/citizen-tribute.d.ts +17 -0
- package/dist/core/citizen-tribute.d.ts.map +1 -0
- package/dist/core/citizen-tribute.js +55 -0
- package/dist/core/citizen-tribute.js.map +1 -0
- package/dist/core/command-bead.d.ts +6 -0
- package/dist/core/command-bead.d.ts.map +1 -0
- package/dist/core/command-bead.js +20 -0
- package/dist/core/command-bead.js.map +1 -0
- package/dist/core/courier.d.ts +52 -0
- package/dist/core/courier.d.ts.map +1 -0
- package/dist/core/courier.js +167 -0
- package/dist/core/courier.js.map +1 -0
- package/dist/core/fo-broadcast.d.ts +15 -0
- package/dist/core/fo-broadcast.d.ts.map +1 -0
- package/dist/core/fo-broadcast.js +57 -0
- package/dist/core/fo-broadcast.js.map +1 -0
- package/dist/core/fo-command-processor.d.ts +7 -0
- package/dist/core/fo-command-processor.d.ts.map +1 -0
- package/dist/core/fo-command-processor.js +123 -0
- package/dist/core/fo-command-processor.js.map +1 -0
- package/dist/core/fo-dispatch.d.ts +24 -0
- package/dist/core/fo-dispatch.d.ts.map +1 -0
- package/dist/core/fo-dispatch.js +76 -0
- package/dist/core/fo-dispatch.js.map +1 -0
- package/dist/core/fo-escalation.d.ts +20 -0
- package/dist/core/fo-escalation.d.ts.map +1 -0
- package/dist/core/fo-escalation.js +83 -0
- package/dist/core/fo-escalation.js.map +1 -0
- package/dist/core/fo-handlers/ceiling-hit.d.ts +8 -0
- package/dist/core/fo-handlers/ceiling-hit.d.ts.map +1 -0
- package/dist/core/fo-handlers/ceiling-hit.js +10 -0
- package/dist/core/fo-handlers/ceiling-hit.js.map +1 -0
- package/dist/core/fo-handlers/slot-open.d.ts +13 -0
- package/dist/core/fo-handlers/slot-open.d.ts.map +1 -0
- package/dist/core/fo-handlers/slot-open.js +63 -0
- package/dist/core/fo-handlers/slot-open.js.map +1 -0
- package/dist/core/fo-nudge-watcher.d.ts +19 -0
- package/dist/core/fo-nudge-watcher.d.ts.map +1 -0
- package/dist/core/fo-nudge-watcher.js +71 -0
- package/dist/core/fo-nudge-watcher.js.map +1 -0
- package/dist/core/fo-nudge-wiring.d.ts +13 -0
- package/dist/core/fo-nudge-wiring.d.ts.map +1 -0
- package/dist/core/fo-nudge-wiring.js +120 -0
- package/dist/core/fo-nudge-wiring.js.map +1 -0
- package/dist/core/fo-relay.d.ts +7 -0
- package/dist/core/fo-relay.d.ts.map +1 -0
- package/dist/core/fo-relay.js +47 -0
- package/dist/core/fo-relay.js.map +1 -0
- package/dist/core/fo-retry-policy.d.ts +17 -0
- package/dist/core/fo-retry-policy.d.ts.map +1 -0
- package/dist/core/fo-retry-policy.js +89 -0
- package/dist/core/fo-retry-policy.js.map +1 -0
- package/dist/core/fo-state.d.ts +25 -0
- package/dist/core/fo-state.d.ts.map +1 -0
- package/dist/core/fo-state.js +99 -0
- package/dist/core/fo-state.js.map +1 -0
- package/dist/core/fo-task-report.d.ts +16 -0
- package/dist/core/fo-task-report.d.ts.map +1 -0
- package/dist/core/fo-task-report.js +63 -0
- package/dist/core/fo-task-report.js.map +1 -0
- package/dist/core/fo-webhook.d.ts +22 -0
- package/dist/core/fo-webhook.d.ts.map +1 -0
- package/dist/core/fo-webhook.js +43 -0
- package/dist/core/fo-webhook.js.map +1 -0
- package/dist/core/grand-archives.d.ts +14 -0
- package/dist/core/grand-archives.d.ts.map +1 -0
- package/dist/core/grand-archives.js +79 -0
- package/dist/core/grand-archives.js.map +1 -0
- package/dist/core/nudge.d.ts +6 -0
- package/dist/core/nudge.d.ts.map +1 -0
- package/dist/core/nudge.js +19 -0
- package/dist/core/nudge.js.map +1 -0
- package/dist/core/outpost-registry.d.ts +16 -0
- package/dist/core/outpost-registry.d.ts.map +1 -0
- package/dist/core/outpost-registry.js +27 -0
- package/dist/core/outpost-registry.js.map +1 -0
- package/dist/core/pre-spawn-check.d.ts +16 -0
- package/dist/core/pre-spawn-check.d.ts.map +1 -0
- package/dist/core/pre-spawn-check.js +50 -0
- package/dist/core/pre-spawn-check.js.map +1 -0
- package/dist/core/process-registry.d.ts +17 -0
- package/dist/core/process-registry.d.ts.map +1 -0
- package/dist/core/process-registry.js +71 -0
- package/dist/core/process-registry.js.map +1 -0
- package/dist/core/spawn-citizen.d.ts +23 -0
- package/dist/core/spawn-citizen.d.ts.map +1 -0
- package/dist/core/spawn-citizen.js +99 -0
- package/dist/core/spawn-citizen.js.map +1 -0
- package/dist/core/timeout-watchdog.d.ts +14 -0
- package/dist/core/timeout-watchdog.d.ts.map +1 -0
- package/dist/core/timeout-watchdog.js +68 -0
- package/dist/core/timeout-watchdog.js.map +1 -0
- package/dist/core/validate-tribute.d.ts +11 -0
- package/dist/core/validate-tribute.d.ts.map +1 -0
- package/dist/core/validate-tribute.js +44 -0
- package/dist/core/validate-tribute.js.map +1 -0
- package/dist/core/worktree-manager.d.ts +20 -0
- package/dist/core/worktree-manager.d.ts.map +1 -0
- package/dist/core/worktree-manager.js +123 -0
- package/dist/core/worktree-manager.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/types/agent.d.ts +47 -0
- package/dist/types/agent.d.ts.map +1 -0
- package/dist/types/agent.js +4 -0
- package/dist/types/agent.js.map +1 -0
- package/dist/types/beads.d.ts +175 -0
- package/dist/types/beads.d.ts.map +1 -0
- package/dist/types/beads.js +4 -0
- package/dist/types/beads.js.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +4 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/registry.d.ts +110 -0
- package/dist/types/registry.d.ts.map +1 -0
- package/dist/types/registry.js +3 -0
- package/dist/types/registry.js.map +1 -0
- package/docs/README.md +7 -0
- package/docs/architecture.md +106 -0
- package/docs/cli-reference.md +81 -0
- package/docs/configuration.md +143 -0
- package/docs/contributing.md +65 -0
- package/docs/getting-started.md +88 -0
- package/grand-archives/archetypes/architect/archetype.json +18 -0
- package/grand-archives/archetypes/qa-engineer/archetype.json +18 -0
- package/grand-archives/archetypes/software-engineer/archetype.json +18 -0
- package/grand-archives/archetypes/software-engineer/system-prompt.md +39 -0
- package/grand-archives/archetypes/software-engineer/toolset.json +15 -0
- package/grand-archives/first-officer/config.json +21 -0
- package/grand-archives/first-officer/system-prompt.md +47 -0
- package/grand-archives/first-officer/toolset.json +19 -0
- package/grand-archives/registry.json +8 -0
- package/grand-archives/shared/base-prompt-preamble.md +25 -0
- package/grand-archives/shared/base-tools.json +10 -0
- package/package.json +73 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { dispatch } from '../core/fo-dispatch.js';
|
|
8
|
+
import { ProcessRegistry } from '../core/process-registry.js';
|
|
9
|
+
import { appendBead, readBeads, mintBeadId } from '../core/append-bead.js';
|
|
10
|
+
import { handleSlotOpen } from '../core/fo-handlers/slot-open.js';
|
|
11
|
+
import { handleCeilingHit } from '../core/fo-handlers/ceiling-hit.js';
|
|
12
|
+
import { preSpawnCheck } from '../core/pre-spawn-check.js';
|
|
13
|
+
import { applyRetryPolicy } from '../core/fo-retry-policy.js';
|
|
14
|
+
import { citizenBootstrap } from '../core/citizen-bootstrap.js';
|
|
15
|
+
import { nudgeFirstOfficer } from '../core/nudge.js';
|
|
16
|
+
import { broadcastDependencyResolved } from '../core/fo-broadcast.js';
|
|
17
|
+
import { writeCommandBead } from '../core/command-bead.js';
|
|
18
|
+
import { processCommandBead } from '../core/fo-command-processor.js';
|
|
19
|
+
import { reconstructState } from '../core/fo-state.js';
|
|
20
|
+
import { validateTribute } from '../core/validate-tribute.js';
|
|
21
|
+
import { SCHEMA_VERSION } from '../types/index.js';
|
|
22
|
+
const execFile = promisify(execFileCb);
|
|
23
|
+
async function setupTempCitadel(opts) {
|
|
24
|
+
const citadelRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'citadel-int-'));
|
|
25
|
+
const outpostSlug = 'test-outpost';
|
|
26
|
+
const outpostPath = path.join(citadelRoot, 'outposts', outpostSlug);
|
|
27
|
+
// Citadel structure
|
|
28
|
+
await fs.mkdir(path.join(citadelRoot, '.citadel'), { recursive: true });
|
|
29
|
+
await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'archetypes', 'software-engineer'), {
|
|
30
|
+
recursive: true,
|
|
31
|
+
});
|
|
32
|
+
await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'first-officer'), {
|
|
33
|
+
recursive: true,
|
|
34
|
+
});
|
|
35
|
+
// Grand archives registry
|
|
36
|
+
await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'registry.json'), JSON.stringify({
|
|
37
|
+
schema_version: '1.0',
|
|
38
|
+
archetypes: [
|
|
39
|
+
{ key: 'software-engineer', description: 'Features, bugs, tests, PRs.', status: 'active' },
|
|
40
|
+
],
|
|
41
|
+
}));
|
|
42
|
+
// First Officer config
|
|
43
|
+
await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'first-officer', 'config.json'), JSON.stringify({
|
|
44
|
+
schema_version: '1.0',
|
|
45
|
+
display_name: 'First Officer',
|
|
46
|
+
singleton: true,
|
|
47
|
+
assignable: false,
|
|
48
|
+
agent: 'claude-code',
|
|
49
|
+
system_prompt_ref: 'first-officer/system-prompt.md',
|
|
50
|
+
toolset_ref: 'first-officer/toolset.json',
|
|
51
|
+
model: 'claude-opus-4',
|
|
52
|
+
constraints: { temperature: 0.2, max_tokens_per_turn: 8192 },
|
|
53
|
+
retry_policy: {
|
|
54
|
+
max_retries: 3,
|
|
55
|
+
retry_on: ['PARTIAL', 'FAILED', 'TRIBUTE_INVALID'],
|
|
56
|
+
partial_strategy: 'remaining_criteria_only',
|
|
57
|
+
on_ceiling_reached: 'ESCALATE_TO_COMMANDER',
|
|
58
|
+
},
|
|
59
|
+
nudge_retention: 500,
|
|
60
|
+
}));
|
|
61
|
+
// Outpost registry
|
|
62
|
+
const outpostRegistry = {
|
|
63
|
+
schema_version: SCHEMA_VERSION,
|
|
64
|
+
outposts: [
|
|
65
|
+
{
|
|
66
|
+
slug: outpostSlug,
|
|
67
|
+
path: outpostPath,
|
|
68
|
+
status: 'ACTIVE',
|
|
69
|
+
registered_at: new Date().toISOString(),
|
|
70
|
+
default_archetype: 'software-engineer',
|
|
71
|
+
max_citizens: opts?.maxCitizens ?? 2,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
await fs.writeFile(path.join(citadelRoot, '.citadel', 'outposts.json'), JSON.stringify(outpostRegistry));
|
|
76
|
+
// Nudges file (empty)
|
|
77
|
+
await fs.writeFile(path.join(citadelRoot, '.citadel', 'nudges.jsonl'), '');
|
|
78
|
+
// Outpost structure
|
|
79
|
+
await fs.mkdir(path.join(outpostPath, '.beads', 'mail'), { recursive: true });
|
|
80
|
+
await fs.mkdir(path.join(outpostPath, '.beads', 'tribute'), { recursive: true });
|
|
81
|
+
await fs.mkdir(path.join(outpostPath, 'worktrees', 'citizen'), { recursive: true });
|
|
82
|
+
// Initialize git in outpost (appendBead does git add/commit)
|
|
83
|
+
await execFile('git', ['init'], { cwd: outpostPath });
|
|
84
|
+
await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: outpostPath });
|
|
85
|
+
await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: outpostPath });
|
|
86
|
+
// Initial commit so git add/commit works
|
|
87
|
+
await fs.writeFile(path.join(outpostPath, '.gitkeep'), '');
|
|
88
|
+
await execFile('git', ['add', '.'], { cwd: outpostPath });
|
|
89
|
+
await execFile('git', ['commit', '-m', 'init'], { cwd: outpostPath });
|
|
90
|
+
// Initialize git in citadel root (nudge writes to .citadel/nudges.jsonl)
|
|
91
|
+
await execFile('git', ['init'], { cwd: citadelRoot });
|
|
92
|
+
await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: citadelRoot });
|
|
93
|
+
await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: citadelRoot });
|
|
94
|
+
await execFile('git', ['add', '.'], { cwd: citadelRoot });
|
|
95
|
+
await execFile('git', ['commit', '-m', 'init'], { cwd: citadelRoot });
|
|
96
|
+
return { citadelRoot, outpostPath, outpostSlug };
|
|
97
|
+
}
|
|
98
|
+
async function setupMultiOutpostCitadel(slugs) {
|
|
99
|
+
const citadelRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'citadel-multi-'));
|
|
100
|
+
await fs.mkdir(path.join(citadelRoot, '.citadel'), { recursive: true });
|
|
101
|
+
await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'archetypes', 'software-engineer'), { recursive: true });
|
|
102
|
+
await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'first-officer'), { recursive: true });
|
|
103
|
+
await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'registry.json'), JSON.stringify({
|
|
104
|
+
schema_version: '1.0',
|
|
105
|
+
archetypes: [{ key: 'software-engineer', description: 'Features', status: 'active' }],
|
|
106
|
+
}));
|
|
107
|
+
await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'first-officer', 'config.json'), JSON.stringify({
|
|
108
|
+
schema_version: '1.0', display_name: 'First Officer', singleton: true, assignable: false,
|
|
109
|
+
agent: 'claude-code', system_prompt_ref: 'first-officer/system-prompt.md',
|
|
110
|
+
toolset_ref: 'first-officer/toolset.json', model: 'claude-opus-4',
|
|
111
|
+
constraints: { temperature: 0.2, max_tokens_per_turn: 8192 },
|
|
112
|
+
retry_policy: { max_retries: 3, retry_on: ['PARTIAL', 'FAILED', 'TRIBUTE_INVALID'], partial_strategy: 'remaining_criteria_only', on_ceiling_reached: 'ESCALATE_TO_COMMANDER' },
|
|
113
|
+
nudge_retention: 500,
|
|
114
|
+
}));
|
|
115
|
+
const outposts = [];
|
|
116
|
+
const registryEntries = [];
|
|
117
|
+
for (const slug of slugs) {
|
|
118
|
+
const outpostPath = path.join(citadelRoot, 'outposts', slug);
|
|
119
|
+
await fs.mkdir(path.join(outpostPath, '.beads', 'mail'), { recursive: true });
|
|
120
|
+
await fs.mkdir(path.join(outpostPath, '.beads', 'tribute'), { recursive: true });
|
|
121
|
+
await fs.mkdir(path.join(outpostPath, 'worktrees', 'citizen'), { recursive: true });
|
|
122
|
+
await execFile('git', ['init'], { cwd: outpostPath });
|
|
123
|
+
await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: outpostPath });
|
|
124
|
+
await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: outpostPath });
|
|
125
|
+
await fs.writeFile(path.join(outpostPath, '.gitkeep'), '');
|
|
126
|
+
await execFile('git', ['add', '.'], { cwd: outpostPath });
|
|
127
|
+
await execFile('git', ['commit', '-m', 'init'], { cwd: outpostPath });
|
|
128
|
+
outposts.push({ slug, path: outpostPath });
|
|
129
|
+
registryEntries.push({
|
|
130
|
+
slug, path: outpostPath, status: 'ACTIVE',
|
|
131
|
+
registered_at: new Date().toISOString(), default_archetype: 'software-engineer', max_citizens: 2,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const registry = { schema_version: SCHEMA_VERSION, outposts: registryEntries };
|
|
135
|
+
await fs.writeFile(path.join(citadelRoot, '.citadel', 'outposts.json'), JSON.stringify(registry));
|
|
136
|
+
await fs.writeFile(path.join(citadelRoot, '.citadel', 'nudges.jsonl'), '');
|
|
137
|
+
await execFile('git', ['init'], { cwd: citadelRoot });
|
|
138
|
+
await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: citadelRoot });
|
|
139
|
+
await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: citadelRoot });
|
|
140
|
+
await execFile('git', ['add', '.'], { cwd: citadelRoot });
|
|
141
|
+
await execFile('git', ['commit', '-m', 'init'], { cwd: citadelRoot });
|
|
142
|
+
return { citadelRoot, outposts };
|
|
143
|
+
}
|
|
144
|
+
function makeTaskDescription(title) {
|
|
145
|
+
return {
|
|
146
|
+
title,
|
|
147
|
+
description: `Implement ${title}`,
|
|
148
|
+
acceptanceCriteria: ['Tests pass', 'No regressions'],
|
|
149
|
+
priority: 'MEDIUM',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Test 1: Parallel Citizens on same Outpost
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
describe('parallel Citizens on same Outpost', () => {
|
|
156
|
+
let env;
|
|
157
|
+
let processRegistry;
|
|
158
|
+
beforeEach(async () => {
|
|
159
|
+
env = await setupTempCitadel({ maxCitizens: 2 });
|
|
160
|
+
processRegistry = new ProcessRegistry();
|
|
161
|
+
});
|
|
162
|
+
afterEach(async () => {
|
|
163
|
+
await fs.rm(env.citadelRoot, { recursive: true, force: true });
|
|
164
|
+
});
|
|
165
|
+
it('creates 2 separate mail files with unique task_id and citizen_id', async () => {
|
|
166
|
+
const mail1 = await dispatch({
|
|
167
|
+
citadelRoot: env.citadelRoot,
|
|
168
|
+
outpostSlug: env.outpostSlug,
|
|
169
|
+
task: makeTaskDescription('Feature A'),
|
|
170
|
+
processRegistry,
|
|
171
|
+
});
|
|
172
|
+
const mail2 = await dispatch({
|
|
173
|
+
citadelRoot: env.citadelRoot,
|
|
174
|
+
outpostSlug: env.outpostSlug,
|
|
175
|
+
task: makeTaskDescription('Feature B'),
|
|
176
|
+
processRegistry,
|
|
177
|
+
});
|
|
178
|
+
// Verify: 2 separate mail files created in .beads/mail/
|
|
179
|
+
const mailDir = path.join(env.outpostPath, '.beads', 'mail');
|
|
180
|
+
const files = await fs.readdir(mailDir);
|
|
181
|
+
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
182
|
+
expect(jsonlFiles).toHaveLength(2);
|
|
183
|
+
// Verify: each has unique task_id
|
|
184
|
+
expect(mail1.task_id).toBeDefined();
|
|
185
|
+
expect(mail2.task_id).toBeDefined();
|
|
186
|
+
expect(mail1.task_id).not.toBe(mail2.task_id);
|
|
187
|
+
// Verify: each has unique citizen_id in spawn_hint
|
|
188
|
+
expect(mail1.spawn_hint?.citizen_id).toBeDefined();
|
|
189
|
+
expect(mail2.spawn_hint?.citizen_id).toBeDefined();
|
|
190
|
+
expect(mail1.spawn_hint.citizen_id).not.toBe(mail2.spawn_hint.citizen_id);
|
|
191
|
+
// Verify: both are TASK_ASSIGNMENT, unread
|
|
192
|
+
expect(mail1.mail_type).toBe('TASK_ASSIGNMENT');
|
|
193
|
+
expect(mail2.mail_type).toBe('TASK_ASSIGNMENT');
|
|
194
|
+
expect(mail1.read).toBe(false);
|
|
195
|
+
expect(mail2.read).toBe(false);
|
|
196
|
+
// Read back from disk to confirm persistence
|
|
197
|
+
const beads1 = await readBeads(path.join(mailDir, `${mail1.task_id}.jsonl`));
|
|
198
|
+
const beads2 = await readBeads(path.join(mailDir, `${mail2.task_id}.jsonl`));
|
|
199
|
+
expect(beads1).toHaveLength(1);
|
|
200
|
+
expect(beads2).toHaveLength(1);
|
|
201
|
+
expect(beads1[0].bead_id).toBe(mail1.bead_id);
|
|
202
|
+
expect(beads2[0].bead_id).toBe(mail2.bead_id);
|
|
203
|
+
});
|
|
204
|
+
it('dispatches a 3rd task when ceiling is hit — mail is still queued', async () => {
|
|
205
|
+
// Dispatch 2 tasks to fill the ceiling
|
|
206
|
+
await dispatch({
|
|
207
|
+
citadelRoot: env.citadelRoot,
|
|
208
|
+
outpostSlug: env.outpostSlug,
|
|
209
|
+
task: makeTaskDescription('Task 1'),
|
|
210
|
+
processRegistry,
|
|
211
|
+
});
|
|
212
|
+
await dispatch({
|
|
213
|
+
citadelRoot: env.citadelRoot,
|
|
214
|
+
outpostSlug: env.outpostSlug,
|
|
215
|
+
task: makeTaskDescription('Task 2'),
|
|
216
|
+
processRegistry,
|
|
217
|
+
});
|
|
218
|
+
// Register fake processes so processRegistry.countActive returns 2
|
|
219
|
+
processRegistry.set(env.outpostSlug, 'citizen-fake-1', {
|
|
220
|
+
pid: process.pid,
|
|
221
|
+
spawned_at: new Date().toISOString(),
|
|
222
|
+
worktree_path: '/tmp/wt1',
|
|
223
|
+
task_id: 'task-1',
|
|
224
|
+
});
|
|
225
|
+
processRegistry.set(env.outpostSlug, 'citizen-fake-2', {
|
|
226
|
+
pid: process.pid,
|
|
227
|
+
spawned_at: new Date().toISOString(),
|
|
228
|
+
worktree_path: '/tmp/wt2',
|
|
229
|
+
task_id: 'task-2',
|
|
230
|
+
});
|
|
231
|
+
expect(processRegistry.countActive(env.outpostSlug)).toBe(2);
|
|
232
|
+
// Dispatch 3rd task — should still write mail (queued), not throw
|
|
233
|
+
const mail3 = await dispatch({
|
|
234
|
+
citadelRoot: env.citadelRoot,
|
|
235
|
+
outpostSlug: env.outpostSlug,
|
|
236
|
+
task: makeTaskDescription('Task 3'),
|
|
237
|
+
processRegistry,
|
|
238
|
+
});
|
|
239
|
+
expect(mail3.task_id).toBeDefined();
|
|
240
|
+
expect(mail3.mail_type).toBe('TASK_ASSIGNMENT');
|
|
241
|
+
// 3 mail files total
|
|
242
|
+
const mailDir = path.join(env.outpostPath, '.beads', 'mail');
|
|
243
|
+
const files = await fs.readdir(mailDir);
|
|
244
|
+
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
245
|
+
expect(jsonlFiles).toHaveLength(3);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Test 2: Ceiling hit -> slot opens -> pending dispatched
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
describe('ceiling hit and slot-open auto-dispatch', () => {
|
|
252
|
+
let env;
|
|
253
|
+
let processRegistry;
|
|
254
|
+
beforeEach(async () => {
|
|
255
|
+
env = await setupTempCitadel({ maxCitizens: 1 });
|
|
256
|
+
processRegistry = new ProcessRegistry();
|
|
257
|
+
});
|
|
258
|
+
afterEach(async () => {
|
|
259
|
+
await fs.rm(env.citadelRoot, { recursive: true, force: true });
|
|
260
|
+
});
|
|
261
|
+
it('ceiling blocks spawn, then slot-open unblocks after tribute', async () => {
|
|
262
|
+
const outpostEntry = {
|
|
263
|
+
slug: env.outpostSlug,
|
|
264
|
+
path: env.outpostPath,
|
|
265
|
+
status: 'ACTIVE',
|
|
266
|
+
registered_at: new Date().toISOString(),
|
|
267
|
+
default_archetype: 'software-engineer',
|
|
268
|
+
max_citizens: 1,
|
|
269
|
+
};
|
|
270
|
+
// Dispatch 1 task — fills the single slot
|
|
271
|
+
const mail1 = await dispatch({
|
|
272
|
+
citadelRoot: env.citadelRoot,
|
|
273
|
+
outpostSlug: env.outpostSlug,
|
|
274
|
+
task: makeTaskDescription('First task'),
|
|
275
|
+
processRegistry,
|
|
276
|
+
});
|
|
277
|
+
// Register a fake process for that citizen (simulates running citizen)
|
|
278
|
+
const citizenId1 = mail1.spawn_hint.citizen_id;
|
|
279
|
+
processRegistry.set(env.outpostSlug, citizenId1, {
|
|
280
|
+
pid: process.pid,
|
|
281
|
+
spawned_at: new Date().toISOString(),
|
|
282
|
+
worktree_path: path.join(env.outpostPath, 'worktrees', 'citizen', mail1.task_id),
|
|
283
|
+
task_id: mail1.task_id,
|
|
284
|
+
});
|
|
285
|
+
// Dispatch 2nd task — mail is written (queued), but ceiling is hit
|
|
286
|
+
const mail2 = await dispatch({
|
|
287
|
+
citadelRoot: env.citadelRoot,
|
|
288
|
+
outpostSlug: env.outpostSlug,
|
|
289
|
+
task: makeTaskDescription('Second task'),
|
|
290
|
+
processRegistry,
|
|
291
|
+
});
|
|
292
|
+
expect(mail2.task_id).toBeDefined();
|
|
293
|
+
// preSpawnCheck should report ceiling hit (1 active, max 1)
|
|
294
|
+
const checkBlocked = await preSpawnCheck(env.citadelRoot, outpostEntry, processRegistry, 1);
|
|
295
|
+
expect(checkBlocked.canSpawn).toBe(false);
|
|
296
|
+
expect(checkBlocked.reason).toMatch(/Ceiling/i);
|
|
297
|
+
expect(checkBlocked.taskMail).not.toBeNull();
|
|
298
|
+
// handleCeilingHit logs the contention (should not throw)
|
|
299
|
+
const ceilingNudge = {
|
|
300
|
+
bead_id: mintBeadId(),
|
|
301
|
+
bead_type: 'NUDGE',
|
|
302
|
+
schema_version: SCHEMA_VERSION,
|
|
303
|
+
created_at: new Date().toISOString(),
|
|
304
|
+
signal_type: 'PARALLELISM_CEILING_HIT',
|
|
305
|
+
payload: {
|
|
306
|
+
outpost: env.outpostSlug,
|
|
307
|
+
active_count: 1,
|
|
308
|
+
ceiling: 1,
|
|
309
|
+
},
|
|
310
|
+
processed: false,
|
|
311
|
+
};
|
|
312
|
+
await handleCeilingHit(ceilingNudge);
|
|
313
|
+
// Now: write a SUCCESS tribute for the first task
|
|
314
|
+
const tribute = {
|
|
315
|
+
bead_id: mintBeadId(),
|
|
316
|
+
bead_type: 'TRIBUTE',
|
|
317
|
+
schema_version: SCHEMA_VERSION,
|
|
318
|
+
created_at: new Date().toISOString(),
|
|
319
|
+
citizen_id: citizenId1,
|
|
320
|
+
task_id: mail1.task_id,
|
|
321
|
+
originating_mail_ref: mail1.bead_id,
|
|
322
|
+
archetype: 'software-engineer',
|
|
323
|
+
outpost: env.outpostSlug,
|
|
324
|
+
worktree: `worktrees/citizen/${mail1.task_id}`,
|
|
325
|
+
attempt: 1,
|
|
326
|
+
status: 'SUCCESS',
|
|
327
|
+
summary: 'All tests pass',
|
|
328
|
+
artifacts: ['src/feature.ts'],
|
|
329
|
+
acceptance_met: [true, true],
|
|
330
|
+
elapsed_seconds: 120,
|
|
331
|
+
};
|
|
332
|
+
const tributePath = path.join(env.outpostPath, '.beads', 'tribute', `${mail1.task_id}_tribute.jsonl`);
|
|
333
|
+
await appendBead(tributePath, tribute);
|
|
334
|
+
// Create TRIBUTE_WRITTEN nudge
|
|
335
|
+
const tributeNudge = {
|
|
336
|
+
bead_id: mintBeadId(),
|
|
337
|
+
bead_type: 'NUDGE',
|
|
338
|
+
schema_version: SCHEMA_VERSION,
|
|
339
|
+
created_at: new Date().toISOString(),
|
|
340
|
+
signal_type: 'TRIBUTE_WRITTEN',
|
|
341
|
+
payload: {
|
|
342
|
+
outpost: env.outpostSlug,
|
|
343
|
+
tribute_bead_id: tribute.bead_id,
|
|
344
|
+
task_id: mail1.task_id,
|
|
345
|
+
worktree: tribute.worktree,
|
|
346
|
+
},
|
|
347
|
+
processed: false,
|
|
348
|
+
};
|
|
349
|
+
// Call handleSlotOpen — should log slot opening with pending count
|
|
350
|
+
await handleSlotOpen(tributeNudge, env.citadelRoot);
|
|
351
|
+
// Remove the fake process from registry (simulating citizen exit)
|
|
352
|
+
processRegistry.delete(env.outpostSlug, citizenId1);
|
|
353
|
+
expect(processRegistry.countActive(env.outpostSlug)).toBe(0);
|
|
354
|
+
// Now preSpawnCheck should return canSpawn=true for the remaining mail
|
|
355
|
+
const checkUnblocked = await preSpawnCheck(env.citadelRoot, outpostEntry, processRegistry, 1);
|
|
356
|
+
expect(checkUnblocked.canSpawn).toBe(true);
|
|
357
|
+
expect(checkUnblocked.taskMail).not.toBeNull();
|
|
358
|
+
expect(checkUnblocked.taskMail.mail_type).toBe('TASK_ASSIGNMENT');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Test 3: Retry with inherited worktree
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
describe('retry with inherited worktree', () => {
|
|
365
|
+
let env;
|
|
366
|
+
let processRegistry;
|
|
367
|
+
const savedEnv = {};
|
|
368
|
+
beforeEach(async () => {
|
|
369
|
+
env = await setupTempCitadel({ maxCitizens: 2 });
|
|
370
|
+
processRegistry = new ProcessRegistry();
|
|
371
|
+
// Save env vars we will set
|
|
372
|
+
for (const key of [
|
|
373
|
+
'CITADEL_CITIZEN_ID',
|
|
374
|
+
'CITADEL_BEADS_ROOT',
|
|
375
|
+
'CITADEL_OUTPOST',
|
|
376
|
+
'CITADEL_TASK_ID',
|
|
377
|
+
'CITADEL_WORKTREE',
|
|
378
|
+
]) {
|
|
379
|
+
savedEnv[key] = process.env[key];
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
afterEach(async () => {
|
|
383
|
+
// Restore env vars
|
|
384
|
+
for (const [key, val] of Object.entries(savedEnv)) {
|
|
385
|
+
if (val === undefined) {
|
|
386
|
+
delete process.env[key];
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
process.env[key] = val;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
await fs.rm(env.citadelRoot, { recursive: true, force: true });
|
|
393
|
+
});
|
|
394
|
+
it('PARTIAL tribute triggers retry and bootstrap detects isRetry with errors', async () => {
|
|
395
|
+
// 1. Dispatch a task
|
|
396
|
+
const mail = await dispatch({
|
|
397
|
+
citadelRoot: env.citadelRoot,
|
|
398
|
+
outpostSlug: env.outpostSlug,
|
|
399
|
+
task: makeTaskDescription('Retry Feature'),
|
|
400
|
+
processRegistry,
|
|
401
|
+
});
|
|
402
|
+
const taskId = mail.task_id;
|
|
403
|
+
const citizenId = mail.spawn_hint.citizen_id;
|
|
404
|
+
// 2. Write a PARTIAL tribute (attempt 1, some acceptance criteria not met)
|
|
405
|
+
const tribute = {
|
|
406
|
+
bead_id: mintBeadId(),
|
|
407
|
+
bead_type: 'TRIBUTE',
|
|
408
|
+
schema_version: SCHEMA_VERSION,
|
|
409
|
+
created_at: new Date().toISOString(),
|
|
410
|
+
citizen_id: citizenId,
|
|
411
|
+
task_id: taskId,
|
|
412
|
+
originating_mail_ref: mail.bead_id,
|
|
413
|
+
archetype: 'software-engineer',
|
|
414
|
+
outpost: env.outpostSlug,
|
|
415
|
+
worktree: `worktrees/citizen/${taskId}`,
|
|
416
|
+
attempt: 1,
|
|
417
|
+
status: 'PARTIAL',
|
|
418
|
+
summary: 'Tests pass but regressions found',
|
|
419
|
+
artifacts: ['src/feature.ts'],
|
|
420
|
+
acceptance_met: [true, false], // second criterion not met
|
|
421
|
+
elapsed_seconds: 90,
|
|
422
|
+
};
|
|
423
|
+
const tributePath = path.join(env.outpostPath, '.beads', 'tribute', `${taskId}_tribute.jsonl`);
|
|
424
|
+
await appendBead(tributePath, tribute);
|
|
425
|
+
// 3. Create a TRIBUTE_WRITTEN nudge bead
|
|
426
|
+
const nudgeBead = await nudgeFirstOfficer(env.citadelRoot, 'TRIBUTE_WRITTEN', {
|
|
427
|
+
outpost: env.outpostSlug,
|
|
428
|
+
tribute_bead_id: tribute.bead_id,
|
|
429
|
+
task_id: taskId,
|
|
430
|
+
worktree: tribute.worktree,
|
|
431
|
+
});
|
|
432
|
+
// 4. Apply retry policy
|
|
433
|
+
const result = await applyRetryPolicy(env.citadelRoot, nudgeBead);
|
|
434
|
+
// 5. Verify: decision is 'retry'
|
|
435
|
+
expect(result.decision).toBe('retry');
|
|
436
|
+
expect(result.tribute.bead_id).toBe(tribute.bead_id);
|
|
437
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
438
|
+
// 6. Verify: TRIBUTE_INVALID mail was written to .beads/mail/
|
|
439
|
+
const mailDir = path.join(env.outpostPath, '.beads', 'mail');
|
|
440
|
+
const mailFiles = await fs.readdir(mailDir);
|
|
441
|
+
const invalidFiles = mailFiles.filter(f => f.startsWith('invalid-'));
|
|
442
|
+
expect(invalidFiles).toHaveLength(1);
|
|
443
|
+
const invalidBeads = await readBeads(path.join(mailDir, invalidFiles[0]));
|
|
444
|
+
expect(invalidBeads).toHaveLength(1);
|
|
445
|
+
expect(invalidBeads[0].mail_type).toBe('TRIBUTE_INVALID');
|
|
446
|
+
expect(invalidBeads[0].task_id).toBe(taskId);
|
|
447
|
+
const invalidPayload = invalidBeads[0].payload;
|
|
448
|
+
expect(invalidPayload.errors.length).toBeGreaterThan(0);
|
|
449
|
+
// 7. Verify: a retry TASK_ASSIGNMENT mail was written (has retry_of set)
|
|
450
|
+
const retryFiles = mailFiles.filter(f => f.startsWith('retry-'));
|
|
451
|
+
expect(retryFiles).toHaveLength(1);
|
|
452
|
+
const retryBeads = await readBeads(path.join(mailDir, retryFiles[0]));
|
|
453
|
+
expect(retryBeads).toHaveLength(1);
|
|
454
|
+
expect(retryBeads[0].mail_type).toBe('TASK_ASSIGNMENT');
|
|
455
|
+
expect(retryBeads[0].retry_of).toBe(mail.bead_id);
|
|
456
|
+
const retryMail = retryBeads[0];
|
|
457
|
+
const retryCitizenId = retryMail.spawn_hint.citizen_id;
|
|
458
|
+
// 8. Simulate citizen bootstrap in retry: set env vars
|
|
459
|
+
process.env.CITADEL_CITIZEN_ID = retryCitizenId;
|
|
460
|
+
process.env.CITADEL_BEADS_ROOT = env.citadelRoot;
|
|
461
|
+
process.env.CITADEL_OUTPOST = env.outpostSlug;
|
|
462
|
+
process.env.CITADEL_TASK_ID = taskId;
|
|
463
|
+
process.env.CITADEL_WORKTREE = `worktrees/citizen/${taskId}`;
|
|
464
|
+
const ctx = await citizenBootstrap();
|
|
465
|
+
// 9. Verify: bootstrap detects isRetry=true with retryErrors from TRIBUTE_INVALID
|
|
466
|
+
expect(ctx.isRetry).toBe(true);
|
|
467
|
+
expect(ctx.retryErrors.length).toBeGreaterThan(0);
|
|
468
|
+
expect(ctx.taskAssignment).not.toBeNull();
|
|
469
|
+
expect(ctx.taskAssignment.mail_type).toBe('TASK_ASSIGNMENT');
|
|
470
|
+
expect(ctx.citizenId).toBe(retryCitizenId);
|
|
471
|
+
expect(ctx.worktree).toBe(`worktrees/citizen/${taskId}`);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Test 4: 2-Outpost dependency chain with DEPENDENCY_RESOLVED broadcast
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
describe('2-Outpost dependency chain', () => {
|
|
478
|
+
let multi;
|
|
479
|
+
let processRegistry;
|
|
480
|
+
beforeEach(async () => {
|
|
481
|
+
multi = await setupMultiOutpostCitadel(['backend', 'frontend']);
|
|
482
|
+
processRegistry = new ProcessRegistry();
|
|
483
|
+
});
|
|
484
|
+
afterEach(async () => {
|
|
485
|
+
await fs.rm(multi.citadelRoot, { recursive: true, force: true });
|
|
486
|
+
});
|
|
487
|
+
it('backend SUCCESS triggers DEPENDENCY_RESOLVED to frontend', async () => {
|
|
488
|
+
const backend = multi.outposts.find(o => o.slug === 'backend');
|
|
489
|
+
const frontend = multi.outposts.find(o => o.slug === 'frontend');
|
|
490
|
+
// 1. Dispatch backend task (no dependencies)
|
|
491
|
+
const backendMail = await dispatch({
|
|
492
|
+
citadelRoot: multi.citadelRoot,
|
|
493
|
+
outpostSlug: 'backend',
|
|
494
|
+
task: {
|
|
495
|
+
title: 'Build API endpoint',
|
|
496
|
+
description: 'Create /api/users endpoint',
|
|
497
|
+
acceptanceCriteria: ['Tests pass'],
|
|
498
|
+
},
|
|
499
|
+
processRegistry,
|
|
500
|
+
});
|
|
501
|
+
const backendTaskId = backendMail.task_id;
|
|
502
|
+
// 2. Dispatch frontend task WITH dependency on backend task
|
|
503
|
+
const frontendMail = await dispatch({
|
|
504
|
+
citadelRoot: multi.citadelRoot,
|
|
505
|
+
outpostSlug: 'frontend',
|
|
506
|
+
task: {
|
|
507
|
+
title: 'Build user list UI',
|
|
508
|
+
description: 'Create user list component consuming /api/users',
|
|
509
|
+
acceptanceCriteria: ['Renders users'],
|
|
510
|
+
dependencies: [backendTaskId],
|
|
511
|
+
},
|
|
512
|
+
processRegistry,
|
|
513
|
+
});
|
|
514
|
+
const frontendTaskId = frontendMail.task_id;
|
|
515
|
+
// Verify: frontend mail has dependency on backend task
|
|
516
|
+
const frontendPayload = frontendMail.payload;
|
|
517
|
+
expect(frontendPayload.dependencies).toContain(backendTaskId);
|
|
518
|
+
// 3. Backend completes — write SUCCESS tribute
|
|
519
|
+
const backendTribute = {
|
|
520
|
+
bead_id: mintBeadId(),
|
|
521
|
+
bead_type: 'TRIBUTE',
|
|
522
|
+
schema_version: SCHEMA_VERSION,
|
|
523
|
+
created_at: new Date().toISOString(),
|
|
524
|
+
citizen_id: backendMail.spawn_hint.citizen_id,
|
|
525
|
+
task_id: backendTaskId,
|
|
526
|
+
originating_mail_ref: backendMail.bead_id,
|
|
527
|
+
archetype: 'software-engineer',
|
|
528
|
+
outpost: 'backend',
|
|
529
|
+
worktree: `worktrees/citizen/${backendTaskId}`,
|
|
530
|
+
attempt: 1,
|
|
531
|
+
status: 'SUCCESS',
|
|
532
|
+
pr_url: 'https://github.com/org/repo/pull/10',
|
|
533
|
+
summary: 'API endpoint /api/users implemented with tests',
|
|
534
|
+
artifacts: ['src/api/users.ts', 'src/api/users.test.ts'],
|
|
535
|
+
acceptance_met: [true],
|
|
536
|
+
elapsed_seconds: 180,
|
|
537
|
+
};
|
|
538
|
+
const tributePath = path.join(backend.path, '.beads', 'tribute', `${backendTaskId}_tribute.jsonl`);
|
|
539
|
+
await appendBead(tributePath, backendTribute);
|
|
540
|
+
// 4. First Officer broadcasts DEPENDENCY_RESOLVED
|
|
541
|
+
const broadcastResult = await broadcastDependencyResolved(multi.citadelRoot, backendTribute);
|
|
542
|
+
// 5. Verify: exactly 1 DEPENDENCY_RESOLVED sent to frontend
|
|
543
|
+
expect(broadcastResult.sent).toBe(1);
|
|
544
|
+
expect(broadcastResult.recipients).toHaveLength(1);
|
|
545
|
+
expect(broadcastResult.recipients[0].outpost).toBe('frontend');
|
|
546
|
+
expect(broadcastResult.recipients[0].taskId).toBe(frontendTaskId);
|
|
547
|
+
// 6. Verify: DEPENDENCY_RESOLVED mail file exists in frontend's .beads/mail/
|
|
548
|
+
const frontendMailDir = path.join(frontend.path, '.beads', 'mail');
|
|
549
|
+
const frontendFiles = await fs.readdir(frontendMailDir);
|
|
550
|
+
const depResFiles = frontendFiles.filter(f => f.startsWith('depres-'));
|
|
551
|
+
expect(depResFiles).toHaveLength(1);
|
|
552
|
+
// 7. Verify: mail contents
|
|
553
|
+
const depResMails = await readBeads(path.join(frontendMailDir, depResFiles[0]));
|
|
554
|
+
expect(depResMails).toHaveLength(1);
|
|
555
|
+
const depRes = depResMails[0];
|
|
556
|
+
expect(depRes.mail_type).toBe('DEPENDENCY_RESOLVED');
|
|
557
|
+
expect(depRes.task_id).toBe(frontendTaskId);
|
|
558
|
+
expect(depRes.to).toBe('frontend');
|
|
559
|
+
expect(depRes.from).toBe('citadel');
|
|
560
|
+
const depPayload = depRes.payload;
|
|
561
|
+
expect(depPayload.resolved_task_id).toBe(backendTaskId);
|
|
562
|
+
expect(depPayload.resolved_by_outpost).toBe('backend');
|
|
563
|
+
expect(depPayload.tribute_ref).toBe(backendTribute.bead_id);
|
|
564
|
+
expect(depPayload.pr_url).toBe('https://github.com/org/repo/pull/10');
|
|
565
|
+
expect(depPayload.summary).toBe('API endpoint /api/users implemented with tests');
|
|
566
|
+
// 8. Verify: no DEPENDENCY_RESOLVED sent to backend (it has no dependencies)
|
|
567
|
+
const backendMailDir = path.join(backend.path, '.beads', 'mail');
|
|
568
|
+
const backendFiles = await fs.readdir(backendMailDir);
|
|
569
|
+
const backendDepRes = backendFiles.filter(f => f.startsWith('depres-'));
|
|
570
|
+
expect(backendDepRes).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Test 5: Concurrent writes stress test — lockfile correctness
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
describe('concurrent writes stress test', () => {
|
|
577
|
+
let env;
|
|
578
|
+
beforeEach(async () => {
|
|
579
|
+
env = await setupTempCitadel({ maxCitizens: 10 });
|
|
580
|
+
});
|
|
581
|
+
afterEach(async () => {
|
|
582
|
+
await fs.rm(env.citadelRoot, { recursive: true, force: true });
|
|
583
|
+
});
|
|
584
|
+
it('concurrent appendBead calls to separate files — no data corruption', async () => {
|
|
585
|
+
const mailDir = path.join(env.outpostPath, '.beads', 'mail');
|
|
586
|
+
const count = 10;
|
|
587
|
+
// Fire all appends in parallel — each to a separate file (realistic scenario:
|
|
588
|
+
// multiple dispatches create separate mail files concurrently)
|
|
589
|
+
const promises = Array.from({ length: count }, (_, i) => {
|
|
590
|
+
const filePath = path.join(mailDir, `stress-${i}.jsonl`);
|
|
591
|
+
const bead = {
|
|
592
|
+
bead_id: `bead-stress-${i}`,
|
|
593
|
+
bead_type: 'MAIL',
|
|
594
|
+
schema_version: SCHEMA_VERSION,
|
|
595
|
+
created_at: new Date().toISOString(),
|
|
596
|
+
task_id: `task-stress-${i}`,
|
|
597
|
+
mail_type: 'TASK_ASSIGNMENT',
|
|
598
|
+
from: 'citadel',
|
|
599
|
+
to: env.outpostSlug,
|
|
600
|
+
subject: `Stress test ${i}`,
|
|
601
|
+
read: false,
|
|
602
|
+
retry_of: null,
|
|
603
|
+
payload: {
|
|
604
|
+
archetype: 'software-engineer',
|
|
605
|
+
citizen_id: `citizen-stress-${i}`,
|
|
606
|
+
priority: 'MEDIUM',
|
|
607
|
+
title: `Stress ${i}`,
|
|
608
|
+
description: `Concurrent write ${i}`,
|
|
609
|
+
acceptance_criteria: [],
|
|
610
|
+
context_refs: [],
|
|
611
|
+
dependencies: [],
|
|
612
|
+
timeout_seconds: 1800,
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
return appendBead(filePath, bead);
|
|
616
|
+
});
|
|
617
|
+
const results = await Promise.all(promises);
|
|
618
|
+
// All 10 should have resolved
|
|
619
|
+
expect(results).toHaveLength(count);
|
|
620
|
+
// Read back each file and verify no data corruption
|
|
621
|
+
for (let i = 0; i < count; i++) {
|
|
622
|
+
const filePath = path.join(mailDir, `stress-${i}.jsonl`);
|
|
623
|
+
const beads = await readBeads(filePath);
|
|
624
|
+
expect(beads).toHaveLength(1);
|
|
625
|
+
expect(beads[0].bead_id).toBe(`bead-stress-${i}`);
|
|
626
|
+
// Verify valid JSON (no corruption from concurrent writes)
|
|
627
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
628
|
+
const lines = raw.split('\n').filter(l => l.trim() !== '');
|
|
629
|
+
expect(lines).toHaveLength(1);
|
|
630
|
+
expect(() => JSON.parse(lines[0])).not.toThrow();
|
|
631
|
+
}
|
|
632
|
+
// Verify total file count
|
|
633
|
+
const allFiles = (await fs.readdir(mailDir)).filter(f => f.startsWith('stress-'));
|
|
634
|
+
expect(allFiles).toHaveLength(count);
|
|
635
|
+
});
|
|
636
|
+
it('sequential writes to same file maintain data integrity', async () => {
|
|
637
|
+
const filePath = path.join(env.outpostPath, '.beads', 'mail', 'seq-stress.jsonl');
|
|
638
|
+
const count = 8;
|
|
639
|
+
// Sequential writes — each waits for the prior to finish
|
|
640
|
+
for (let i = 0; i < count; i++) {
|
|
641
|
+
const bead = {
|
|
642
|
+
bead_id: `bead-seq-${i}`,
|
|
643
|
+
bead_type: 'MAIL',
|
|
644
|
+
schema_version: SCHEMA_VERSION,
|
|
645
|
+
created_at: new Date().toISOString(),
|
|
646
|
+
task_id: `task-seq-${i}`,
|
|
647
|
+
mail_type: 'TASK_ASSIGNMENT',
|
|
648
|
+
from: 'citadel',
|
|
649
|
+
to: env.outpostSlug,
|
|
650
|
+
subject: `Sequential ${i}`,
|
|
651
|
+
read: false,
|
|
652
|
+
retry_of: null,
|
|
653
|
+
payload: {
|
|
654
|
+
archetype: 'software-engineer',
|
|
655
|
+
citizen_id: `citizen-seq-${i}`,
|
|
656
|
+
priority: 'MEDIUM',
|
|
657
|
+
title: `Seq ${i}`,
|
|
658
|
+
description: `Sequential write ${i}`,
|
|
659
|
+
acceptance_criteria: [],
|
|
660
|
+
context_refs: [],
|
|
661
|
+
dependencies: [],
|
|
662
|
+
timeout_seconds: 1800,
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
await appendBead(filePath, bead);
|
|
666
|
+
}
|
|
667
|
+
// Read back
|
|
668
|
+
const beads = await readBeads(filePath);
|
|
669
|
+
expect(beads).toHaveLength(count);
|
|
670
|
+
// Verify order preserved (sequential guarantees order)
|
|
671
|
+
for (let i = 0; i < count; i++) {
|
|
672
|
+
expect(beads[i].bead_id).toBe(`bead-seq-${i}`);
|
|
673
|
+
}
|
|
674
|
+
// Verify no corruption
|
|
675
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
676
|
+
const lines = raw.split('\n').filter(l => l.trim() !== '');
|
|
677
|
+
expect(lines).toHaveLength(count);
|
|
678
|
+
for (const line of lines) {
|
|
679
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
// Test 6: Headless full cycle — no Commander attached
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
describe('headless full cycle', () => {
|
|
687
|
+
let env;
|
|
688
|
+
let processRegistry;
|
|
689
|
+
beforeEach(async () => {
|
|
690
|
+
env = await setupTempCitadel({ maxCitizens: 2 });
|
|
691
|
+
processRegistry = new ProcessRegistry();
|
|
692
|
+
// Create commands dir
|
|
693
|
+
await fs.mkdir(path.join(env.citadelRoot, '.citadel', 'commands'), { recursive: true });
|
|
694
|
+
});
|
|
695
|
+
afterEach(async () => {
|
|
696
|
+
await fs.rm(env.citadelRoot, { recursive: true, force: true });
|
|
697
|
+
});
|
|
698
|
+
it('Command Bead -> dispatch -> Tribute -> state reconstruction, all headless', async () => {
|
|
699
|
+
// 1. Write a dispatch Command Bead (simulates CLI / API, no Commander)
|
|
700
|
+
const cmdBead = await writeCommandBead(env.citadelRoot, 'dispatch', {
|
|
701
|
+
outpost: env.outpostSlug,
|
|
702
|
+
task: 'Headless feature implementation',
|
|
703
|
+
});
|
|
704
|
+
expect(cmdBead.bead_type).toBe('COMMAND');
|
|
705
|
+
expect(cmdBead.command).toBe('dispatch');
|
|
706
|
+
expect(cmdBead.processed).toBe(false);
|
|
707
|
+
// 2. FO processes the command bead — dispatches task
|
|
708
|
+
await processCommandBead(env.citadelRoot, cmdBead, processRegistry);
|
|
709
|
+
// 3. Verify: TASK_ASSIGNMENT mail exists in outpost
|
|
710
|
+
const mailDir = path.join(env.outpostPath, '.beads', 'mail');
|
|
711
|
+
const mailFiles = (await fs.readdir(mailDir)).filter(f => f.endsWith('.jsonl'));
|
|
712
|
+
expect(mailFiles.length).toBeGreaterThanOrEqual(1);
|
|
713
|
+
// Read the dispatched mail
|
|
714
|
+
const allMails = [];
|
|
715
|
+
for (const file of mailFiles) {
|
|
716
|
+
const beads = await readBeads(path.join(mailDir, file));
|
|
717
|
+
allMails.push(...beads);
|
|
718
|
+
}
|
|
719
|
+
const taskMail = allMails.find(m => m.mail_type === 'TASK_ASSIGNMENT');
|
|
720
|
+
expect(taskMail).toBeDefined();
|
|
721
|
+
const taskId = taskMail.task_id;
|
|
722
|
+
const citizenId = taskMail.spawn_hint.citizen_id;
|
|
723
|
+
// 4. Simulate citizen work — write SUCCESS tribute
|
|
724
|
+
const tribute = {
|
|
725
|
+
bead_id: mintBeadId(),
|
|
726
|
+
bead_type: 'TRIBUTE',
|
|
727
|
+
schema_version: SCHEMA_VERSION,
|
|
728
|
+
created_at: new Date().toISOString(),
|
|
729
|
+
citizen_id: citizenId,
|
|
730
|
+
task_id: taskId,
|
|
731
|
+
originating_mail_ref: taskMail.bead_id,
|
|
732
|
+
archetype: 'software-engineer',
|
|
733
|
+
outpost: env.outpostSlug,
|
|
734
|
+
worktree: `worktrees/citizen/${taskId}`,
|
|
735
|
+
attempt: 1,
|
|
736
|
+
status: 'SUCCESS',
|
|
737
|
+
summary: 'Feature implemented and tested',
|
|
738
|
+
artifacts: ['src/headless.ts'],
|
|
739
|
+
acceptance_met: [],
|
|
740
|
+
elapsed_seconds: 200,
|
|
741
|
+
};
|
|
742
|
+
const tributePath = path.join(env.outpostPath, '.beads', 'tribute', `${taskId}_tribute.jsonl`);
|
|
743
|
+
await appendBead(tributePath, tribute);
|
|
744
|
+
// 5. Validate tribute against task mail (acceptance_criteria is [] from dispatch)
|
|
745
|
+
const validation = validateTribute(tribute, taskMail);
|
|
746
|
+
expect(validation.valid).toBe(true);
|
|
747
|
+
expect(validation.errors).toHaveLength(0);
|
|
748
|
+
// 6. Reconstruct state — verify task shows as succeeded
|
|
749
|
+
const state = await reconstructState(env.citadelRoot);
|
|
750
|
+
expect(state.tasks.length).toBeGreaterThanOrEqual(1);
|
|
751
|
+
const task = state.tasks.find(t => t.taskId === taskId);
|
|
752
|
+
expect(task).toBeDefined();
|
|
753
|
+
expect(task.state).toBe('succeeded');
|
|
754
|
+
expect(task.outpost).toBe(env.outpostSlug);
|
|
755
|
+
expect(task.tributes).toHaveLength(1);
|
|
756
|
+
expect(task.latestTribute.status).toBe('SUCCESS');
|
|
757
|
+
expect(task.attempt).toBe(1);
|
|
758
|
+
// 7. Verify succeeded list
|
|
759
|
+
expect(state.succeeded.map(t => t.taskId)).toContain(taskId);
|
|
760
|
+
expect(state.inFlight.map(t => t.taskId)).not.toContain(taskId);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
//# sourceMappingURL=integration.test.js.map
|