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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ GITEA_ALLOWED_USERS=
# GITEA_BOT_MENTION=archon

# Server
PORT=3000
# PORT=3090 # Default: 3090. Uncomment to override — must match between server and Vite proxy.
# HOST=0.0.0.0 # Bind address (default: 0.0.0.0). Set to 127.0.0.1 to restrict to localhost only.

# Cloud Deployment (for --profile cloud with Caddy reverse proxy)
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Cherry-pick batch 2 from upstream (10 commits).** Selective Tier 1 picks from the upstream delta:
- `0ec74410` — Bumped `hono` to `^4.12.16` and added `@hono/node-server` `^1.19.13` override (closes upstream #1484).
- `0afbeb30` — Bumped `@anthropic-ai/claude-agent-sdk` to `0.2.121` and `@openai/codex-sdk` to `0.125.0`. Pi packages skipped (fork doesn't use Pi).
- `cbcca8c1` — Orchestrator clears stale session ID on `error_during_execution` instead of persisting the failed session ID, preventing infinite failure loops after Claude session expiry (closes upstream #1280).
- `0c5d7b12` — Orchestrator now creates `~/.archon/workspaces` before AI provider spawn so fresh-install ENOENT no longer surfaces as an incorrect "Claude binary not found" error.
- `45682bd2` — Claude provider's `hasExplicitTokens` uses `||` instead of `??` so empty-string env vars are treated as missing (upstream #1028).
- `4885ee64` — `CLAUDE_BIN_PATH` is now honored in dev mode (relevant for libc-mismatch hosts; upstream #1481).
- `ff901115` — Claude provider stops passing `--no-env-file` to the native binary in dev mode (the flag is Bun-only; upstream #1461).
- `7d067738` — CLI lazy-imports bundled skill files so non-setup commands don't crash on missing source (upstream #1394).
- `d89bc767` — Aligned PORT default to `3090` across `.env.example`, setup wizard, and JSDoc (upstream #1271).
- `301a139e` — Split `connection.test.ts` into its own batch in `@archon/core` test script to avoid mock pollution that caused `getDatabaseType()` tests to see leaked `DATABASE_URL` (upstream #1269). Also reapplied to preserve fork-only batches (`workflow-analytics`, `cron-parser`, `knowledge-writer`).

- **Bumped transitive `axios` to `^1.15.0` via root `overrides` to clear CVE-2025-62718** (NO_PROXY bypass via hostname normalization → potential SSRF). Archon pulls `axios` transitively through `@slack/bolt` and `@slack/web-api`; both semver ranges (`^1.12.0` and `^1.13.5`) accept the override cleanly, so no API surface changes. Credits @stefans71 for identifying and reporting the vulnerability in #1153. Closes #1053.
- **Stale workspace symlink no longer reported as "not in a git repository" by the CLI.** When `archon workflow run` (or `--resume`) is invoked from a valid git repo whose `~/.archon/workspaces/<owner>/<repo>/source` symlink points somewhere else (common after moving/renaming the checkout), auto-registration fails but the repo is fine. Previously both the worktree-creation and resume paths fell through to the generic `Cannot create worktree: not in a git repository` / `Cannot resume: Not in a git repository` errors — a lie that sent users down the wrong diagnostic path. Both sites now preserve the registration error and throw `Cannot {create worktree,resume}: repository registration failed.` with the original cause and a concrete cleanup hint (`Remove the stale workspace entry at <path> and retry`) when the failure matches the `createProjectSourceSymlink()` shape. Credits @Bortlesboat for identifying the root cause and the parser approach in #1157. Closes #1146.
- **Server startup no longer marks actively-running workflows as failed.** The `failOrphanedRuns()` call has been removed from `packages/server/src/index.ts` to match the CLI precedent (`packages/cli/src/cli.ts:256-258`). Per the new CLAUDE.md principle "No Autonomous Lifecycle Mutation Across Process Boundaries", a stuck `running` row is now transitioned explicitly by the user: via the per-row Cancel/Abandon buttons on the dashboard workflow card, or `archon workflow abandon <run-id>` from the CLI. (`archon workflow cleanup` is a separate command that deletes OLD terminal runs for disk hygiene — it does not handle stuck `running` rows.) Closes #1216.
Expand Down
135 changes: 75 additions & 60 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ TELEGRAM_BOT_TOKEN=123456789:ABC...
# ============================================
# Optional
# ============================================
PORT=3000
PORT=3000 # Docker deployment default (the included compose/Caddy configs target :3000). For local dev (no Docker), omit PORT — server and Vite proxy both default to 3090.
# TELEGRAM_STREAMING_MODE=stream
# DISCORD_STREAMING_MODE=batch

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@
},
"overrides": {
"test-exclude": "^7.0.1",
"axios": "^1.15.0"
"axios": "^1.15.0",
"@hono/node-server": "^1.19.13"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.74"
"@anthropic-ai/claude-agent-sdk": "^0.2.121"
}
}
20 changes: 11 additions & 9 deletions packages/cli/src/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ CODEX_ACCOUNT_ID=account1
expect(content).toContain('# Using SQLite (default)');
expect(content).toContain('CLAUDE_USE_GLOBAL_AUTH=true');
expect(content).toContain('DEFAULT_AI_ASSISTANT=claude');
expect(content).toContain('PORT=3000');
// PORT is intentionally commented out — server and Vite both default to 3090 when unset (#1152).
expect(content).toContain('# PORT=3090');
expect(content).not.toMatch(/^PORT=/m);
expect(content).not.toContain('DATABASE_URL=');
});

Expand Down Expand Up @@ -401,11 +403,11 @@ CODEX_ACCOUNT_ID=account1
});

describe('copyArchonSkill', () => {
it('should create skill files in target directory', () => {
it('should create skill files in target directory', async () => {
const target = join(TEST_DIR, 'skill-target');
mkdirSync(target, { recursive: true });

copyArchonSkill(target);
await copyArchonSkill(target);

expect(existsSync(join(target, '.claude', 'skills', 'archon', 'SKILL.md'))).toBe(true);
expect(existsSync(join(target, '.claude', 'skills', 'archon', 'guides', 'setup.md'))).toBe(
Expand All @@ -419,11 +421,11 @@ CODEX_ACCOUNT_ID=account1
).toBe(true);
});

it('should write non-empty content to skill files', () => {
it('should write non-empty content to skill files', async () => {
const target = join(TEST_DIR, 'skill-target-content');
mkdirSync(target, { recursive: true });

copyArchonSkill(target);
await copyArchonSkill(target);

const content = readFileSync(
join(target, '.claude', 'skills', 'archon', 'SKILL.md'),
Expand All @@ -433,23 +435,23 @@ CODEX_ACCOUNT_ID=account1
expect(content).toContain('archon');
});

it('should overwrite existing skill files', () => {
it('should overwrite existing skill files', async () => {
const target = join(TEST_DIR, 'skill-target-overwrite');
const skillDir = join(target, '.claude', 'skills', 'archon');
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), 'old content');

copyArchonSkill(target);
await copyArchonSkill(target);

const content = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8');
expect(content).not.toBe('old content');
});

it('should create skill files even when target directory does not exist', () => {
it('should create skill files even when target directory does not exist', async () => {
const target = join(TEST_DIR, 'non-existent-parent', 'skill-target-new');
// Do NOT pre-create target — copyArchonSkill must handle it

copyArchonSkill(target);
await copyArchonSkill(target);

expect(existsSync(join(target, '.claude', 'skills', 'archon', 'SKILL.md'))).toBe(true);
});
Expand Down
23 changes: 18 additions & 5 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
} from '@clack/prompts';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { BUNDLED_SKILL_FILES } from '../bundled-skill';
import { homedir } from 'os';
import { randomBytes } from 'crypto';
import { spawn, execSync, type ChildProcess } from 'child_process';
Expand Down Expand Up @@ -1290,8 +1289,12 @@ export function generateEnvContent(config: SetupConfig): string {
}

// Server
// PORT is intentionally omitted: both the Hono server (packages/core/src/utils/port-allocation.ts)
// and the Vite dev proxy (packages/web/vite.config.ts) default to 3090 when unset, which keeps
// them in sync. Writing a fixed PORT here risked a mismatch if ~/.archon/.env leaks a PORT that
// the Vite proxy (which only reads repo-local .env) never sees — see #1152.
lines.push('# Server');
lines.push('PORT=3000');
lines.push('# PORT=3090 # Default: 3090. Uncomment to override.');
lines.push('');

// Concurrency
Expand Down Expand Up @@ -1330,8 +1333,18 @@ function writeEnvFiles(
* Copy the bundled Archon skill files to <targetPath>/.claude/skills/archon/
*
* Always overwrites existing files to ensure the latest skill version is installed.
*
* The `bundled-skill` module is dynamically imported here so that its 18 top-level
* `import … with { type: 'text' }` statements only execute when this function is
* actually called. Compiled binaries (`bun build --compile`) still statically
* analyze the literal-string `import()` and embed the chunk; linked-source
* installs (`bun link`) don't touch the source skill files unless the user runs
* `archon setup`. Without this indirection, every `archon` invocation —
* including `archon --help` — fails at module load when the source skill files
* are missing from disk.
*/
export function copyArchonSkill(targetPath: string): void {
export async function copyArchonSkill(targetPath: string): Promise<void> {
const { BUNDLED_SKILL_FILES } = await import('../bundled-skill');
const skillRoot = join(targetPath, '.claude', 'skills', 'archon');
for (const [relativePath, content] of Object.entries(BUNDLED_SKILL_FILES)) {
const dest = join(skillRoot, relativePath);
Expand Down Expand Up @@ -1675,7 +1688,7 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
const skillTarget = skillTargetRaw;
s.start('Installing Archon skill...');
try {
copyArchonSkill(skillTarget);
await copyArchonSkill(skillTarget);
} catch (err) {
s.stop('Archon skill installation failed');
cancel(`Could not install skill: ${(err as NodeJS.ErrnoException).message}`);
Expand Down Expand Up @@ -1769,7 +1782,7 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
// Additional options note
note(
'Other settings you can customize in ~/.archon/.env:\n' +
' - PORT (default: 3000)\n' +
' - PORT (default: 3090)\n' +
' - MAX_CONCURRENT_CONVERSATIONS (default: 10)\n' +
' - *_STREAMING_MODE (stream | batch per platform)\n\n' +
'These defaults work well for most users.',
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"./state/*": "./src/state/*.ts"
},
"scripts": {
"test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/db/workflow-analytics.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/services/cron-parser.test.ts && bun test src/services/knowledge-writer.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
"test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/db/workflow-analytics.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/services/cron-parser.test.ts && bun test src/services/knowledge-writer.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
"type-check": "bun x tsc --noEmit",
"build": "echo 'No build needed - Bun runs TypeScript directly'"
},
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/db/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,18 @@ describe('sessions', () => {
);
});

test('throws SessionNotFoundError when session does not exist', async () => {
test('sets assistant_session_id to NULL when called with null', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([], 1));

await updateSession('session-123', null);

expect(mockQuery).toHaveBeenCalledWith(
'UPDATE remote_agent_sessions SET assistant_session_id = $1 WHERE id = $2',
[null, 'session-123']
);
});

test('throws SessionNotFoundError when session does not exist (updateSession)', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([], 0)); // rowCount = 0

const error = await updateSession('non-existent', 'new-session-id').catch(e => e);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/db/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function createSession(data: {
return result.rows[0];
}

export async function updateSession(id: string, sessionId: string): Promise<void> {
export async function updateSession(id: string, sessionId: string | null): Promise<void> {
const result = await pool.query(
'UPDATE remote_agent_sessions SET assistant_session_id = $1 WHERE id = $2',
[sessionId, id]
Expand Down
84 changes: 82 additions & 2 deletions packages/core/src/orchestrator/orchestrator-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ const mockLoadConfig = mock(() =>

const mockLogger = createMockLogger();

const mockEnsureArchonWorkspacesPath = mock(() => Promise.resolve('/home/test/.archon/workspaces'));
mock.module('@archon/paths', () => ({
createLogger: mock(() => mockLogger),
getArchonWorkspacesPath: mock(() => '/home/test/.archon/workspaces'),
ensureArchonWorkspacesPath: mockEnsureArchonWorkspacesPath,
getArchonHome: mock(() => '/home/test/.archon'),
}));

Expand All @@ -72,10 +74,14 @@ mock.module('../db/codebases', () => ({
createCodebase: mock(() => Promise.resolve({ id: 'new-codebase-id' })),
}));

const mockUpdateSession = mock(() => Promise.resolve());
const mockTransitionSession = mock(() =>
Promise.resolve({ id: 'session-1', assistant_session_id: null })
);
mock.module('../db/sessions', () => ({
getActiveSession: mock(() => Promise.resolve(null)),
updateSession: mock(() => Promise.resolve()),
transitionSession: mock(() => Promise.resolve({ id: 'session-1', assistant_session_id: null })),
updateSession: mockUpdateSession,
transitionSession: mockTransitionSession,
}));

const mockParseCommand = mock(
Expand Down Expand Up @@ -902,6 +908,7 @@ describe('discoverAllWorkflows — remote sync', () => {
mockSendQuery.mockClear();
mockGetCodebaseEnvVars.mockReset();
mockLoadConfig.mockReset();
mockEnsureArchonWorkspacesPath.mockClear();
// Reset mocks between tests in this suite and restore safe defaults
mockGetOrCreateConversation.mockImplementation(() => Promise.resolve(null));
mockGetCodebase.mockImplementation(() => Promise.resolve(null));
Expand All @@ -927,6 +934,9 @@ describe('discoverAllWorkflows — remote sync', () => {
expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined, {
resetAfterFetch: false,
});
// Regression guard: orchestrator must resolve cwd through the ensure variant
// so the workspaces dir is created before the AI provider spawn (issue #1528).
expect(mockEnsureArchonWorkspacesPath).toHaveBeenCalled();
});

test('passes resetAfterFetch=true for managed clones', async () => {
Expand Down Expand Up @@ -1602,3 +1612,73 @@ describe('handleMessage — workflow context injection', () => {
await expect(handleMessage(platform, 'conv-1', 'Hello')).resolves.toBeUndefined();
});
});

// ─── Stale session ID clearing on error_during_execution ────────────────────

describe('stale session ID clearing on error_during_execution', () => {
beforeEach(() => {
mockUpdateSession.mockClear();
mockTransitionSession.mockClear();
mockGetOrCreateConversation.mockReset();
mockGetCodebase.mockReset();
mockSendQuery.mockReset();
mockLogger.warn.mockClear();
mockGetRecentWorkflowResultMessages.mockReset();
mockGetRecentWorkflowResultMessages.mockImplementation(() => Promise.resolve([]));
mockDiscoverWorkflowsWithConfig.mockReset();
mockDiscoverWorkflowsWithConfig.mockImplementation(() =>
Promise.resolve({ workflows: [], errors: [] })
);
mockGetOrCreateConversation.mockImplementation(() => Promise.resolve(makeConversation()));
mockGetCodebase.mockImplementation(() => Promise.resolve(null));
mockListCodebases.mockReset();
mockListCodebases.mockImplementation(() => Promise.resolve([]));
});

test('handleStreamMode: clears session ID on error_during_execution result', async () => {
// Simulate AI returning error_during_execution with a stale session ID
mockSendQuery.mockImplementationOnce(async function* () {
yield {
type: 'result',
isError: true,
errorSubtype: 'error_during_execution',
sessionId: 'stale-session-id',
};
});
// transitionSession returns a session with an existing assistant_session_id
mockTransitionSession.mockResolvedValueOnce({
id: 'session-1',
assistant_session_id: 'stale-session-id',
});

const platform = makePlatform();
// Use streaming mode
(platform.getStreamingMode as ReturnType<typeof mock>).mockReturnValue('stream');
await handleMessage(platform, 'conv-1', 'hello');

// updateSession should be called with null to clear the stale session ID
expect(mockUpdateSession).toHaveBeenCalledWith('session-1', null);
});

test('handleBatchMode: clears session ID on error_during_execution result', async () => {
mockSendQuery.mockImplementationOnce(async function* () {
yield {
type: 'result',
isError: true,
errorSubtype: 'error_during_execution',
sessionId: 'stale-session-id',
};
});
mockTransitionSession.mockResolvedValueOnce({
id: 'session-1',
assistant_session_id: 'stale-session-id',
});

const platform = makePlatform();
// batch is the default from makePlatform, but be explicit
(platform.getStreamingMode as ReturnType<typeof mock>).mockReturnValue('batch');
await handleMessage(platform, 'conv-1', 'hello');

expect(mockUpdateSession).toHaveBeenCalledWith('session-1', null);
});
});
Loading
Loading