Skip to content

Commit 056707d

Browse files
WirasmBortlesboat
andauthored
fix(cli): surface stale-workspace registration error instead of fake "not a git repo" (#1332)
* fix(cli): surface stale-workspace registration error instead of fake "not a git repo" When workflowRunCommand auto-registers an unregistered repo, a stale ~/.archon/workspaces/<owner>/<repo>/source symlink (pointing to an old checkout) causes createProjectSourceSymlink() in @archon/paths to throw: Source symlink at <linkPath> already points to <existing>, expected <target> 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: <original message> Hint: Remove the stale workspace entry at <dir> 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 <archon-home>/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 <Bortlesboat@users.noreply.github.com> * review: add resume-path test, null-fallback test, update troubleshooting docs 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. * review: guard getArchonHome in hint + export parser for direct tests Two follow-up fixes to the multi-agent review commit (f32f002): 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. * fix(test): make generic-hint assertion path-separator agnostic 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. --------- Co-authored-by: Bortlesboat <Bortlesboat@users.noreply.github.com>
1 parent ae2d936 commit 056707d

4 files changed

Lines changed: 225 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- **`archon setup` no longer writes to `<repo>/.env`.** Prior versions unconditionally wrote the generated config to both `~/.archon/.env` and `<repo>/.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. `<repo>/.env` is never touched — it belongs to the user's target project. (#1303)
3333
- **CLI and server no longer silently lose repo-local env vars.** Previously, env vars in `<repo>/.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 `<cwd>/.archon/.env` path + archon-owned log lines make the load state observable and recoverable. (#1302)
3434
- **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.
35+
- **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.
3536

3637
- **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.
3738
- **`MCP server connection failed: <plugin>` 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`).

packages/cli/src/commands/workflow.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,114 @@ describe('workflowRunCommand', () => {
865865
expect(createCallsAfter).toBe(createCallsBefore);
866866
});
867867

868+
// -------------------------------------------------------------------------
869+
// Stale workspace source-symlink → truthful CLI error
870+
// -------------------------------------------------------------------------
871+
872+
it('surfaces auto-registration failures instead of claiming the repo is invalid', async () => {
873+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
874+
const { registerRepository } = await import('@archon/core');
875+
const conversationDb = await import('@archon/core/db/conversations');
876+
const codebaseDb = await import('@archon/core/db/codebases');
877+
const gitModule = await import('@archon/git');
878+
879+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
880+
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
881+
errors: [],
882+
});
883+
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
884+
id: 'conv-123',
885+
});
886+
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
887+
(gitModule.findRepoRoot as ReturnType<typeof mock>).mockResolvedValueOnce('/test/path');
888+
(registerRepository as ReturnType<typeof mock>).mockRejectedValueOnce(
889+
new Error(
890+
'Source symlink at /home/test/.archon/workspaces/acme/widget/source already points to ' +
891+
'/home/test/.archon/workspaces/widget, expected /test/path'
892+
)
893+
);
894+
895+
const error = await workflowRunCommand('/test/path', 'assist', 'hello', {}).catch(
896+
err => err as Error
897+
);
898+
899+
expect(error).toBeInstanceOf(Error);
900+
expect(error.message).toContain('Cannot create worktree: repository registration failed.');
901+
expect(error.message).toContain(
902+
'Remove the stale workspace entry at /home/test/.archon/workspaces/acme/widget and retry'
903+
);
904+
expect(error.message).not.toContain('not in a git repository');
905+
});
906+
907+
it('surfaces auto-registration failures on --resume instead of claiming the repo is invalid', async () => {
908+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
909+
const { registerRepository } = await import('@archon/core');
910+
const conversationDb = await import('@archon/core/db/conversations');
911+
const codebaseDb = await import('@archon/core/db/codebases');
912+
const gitModule = await import('@archon/git');
913+
914+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
915+
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
916+
errors: [],
917+
});
918+
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
919+
id: 'conv-123',
920+
});
921+
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
922+
(gitModule.findRepoRoot as ReturnType<typeof mock>).mockResolvedValueOnce('/test/path');
923+
(registerRepository as ReturnType<typeof mock>).mockRejectedValueOnce(
924+
new Error(
925+
'Source symlink at /home/test/.archon/workspaces/acme/widget/source already points to ' +
926+
'/home/test/.archon/workspaces/widget, expected /test/path'
927+
)
928+
);
929+
930+
const error = await workflowRunCommand('/test/path', 'assist', 'hello', {
931+
resume: true,
932+
}).catch(err => err as Error);
933+
934+
expect(error).toBeInstanceOf(Error);
935+
expect(error.message).toContain('Cannot resume: repository registration failed.');
936+
expect(error.message).toContain(
937+
'Remove the stale workspace entry at /home/test/.archon/workspaces/acme/widget and retry'
938+
);
939+
expect(error.message).not.toContain('Not in a git repository');
940+
});
941+
942+
it('falls back to generic workspace hint when registration error has an unrecognized shape', async () => {
943+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
944+
const { registerRepository } = await import('@archon/core');
945+
const conversationDb = await import('@archon/core/db/conversations');
946+
const codebaseDb = await import('@archon/core/db/codebases');
947+
const gitModule = await import('@archon/git');
948+
949+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
950+
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
951+
errors: [],
952+
});
953+
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
954+
id: 'conv-123',
955+
});
956+
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
957+
(gitModule.findRepoRoot as ReturnType<typeof mock>).mockResolvedValueOnce('/test/path');
958+
(registerRepository as ReturnType<typeof mock>).mockRejectedValueOnce(
959+
new Error("EACCES: permission denied, mkdir '/home/test/.archon/workspaces/acme'")
960+
);
961+
962+
const error = await workflowRunCommand('/test/path', 'assist', 'hello', {}).catch(
963+
err => err as Error
964+
);
965+
966+
expect(error).toBeInstanceOf(Error);
967+
expect(error.message).toContain('Cannot create worktree: repository registration failed.');
968+
expect(error.message).toContain('EACCES: permission denied');
969+
// Path-separator-agnostic check: on Windows path.join normalizes to `\`,
970+
// on POSIX to `/`. Assert the hint prefix + the final segment separately.
971+
expect(error.message).toContain('Check your Archon workspace registration under');
972+
expect(error.message).toMatch(/workspaces\b/);
973+
expect(error.message).not.toContain('Remove the stale workspace entry');
974+
});
975+
868976
// -------------------------------------------------------------------------
869977
// Workflow-level `worktree.enabled` policy
870978
// -------------------------------------------------------------------------
@@ -2410,3 +2518,51 @@ describe('workflowRunCommand — progress rendering', () => {
24102518
expect(stderrSpy).toHaveBeenCalledWith('[slow] Completed (1m30s)\n');
24112519
});
24122520
});
2521+
2522+
// ---------------------------------------------------------------------------
2523+
// extractStaleWorkspaceEntry — parser edge cases
2524+
// ---------------------------------------------------------------------------
2525+
2526+
describe('extractStaleWorkspaceEntry', () => {
2527+
it('extracts the workspace dir from a POSIX source-symlink error', async () => {
2528+
const { extractStaleWorkspaceEntry } = await import('./workflow');
2529+
expect(
2530+
extractStaleWorkspaceEntry(
2531+
'Source symlink at /home/user/.archon/workspaces/acme/widget/source already points to /other, expected /here'
2532+
)
2533+
).toBe('/home/user/.archon/workspaces/acme/widget');
2534+
});
2535+
2536+
it('extracts the workspace dir from a Windows source-symlink error (backslash sep)', async () => {
2537+
const { extractStaleWorkspaceEntry } = await import('./workflow');
2538+
expect(
2539+
extractStaleWorkspaceEntry(
2540+
'Source symlink at C:\\Users\\me\\.archon\\workspaces\\acme\\widget\\source already points to D:\\x, expected D:\\y'
2541+
)
2542+
).toBe('C:\\Users\\me\\.archon\\workspaces\\acme\\widget');
2543+
});
2544+
2545+
it('returns null when the prefix does not match (unrelated error)', async () => {
2546+
const { extractStaleWorkspaceEntry } = await import('./workflow');
2547+
expect(extractStaleWorkspaceEntry('ENOENT: no such file or directory')).toBeNull();
2548+
});
2549+
2550+
it('returns null when the prefix matches but the delimiter is missing', async () => {
2551+
const { extractStaleWorkspaceEntry } = await import('./workflow');
2552+
expect(
2553+
extractStaleWorkspaceEntry('Source symlink at /some/path (truncated message)')
2554+
).toBeNull();
2555+
});
2556+
2557+
it('returns null when the source path has no path separator at all', async () => {
2558+
const { extractStaleWorkspaceEntry } = await import('./workflow');
2559+
expect(
2560+
extractStaleWorkspaceEntry('Source symlink at bareword already points to /x, expected /y')
2561+
).toBeNull();
2562+
});
2563+
2564+
it('returns null on an empty input', async () => {
2565+
const { extractStaleWorkspaceEntry } = await import('./workflow');
2566+
expect(extractStaleWorkspaceEntry('')).toBeNull();
2567+
});
2568+
});

packages/cli/src/commands/workflow.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
} from '@archon/core';
1111
import { WORKFLOW_EVENT_TYPES, type WorkflowEventType } from '@archon/workflows/store';
1212
import { configureIsolation, getIsolationProvider } from '@archon/isolation';
13-
import { createLogger } from '@archon/paths';
13+
import { createLogger, getArchonHome } from '@archon/paths';
14+
import { join } from 'node:path';
1415
import { createWorkflowDeps } from '@archon/core/workflows/store-adapter';
1516
import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery';
1617
import { resolveWorkflowName } from '@archon/workflows/router';
@@ -77,6 +78,57 @@ function generateConversationId(): string {
7778
return `cli-${String(timestamp)}-${random}`;
7879
}
7980

81+
/**
82+
* Parses the "Source symlink at X already points to Y, expected Z" error
83+
* thrown by `createProjectSourceSymlink` in @archon/paths. Cross-package
84+
* string contract — if that throw site changes wording, this parser silently
85+
* stops matching. Returns the workspace dir (parent of the `source` link) so
86+
* the caller can emit an exact cleanup path, or null if unrecognized.
87+
*/
88+
export function extractStaleWorkspaceEntry(message: string): string | null {
89+
const prefix = 'Source symlink at ';
90+
const delimiter = ' already points to ';
91+
if (!message.startsWith(prefix)) return null;
92+
93+
const remainder = message.slice(prefix.length);
94+
const delimiterIndex = remainder.indexOf(delimiter);
95+
if (delimiterIndex === -1) return null;
96+
97+
const sourcePath = remainder.slice(0, delimiterIndex).trim();
98+
const lastSeparator = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\'));
99+
return lastSeparator === -1 ? null : sourcePath.slice(0, lastSeparator);
100+
}
101+
102+
/**
103+
* Wraps a codebase auto-registration failure for either the worktree-create or
104+
* resume path. Preserves the original error message and delegates hint detail
105+
* to `extractStaleWorkspaceEntry`; falls back to a workspace-root pointer when
106+
* the error shape is unrecognized.
107+
*/
108+
function buildRegistrationFailureError(action: string, error: Error): Error {
109+
const staleWorkspaceEntry = extractStaleWorkspaceEntry(error.message);
110+
let hint: string;
111+
if (staleWorkspaceEntry) {
112+
hint = `Hint: Remove the stale workspace entry at ${staleWorkspaceEntry} and retry, or use --no-worktree to skip isolation.`;
113+
} else {
114+
// Guard against a throwing getArchonHome() (misconfigured env vars, etc.):
115+
// the registration error we're wrapping is the load-bearing one — we'd
116+
// rather lose the exact path in the hint than replace it with a secondary
117+
// home-resolution error that masks the root cause.
118+
try {
119+
const workspacesPath = join(getArchonHome(), 'workspaces');
120+
hint = `Hint: Check your Archon workspace registration under ${workspacesPath} and retry, or use --no-worktree to skip isolation.`;
121+
} catch {
122+
hint =
123+
'Hint: Check your Archon workspace registration and retry, or use --no-worktree to skip isolation.';
124+
}
125+
}
126+
127+
return new Error(
128+
`Cannot ${action}: repository registration failed.\nError: ${error.message}\n${hint}`
129+
);
130+
}
131+
80132
/** Render a workflow event to stderr as a progress line. Called only when --quiet is not set. */
81133
function renderWorkflowEvent(event: WorkflowEmitterEvent, verbose: boolean): void {
82134
switch (event.type) {
@@ -316,6 +368,7 @@ export async function workflowRunCommand(
316368
// Try to find a codebase for this directory
317369
let codebase = null;
318370
let codebaseLookupError: Error | null = null;
371+
let codebaseRegistrationError: Error | null = null;
319372
try {
320373
codebase = await codebaseDb.findCodebaseByDefaultCwd(cwd);
321374
} catch (error) {
@@ -361,6 +414,7 @@ export async function workflowRunCommand(
361414
}
362415
} catch (error) {
363416
const err = error as Error;
417+
codebaseRegistrationError = err;
364418
getLog().warn(
365419
{ err, errorType: err.constructor.name, repoRoot },
366420
'cli.codebase_auto_registration_failed'
@@ -385,6 +439,9 @@ export async function workflowRunCommand(
385439
'Hint: Check your database connection before using --resume.'
386440
);
387441
}
442+
if (codebaseRegistrationError) {
443+
throw buildRegistrationFailureError('resume', codebaseRegistrationError);
444+
}
388445
throw new Error(
389446
'Cannot resume: Not in a git repository.\n' +
390447
'Either run from a git repo or use /clone first.'
@@ -544,6 +601,9 @@ export async function workflowRunCommand(
544601
'Hint: Check your database connection, or use --no-worktree to skip isolation.'
545602
);
546603
}
604+
if (codebaseRegistrationError) {
605+
throw buildRegistrationFailureError('create worktree', codebaseRegistrationError);
606+
}
547607
throw new Error(
548608
'Cannot create worktree: not in a git repository.\n' +
549609
'Run from within a git repo, or use --no-worktree to skip isolation.'

packages/docs-web/src/content/docs/getting-started/overview.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,17 +482,19 @@ The CLI is standalone, but if you also want to interact via Telegram, Slack, Dis
482482

483483
## Troubleshooting
484484

485-
### "Cannot create worktree: not in a git repository" (but the repo exists)
485+
### "Cannot create worktree: repository registration failed" (stale workspace symlink)
486486

487-
The real cause is usually a stale symlink from a previous Archon run with a different path. Look for this in the error output:
487+
This happens when `~/.archon/workspaces/<owner>/<repo>/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:
488488

489489
```
490-
Source symlink at ~/.archon/workspaces/.../source already points to <old-path>, expected <new-path>
490+
Cannot create worktree: repository registration failed.
491+
Error: Source symlink at ~/.archon/workspaces/<owner>/<repo>/source already points to <old-path>, expected <new-path>
492+
Hint: Remove the stale workspace entry at ~/.archon/workspaces/<owner>/<repo> and retry, or use --no-worktree to skip isolation.
491493
```
492494

493-
Fix it by manually deleting the stale workspace folder at `~/.archon/workspaces/<github-user>/<repo-name>` and retrying the command.
495+
Follow the hint — delete the stale workspace folder and re-run, or pass `--no-worktree` to skip isolation for one run.
494496

495-
> In the future, `archon isolation cleanup` will handle this automatically.
497+
> 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.
496498
497499
---
498500

0 commit comments

Comments
 (0)