From 278808793991860b656d0808c6036dc2fae17903 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Tue, 21 Apr 2026 13:46:01 +0300 Subject: [PATCH 1/4] fix(cli): surface stale-workspace registration error instead of fake "not a git repo" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When workflowRunCommand auto-registers an unregistered repo, a stale ~/.archon/workspaces///source symlink (pointing to an old checkout) causes createProjectSourceSymlink() in @archon/paths to throw: Source symlink at already points to , expected The CLI caught that in a try/catch, logged it at warn level, continued with `codebase = null`, and then the isolation / resume branches hit their "codebase missing" fallback and threw the generic: Cannot create worktree: not in a git repository. That message is false — the repo is valid; the Archon workspace entry is stale. It sends users down the wrong diagnostic path (checking git config, permissions, etc.) instead of pointing at the workspace dir. Fix: preserve the registration error on a new `codebaseRegistrationError` local, and at both fallback sites (resume + worktree-creation) check it before the generic "not a git repo" branch. When set, throw a truthful: Cannot {create worktree,resume}: repository registration failed. Error: Hint: Remove the stale workspace entry at and retry, or use --no-worktree to skip isolation. The hint's exact path comes from a small parser that extracts the workspace directory from the known "Source symlink at …" format; when the message shape doesn't match (future error text changes), the parser returns null and we fall back to a generic "check registration under /workspaces" hint — safe degradation. Regression test in workflow.test.ts asserts the new error message and negatively asserts the old "not in a git repository" string is gone. Supersedes #1157 — that PR was draft + CONFLICTING against current dev, and also mentioned Windows test-compat changes that weren't in the diff (pruned scope). This is a fresh re-do focused strictly on #1146. Closes #1146. Co-authored-by: Bortlesboat --- CHANGELOG.md | 1 + packages/cli/src/commands/workflow.test.ts | 39 +++++++++++++++ packages/cli/src/commands/workflow.ts | 57 +++++++++++++++++++++- 3 files changed, 96 insertions(+), 1 deletion(-) 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..a5c224b1f2 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -865,6 +865,45 @@ describe('workflowRunCommand', () => { expect(createCallsAfter).toBe(createCallsBefore); }); + // ------------------------------------------------------------------------- + // Stale workspace source-symlink → truthful CLI error (#1146) + // ------------------------------------------------------------------------- + + 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'); + }); + // ------------------------------------------------------------------------- // Workflow-level `worktree.enabled` policy // ------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 22130b556d..fe92b8b068 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,52 @@ function generateConversationId(): string { return `cli-${String(timestamp)}-${random}`; } +/** + * Parse the specific error format thrown by `createProjectSourceSymlink` in + * `@archon/paths` when a workspace source link already points elsewhere: + * + * Source symlink at already points to , expected + * + * Returns the workspace directory (one level above the `source` symlink, i.e. + * `.archon/workspaces///`) so the user has an exact path to clean + * up. Returns null for any message that doesn't match the known shape — the + * caller falls back to a generic hint. Tolerates both POSIX and Windows path + * separators so the hint is accurate on every platform. + */ +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); +} + +/** + * Build the truthful error the CLI surfaces when auto-registration failed and + * the subsequent worktree-creation or resume step has nothing to work with. + * + * Before this helper existed, those two sites both threw the generic + * "not in a git repository" message — false when the registration error is the + * real cause (e.g. a stale `~/.archon/workspaces/.../source` symlink from a + * prior cloned path). See #1146. + */ +function buildRegistrationFailureError(action: string, error: Error): Error { + const staleWorkspaceEntry = extractStaleWorkspaceEntry(error.message); + const hint = staleWorkspaceEntry + ? `Hint: Remove the stale workspace entry at ${staleWorkspaceEntry} and retry, or use --no-worktree to skip isolation.` + : `Hint: Check your Archon workspace registration under ${join(getArchonHome(), 'workspaces')} 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 +363,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 +409,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 +434,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 +596,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.' From 20335142bd9df1c39d41245ffc1083898e5730b7 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Wed, 22 Apr 2026 12:00:06 +0300 Subject: [PATCH 2/4] review: add resume-path test, null-fallback test, update troubleshooting docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses multi-agent review feedback on this PR: - Add regression test for the --resume fallback site (the worktree-create site was already covered; the resume site had identical wiring but zero test coverage). - Add test for the unrecognized-error-shape branch of buildRegistrationFailureError so the generic workspace hint is pinned (prevents accidental inversion of the stale-entry vs generic-hint ternary). - Update the troubleshooting page to key on the new "Cannot create worktree: repository registration failed." message. Users hitting the new error won't find the page under the old heading, and the "In the future..." note is obsolete now that the error itself contains the cleanup path. - Trim both new docblocks: keep the load-bearing cross-package error string contract in extractStaleWorkspaceEntry, drop narration of what the code already shows. Drop the "Before this helper existed..." paragraph from buildRegistrationFailureError — that's CHANGELOG material. Drop PR-reference suffix from the test section divider. --- packages/cli/src/commands/workflow.test.ts | 70 ++++++++++++++++++- packages/cli/src/commands/workflow.ts | 26 +++---- .../content/docs/getting-started/overview.md | 12 ++-- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index a5c224b1f2..7abf793eea 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -866,7 +866,7 @@ describe('workflowRunCommand', () => { }); // ------------------------------------------------------------------------- - // Stale workspace source-symlink → truthful CLI error (#1146) + // Stale workspace source-symlink → truthful CLI error // ------------------------------------------------------------------------- it('surfaces auto-registration failures instead of claiming the repo is invalid', async () => { @@ -904,6 +904,74 @@ describe('workflowRunCommand', () => { 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'); + expect(error.message).toContain( + 'Check your Archon workspace registration under /home/test/.archon/workspaces' + ); + expect(error.message).not.toContain('Remove the stale workspace entry'); + }); + // ------------------------------------------------------------------------- // Workflow-level `worktree.enabled` policy // ------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index fe92b8b068..00dc42ef86 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -79,16 +79,11 @@ function generateConversationId(): string { } /** - * Parse the specific error format thrown by `createProjectSourceSymlink` in - * `@archon/paths` when a workspace source link already points elsewhere: - * - * Source symlink at already points to , expected - * - * Returns the workspace directory (one level above the `source` symlink, i.e. - * `.archon/workspaces///`) so the user has an exact path to clean - * up. Returns null for any message that doesn't match the known shape — the - * caller falls back to a generic hint. Tolerates both POSIX and Windows path - * separators so the hint is accurate on every platform. + * 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. */ function extractStaleWorkspaceEntry(message: string): string | null { const prefix = 'Source symlink at '; @@ -105,13 +100,10 @@ function extractStaleWorkspaceEntry(message: string): string | null { } /** - * Build the truthful error the CLI surfaces when auto-registration failed and - * the subsequent worktree-creation or resume step has nothing to work with. - * - * Before this helper existed, those two sites both threw the generic - * "not in a git repository" message — false when the registration error is the - * real cause (e.g. a stale `~/.archon/workspaces/.../source` symlink from a - * prior cloned path). See #1146. + * 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); 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. --- From 28d007cff9e2440169f14ab157c2f1cf886de1e3 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Wed, 22 Apr 2026 12:05:48 +0300 Subject: [PATCH 3/4] review: guard getArchonHome in hint + export parser for direct tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up fixes to the multi-agent review commit (f32f002f): CodeRabbit finding — unguarded getArchonHome() in the fallback hint. If getArchonHome() ever throws (misconfigured env vars, permission issues on the resolution path), the registration-failure Error would never get constructed: we'd throw a secondary home-resolution error that masks the root cause. Wrap the fallback branch in try/catch — prefer losing the exact path in the hint over replacing the actionable registration error. A safe generic hint ("Check your Archon workspace registration and retry") takes over when getArchonHome() throws. The original error.message is always embedded verbatim in the re-thrown Error. S2 — export extractStaleWorkspaceEntry for direct table tests. The parser is where the cross-package string contract with @archon/paths actually lives; direct tests against it are cheaper than end-to-end CLI tests and pin the edge cases: - POSIX path with forward slashes (typical unix user) - Windows path with backslashes (verifies Math.max(lastIndexOf / , lastIndexOf \)) - Unrelated error message (no prefix) → null - Prefix matches but delimiter missing → null - Source path without any separator → null (guards against returning empty string, which would produce a nonsense "Remove the stale workspace entry at " hint) - Empty string → null Six new cases in the test file. The claim of Windows support in the PR description is now actually verified. --- packages/cli/src/commands/workflow.test.ts | 48 ++++++++++++++++++++++ packages/cli/src/commands/workflow.ts | 21 ++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 7abf793eea..ef9b0bdcb3 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -2517,3 +2517,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 00dc42ef86..bdee2f5398 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -85,7 +85,7 @@ function generateConversationId(): string { * stops matching. Returns the workspace dir (parent of the `source` link) so * the caller can emit an exact cleanup path, or null if unrecognized. */ -function extractStaleWorkspaceEntry(message: string): string | null { +export function extractStaleWorkspaceEntry(message: string): string | null { const prefix = 'Source symlink at '; const delimiter = ' already points to '; if (!message.startsWith(prefix)) return null; @@ -107,9 +107,22 @@ function extractStaleWorkspaceEntry(message: string): string | null { */ function buildRegistrationFailureError(action: string, error: Error): Error { const staleWorkspaceEntry = extractStaleWorkspaceEntry(error.message); - const hint = staleWorkspaceEntry - ? `Hint: Remove the stale workspace entry at ${staleWorkspaceEntry} and retry, or use --no-worktree to skip isolation.` - : `Hint: Check your Archon workspace registration under ${join(getArchonHome(), 'workspaces')} and retry, or use --no-worktree to skip isolation.`; + 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}` From eb1cfca79bc31fcf3255d47bd5bd2fa262191754 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Wed, 22 Apr 2026 13:00:30 +0300 Subject: [PATCH 4/4] fix(test): make generic-hint assertion path-separator agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows test runner (CI) hit: Expected to contain: "Check your Archon workspace registration under /home/test/.archon/workspaces" Received: "... under \home\test\.archon\workspaces and retry, ..." path.join normalizes to `\` on Windows and `/` on POSIX. The test hardcoded forward slashes in the expected substring. Split into two separator-agnostic asserts: the prefix up to "under", then `/workspaces\b/` regex for the final path segment. Behavior doesn't change — the hint still gets the full path.join'd workspaces dir on either platform. --- packages/cli/src/commands/workflow.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index ef9b0bdcb3..4c80ee3d50 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -966,9 +966,10 @@ describe('workflowRunCommand', () => { expect(error).toBeInstanceOf(Error); expect(error.message).toContain('Cannot create worktree: repository registration failed.'); expect(error.message).toContain('EACCES: permission denied'); - expect(error.message).toContain( - 'Check your Archon workspace registration under /home/test/.archon/workspaces' - ); + // 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'); });