diff --git a/CHANGELOG.md b/CHANGELOG.md index 40448c978c..5e6698730c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`archon setup` no longer writes to `/.env`.** Prior versions unconditionally wrote the generated config to both `~/.archon/.env` and `/.env`, destroying user-added secrets and silently downgrading PostgreSQL configs to SQLite when re-run in "Add" mode. The write side now targets exactly one archon-owned file (home or project scope via `--scope`), merges into existing content by default, and writes a timestamped backup. `/.env` is never touched — it belongs to the user's target project. (#1303) - **CLI and server no longer silently lose repo-local env vars.** Previously, env vars in `/.env` were parsed, deleted from `process.env` by `stripCwdEnv()`, and the only output operators saw was `[dotenv@17.3.1] injecting env (0) from .env` — which read as "file was empty." Workflows that needed `SLACK_WEBHOOK` or similar had no way to recover without knowing to use `~/.archon/.env`. The new `/.archon/.env` path + archon-owned log lines make the load state observable and recoverable. (#1302) - **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///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 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 ` 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. - **`MCP server connection failed: ` noise no longer surfaces in workflow runs.** The dag-executor now loads the workflow node's `mcp:` config file once and filters the SDK's failure message to only the servers the workflow actually configured. User-level Claude plugin MCPs (e.g. `telegram` inherited from `~/.claude/`) that fail to connect in the headless subprocess are debug-logged as `dag.mcp_plugin_connection_suppressed` instead of being forwarded to the conversation. Other provider warnings (⚠️) surface unchanged. Credits @MrFadiAi for reporting the issue in #1134 (that PR was 9 days stale and conflicting; this is a fresh re-do on current `dev`). diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 996ce99197..4c80ee3d50 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -865,6 +865,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).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })], + errors: [], + }); + (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ + id: 'conv-123', + }); + (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce(null); + (gitModule.findRepoRoot as ReturnType).mockResolvedValueOnce('/test/path'); + (registerRepository as ReturnType).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).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })], + errors: [], + }); + (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ + id: 'conv-123', + }); + (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce(null); + (gitModule.findRepoRoot as ReturnType).mockResolvedValueOnce('/test/path'); + (registerRepository as ReturnType).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).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })], + errors: [], + }); + (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ + id: 'conv-123', + }); + (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce(null); + (gitModule.findRepoRoot as ReturnType).mockResolvedValueOnce('/test/path'); + (registerRepository as ReturnType).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'); + }); + // ------------------------------------------------------------------------- // Workflow-level `worktree.enabled` policy // ------------------------------------------------------------------------- @@ -2410,3 +2518,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(); + }); +}); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 22130b556d..bdee2f5398 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -10,7 +10,8 @@ import { } from '@archon/core'; import { WORKFLOW_EVENT_TYPES, type WorkflowEventType } from '@archon/workflows/store'; import { configureIsolation, getIsolationProvider } from '@archon/isolation'; -import { createLogger } from '@archon/paths'; +import { createLogger, getArchonHome } from '@archon/paths'; +import { join } from 'node:path'; import { createWorkflowDeps } from '@archon/core/workflows/store-adapter'; import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; import { resolveWorkflowName } from '@archon/workflows/router'; @@ -77,6 +78,57 @@ function generateConversationId(): string { return `cli-${String(timestamp)}-${random}`; } +/** + * Parses the "Source symlink at X already points to Y, expected Z" error + * thrown by `createProjectSourceSymlink` in @archon/paths. Cross-package + * string contract — if that throw site changes wording, this parser silently + * stops matching. Returns the workspace dir (parent of the `source` link) so + * the caller can emit an exact cleanup path, or null if unrecognized. + */ +export function extractStaleWorkspaceEntry(message: string): string | null { + const prefix = 'Source symlink at '; + const delimiter = ' already points to '; + if (!message.startsWith(prefix)) return null; + + const remainder = message.slice(prefix.length); + const delimiterIndex = remainder.indexOf(delimiter); + if (delimiterIndex === -1) return null; + + const sourcePath = remainder.slice(0, delimiterIndex).trim(); + const lastSeparator = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\')); + return lastSeparator === -1 ? null : sourcePath.slice(0, lastSeparator); +} + +/** + * Wraps a codebase auto-registration failure for either the worktree-create or + * resume path. Preserves the original error message and delegates hint detail + * to `extractStaleWorkspaceEntry`; falls back to a workspace-root pointer when + * the error shape is unrecognized. + */ +function buildRegistrationFailureError(action: string, error: Error): Error { + const staleWorkspaceEntry = extractStaleWorkspaceEntry(error.message); + let hint: string; + if (staleWorkspaceEntry) { + hint = `Hint: Remove the stale workspace entry at ${staleWorkspaceEntry} and retry, or use --no-worktree to skip isolation.`; + } else { + // Guard against a throwing getArchonHome() (misconfigured env vars, etc.): + // the registration error we're wrapping is the load-bearing one — we'd + // rather lose the exact path in the hint than replace it with a secondary + // home-resolution error that masks the root cause. + try { + const workspacesPath = join(getArchonHome(), 'workspaces'); + hint = `Hint: Check your Archon workspace registration under ${workspacesPath} and retry, or use --no-worktree to skip isolation.`; + } catch { + hint = + 'Hint: Check your Archon workspace registration and retry, or use --no-worktree to skip isolation.'; + } + } + + return new Error( + `Cannot ${action}: repository registration failed.\nError: ${error.message}\n${hint}` + ); +} + /** Render a workflow event to stderr as a progress line. Called only when --quiet is not set. */ function renderWorkflowEvent(event: WorkflowEmitterEvent, verbose: boolean): void { switch (event.type) { @@ -316,6 +368,7 @@ export async function workflowRunCommand( // Try to find a codebase for this directory let codebase = null; let codebaseLookupError: Error | null = null; + let codebaseRegistrationError: Error | null = null; try { codebase = await codebaseDb.findCodebaseByDefaultCwd(cwd); } catch (error) { @@ -361,6 +414,7 @@ export async function workflowRunCommand( } } catch (error) { const err = error as Error; + codebaseRegistrationError = err; getLog().warn( { err, errorType: err.constructor.name, repoRoot }, 'cli.codebase_auto_registration_failed' @@ -385,6 +439,9 @@ export async function workflowRunCommand( 'Hint: Check your database connection before using --resume.' ); } + if (codebaseRegistrationError) { + throw buildRegistrationFailureError('resume', codebaseRegistrationError); + } throw new Error( 'Cannot resume: Not in a git repository.\n' + 'Either run from a git repo or use /clone first.' @@ -544,6 +601,9 @@ export async function workflowRunCommand( 'Hint: Check your database connection, or use --no-worktree to skip isolation.' ); } + if (codebaseRegistrationError) { + throw buildRegistrationFailureError('create worktree', codebaseRegistrationError); + } throw new Error( 'Cannot create worktree: not in a git repository.\n' + 'Run from within a git repo, or use --no-worktree to skip isolation.' diff --git a/packages/docs-web/src/content/docs/getting-started/overview.md b/packages/docs-web/src/content/docs/getting-started/overview.md index bee25faf28..5125b93503 100644 --- a/packages/docs-web/src/content/docs/getting-started/overview.md +++ b/packages/docs-web/src/content/docs/getting-started/overview.md @@ -482,17 +482,19 @@ The CLI is standalone, but if you also want to interact via Telegram, Slack, Dis ## Troubleshooting -### "Cannot create worktree: not in a git repository" (but the repo exists) +### "Cannot create worktree: repository registration failed" (stale workspace symlink) -The real cause is usually a stale symlink from a previous Archon run with a different path. Look for this in the error output: +This happens when `~/.archon/workspaces///source` is a symlink pointing at a previous checkout (common after moving or renaming the repo). The error message includes the exact cleanup path to follow: ``` -Source symlink at ~/.archon/workspaces/.../source already points to , expected +Cannot create worktree: repository registration failed. +Error: Source symlink at ~/.archon/workspaces///source already points to , expected +Hint: Remove the stale workspace entry at ~/.archon/workspaces// and retry, or use --no-worktree to skip isolation. ``` -Fix it by manually deleting the stale workspace folder at `~/.archon/workspaces//` and retrying the command. +Follow the hint — delete the stale workspace folder and re-run, or pass `--no-worktree` to skip isolation for one run. -> In the future, `archon isolation cleanup` will handle this automatically. +> On Archon versions before this fix, the same root cause surfaced as the misleading "Cannot create worktree: not in a git repository" (even though the repo was valid). If you see that string, upgrade and you'll get the actionable message above. ---