Skip to content
Open
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
4 changes: 3 additions & 1 deletion .archon/workflows/defaults/archon-adversarial-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ nodes:
"status": "running"
}
STATEEOF
sed -i "s/SPRINT_COUNT_PLACEHOLDER/$SPRINT_COUNT/" "$ARTIFACTS/state.json"
STATE_TMP="$ARTIFACTS/state.json.tmp"
sed "s/SPRINT_COUNT_PLACEHOLDER/$SPRINT_COUNT/" "$ARTIFACTS/state.json" > "$STATE_TMP"
mv "$STATE_TMP" "$ARTIFACTS/state.json"

echo "{\"totalSprints\": $SPRINT_COUNT, \"appDir\": \"$ARTIFACTS/app\", \"artifactsDir\": \"$ARTIFACTS\"}"
timeout: 30000
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **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.
- **Web UI approval gates now auto-resume.** Previously, clicking Approve or Reject on a paused workflow from the Web UI only recorded the decision — the workflow never continued, and the user had to send a follow-up chat message (or use the CLI) to resume. Three fixes: (1) orchestrator-agent now threads `parentConversationId` through `executeWorkflow` for every web dispatch, (2) the `POST /approve` and `POST /reject` API handlers dispatch `/workflow run <name> <userMessage>` back through the orchestrator when `parent_conversation_id` is set and points at a web-platform parent (mirrors `workflowApproveCommand`/`workflowRejectCommand` on the CLI; non-web parents skip the auto-resume to prevent cross-adapter misrouting), and (3) the during-streaming status check in the DAG executor tolerates the `paused` state so a concurrent AI node in the same topological layer finishes its own stream rather than being aborted when a sibling approval node pauses the run. The Web UI reject button uses the proper `ConfirmRunActionDialog` with an optional reason textarea (was `window.confirm` in the chat card, and lacked a reason input on the dashboard) — the trimmed reason propagates to `$REJECTION_REASON` in the workflow's `on_reject` prompt. Credits @jonasvanderhaegen for surfacing and diagnosing the bug in #1147 (that PR was 87 commits stale on a dev that had since refactored the reject UX; this is a fresh re-do on current `dev`). Closes #1131.

### Changed

Expand Down
26 changes: 13 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"bun": "^1.3.0"
},
"overrides": {
"test-exclude": "^7.0.1"
"test-exclude": "^7.0.1",
"axios": "^1.15.0"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.74"
Expand Down
156 changes: 156 additions & 0 deletions packages/cli/src/commands/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,114 @@ describe('workflowRunCommand', () => {
expect(createCallsAfter).toBe(createCallsBefore);
});

// -------------------------------------------------------------------------
// Stale workspace source-symlink → truthful CLI error
// -------------------------------------------------------------------------

it('surfaces auto-registration failures instead of claiming the repo is invalid', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const { registerRepository } = await import('@archon/core');
const conversationDb = await import('@archon/core/db/conversations');
const codebaseDb = await import('@archon/core/db/codebases');
const gitModule = await import('@archon/git');

(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
errors: [],
});
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'conv-123',
});
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
(gitModule.findRepoRoot as ReturnType<typeof mock>).mockResolvedValueOnce('/test/path');
(registerRepository as ReturnType<typeof mock>).mockRejectedValueOnce(
new Error(
'Source symlink at /home/test/.archon/workspaces/acme/widget/source already points to ' +
'/home/test/.archon/workspaces/widget, expected /test/path'
)
);

const error = await workflowRunCommand('/test/path', 'assist', 'hello', {}).catch(
err => err as Error
);

expect(error).toBeInstanceOf(Error);
expect(error.message).toContain('Cannot create worktree: repository registration failed.');
expect(error.message).toContain(
'Remove the stale workspace entry at /home/test/.archon/workspaces/acme/widget and retry'
);
expect(error.message).not.toContain('not in a git repository');
});

it('surfaces auto-registration failures on --resume instead of claiming the repo is invalid', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const { registerRepository } = await import('@archon/core');
const conversationDb = await import('@archon/core/db/conversations');
const codebaseDb = await import('@archon/core/db/codebases');
const gitModule = await import('@archon/git');

(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
errors: [],
});
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'conv-123',
});
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
(gitModule.findRepoRoot as ReturnType<typeof mock>).mockResolvedValueOnce('/test/path');
(registerRepository as ReturnType<typeof mock>).mockRejectedValueOnce(
new Error(
'Source symlink at /home/test/.archon/workspaces/acme/widget/source already points to ' +
'/home/test/.archon/workspaces/widget, expected /test/path'
)
);

const error = await workflowRunCommand('/test/path', 'assist', 'hello', {
resume: true,
}).catch(err => err as Error);

expect(error).toBeInstanceOf(Error);
expect(error.message).toContain('Cannot resume: repository registration failed.');
expect(error.message).toContain(
'Remove the stale workspace entry at /home/test/.archon/workspaces/acme/widget and retry'
);
expect(error.message).not.toContain('Not in a git repository');
});

it('falls back to generic workspace hint when registration error has an unrecognized shape', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const { registerRepository } = await import('@archon/core');
const conversationDb = await import('@archon/core/db/conversations');
const codebaseDb = await import('@archon/core/db/codebases');
const gitModule = await import('@archon/git');

(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
errors: [],
});
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'conv-123',
});
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
(gitModule.findRepoRoot as ReturnType<typeof mock>).mockResolvedValueOnce('/test/path');
(registerRepository as ReturnType<typeof mock>).mockRejectedValueOnce(
new Error("EACCES: permission denied, mkdir '/home/test/.archon/workspaces/acme'")
);

const error = await workflowRunCommand('/test/path', 'assist', 'hello', {}).catch(
err => err as Error
);

expect(error).toBeInstanceOf(Error);
expect(error.message).toContain('Cannot create worktree: repository registration failed.');
expect(error.message).toContain('EACCES: permission denied');
// Path-separator-agnostic check: on Windows path.join normalizes to `\`,
// on POSIX to `/`. Assert the hint prefix + the final segment separately.
expect(error.message).toContain('Check your Archon workspace registration under');
expect(error.message).toMatch(/workspaces\b/);
expect(error.message).not.toContain('Remove the stale workspace entry');
});

it('throws when isolation cannot be created due to missing codebase', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const conversationDb = await import('@archon/core/db/conversations');
Expand Down Expand Up @@ -2272,3 +2380,51 @@ describe('workflowRunCommand — progress rendering', () => {
expect(stderrSpy).toHaveBeenCalledWith('[slow] Completed (1m30s)\n');
});
});

// ---------------------------------------------------------------------------
// extractStaleWorkspaceEntry — parser edge cases
// ---------------------------------------------------------------------------

describe('extractStaleWorkspaceEntry', () => {
it('extracts the workspace dir from a POSIX source-symlink error', async () => {
const { extractStaleWorkspaceEntry } = await import('./workflow');
expect(
extractStaleWorkspaceEntry(
'Source symlink at /home/user/.archon/workspaces/acme/widget/source already points to /other, expected /here'
)
).toBe('/home/user/.archon/workspaces/acme/widget');
});

it('extracts the workspace dir from a Windows source-symlink error (backslash sep)', async () => {
const { extractStaleWorkspaceEntry } = await import('./workflow');
expect(
extractStaleWorkspaceEntry(
'Source symlink at C:\\Users\\me\\.archon\\workspaces\\acme\\widget\\source already points to D:\\x, expected D:\\y'
)
).toBe('C:\\Users\\me\\.archon\\workspaces\\acme\\widget');
});

it('returns null when the prefix does not match (unrelated error)', async () => {
const { extractStaleWorkspaceEntry } = await import('./workflow');
expect(extractStaleWorkspaceEntry('ENOENT: no such file or directory')).toBeNull();
});

it('returns null when the prefix matches but the delimiter is missing', async () => {
const { extractStaleWorkspaceEntry } = await import('./workflow');
expect(
extractStaleWorkspaceEntry('Source symlink at /some/path (truncated message)')
).toBeNull();
});

it('returns null when the source path has no path separator at all', async () => {
const { extractStaleWorkspaceEntry } = await import('./workflow');
expect(
extractStaleWorkspaceEntry('Source symlink at bareword already points to /x, expected /y')
).toBeNull();
});

it('returns null on an empty input', async () => {
const { extractStaleWorkspaceEntry } = await import('./workflow');
expect(extractStaleWorkspaceEntry('')).toBeNull();
});
});
Loading
Loading