Skip to content

Commit 838f1fc

Browse files
committed
Fix: workflow approve/resume discovery for worktree runs (#1663)
When a workflow paused at an approval gate is resumed via `workflow approve` or `workflow resume`, the CLI re-invoked `workflowRunCommand` with `run.working_path` as the discovery cwd. If `working_path` is a worktree or workspace clone that does not contain the user's local (often untracked) workflow YAML, discovery failed with "Workflow 'foo' not found" before execution could begin. Separate the discovery path from the execution path by adding an optional `discoveryCwd` to `WorkflowRunOptions`. Resume, approve, and reject now look up the codebase and pass `codebase.default_cwd` as `discoveryCwd`, so the source repo is searched even when `working_path` lives elsewhere. The execution cwd and the existing `findResumableRun` keying are unchanged. Changes: - Add `WorkflowRunOptions.discoveryCwd`; use it for `loadWorkflows` in `workflowRunCommand` - `workflowResumeCommand`, `workflowApproveCommand`, and `workflowRejectCommand` resolve `codebase.default_cwd` (with graceful fallback) and pass it through - Tests covering discovery from `codebase.default_cwd` and fallback to `working_path` when no codebase is available Fixes #1663
1 parent aa71520 commit 838f1fc

2 files changed

Lines changed: 177 additions & 3 deletions

File tree

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

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,12 +1850,81 @@ describe('workflowResumeCommand', () => {
18501850
// downstream failure is acceptable
18511851
}
18521852

1853-
// Verify warn was called (not error — it's a soft fallback)
1853+
// Verify warn was called (not error — it's a soft fallback). The resume
1854+
// layer now does its own codebase lookup for `discoveryCwd`, so the warn
1855+
// is emitted with the resume-specific event name.
18541856
expect(mockLogger.warn).toHaveBeenCalledWith(
18551857
expect.objectContaining({ codebaseId: 'cb-bad' }),
1856-
'cli.codebase_id_lookup_failed'
1858+
'cli.workflow_resume_codebase_lookup_failed'
18571859
);
18581860
});
1861+
1862+
it('should discover workflows from codebase.default_cwd, not working_path', async () => {
1863+
// Regression test for #1663: when working_path is a worktree or workspace
1864+
// clone that lacks the user's local workflow YAML, discovery must fall back
1865+
// to codebase.default_cwd so the file is still found.
1866+
const workflowDb = await import('@archon/core/db/workflows');
1867+
const codebaseDb = await import('@archon/core/db/codebases');
1868+
const workflowDiscovery = await import('@archon/workflows/workflow-discovery');
1869+
1870+
(workflowDb.getWorkflowRun as ReturnType<typeof mock>).mockResolvedValueOnce({
1871+
id: 'run-1663',
1872+
workflow_name: 'my-approval-workflow',
1873+
status: 'failed',
1874+
user_message: 'go',
1875+
working_path: '/tmp/worktree-without-yaml',
1876+
codebase_id: 'cb-with-yaml',
1877+
});
1878+
1879+
(codebaseDb.getCodebase as ReturnType<typeof mock>).mockResolvedValueOnce({
1880+
id: 'cb-with-yaml',
1881+
name: 'owner/repo',
1882+
default_cwd: '/users/me/source-repo-with-yaml',
1883+
});
1884+
1885+
const discoverSpy = workflowDiscovery.discoverWorkflowsWithConfig as ReturnType<typeof mock>;
1886+
discoverSpy.mockClear();
1887+
discoverSpy.mockResolvedValueOnce({ workflows: [], errors: [] });
1888+
1889+
try {
1890+
await workflowResumeCommand('run-1663');
1891+
} catch {
1892+
// downstream failure is acceptable — we only need to assert the discovery cwd
1893+
}
1894+
1895+
// Discovery must use the codebase source path, NOT working_path
1896+
expect(discoverSpy).toHaveBeenCalledWith(
1897+
'/users/me/source-repo-with-yaml',
1898+
expect.any(Function)
1899+
);
1900+
});
1901+
1902+
it('should fall back to working_path for discovery when codebase_id is missing', async () => {
1903+
const workflowDb = await import('@archon/core/db/workflows');
1904+
const workflowDiscovery = await import('@archon/workflows/workflow-discovery');
1905+
1906+
(workflowDb.getWorkflowRun as ReturnType<typeof mock>).mockResolvedValueOnce({
1907+
id: 'run-no-codebase',
1908+
workflow_name: 'legacy',
1909+
status: 'failed',
1910+
user_message: 'go',
1911+
working_path: '/tmp/old-worktree',
1912+
codebase_id: null,
1913+
});
1914+
1915+
const discoverSpy = workflowDiscovery.discoverWorkflowsWithConfig as ReturnType<typeof mock>;
1916+
discoverSpy.mockClear();
1917+
discoverSpy.mockResolvedValueOnce({ workflows: [], errors: [] });
1918+
1919+
try {
1920+
await workflowResumeCommand('run-no-codebase');
1921+
} catch {
1922+
// downstream failure is acceptable
1923+
}
1924+
1925+
// No codebase → falls back to working_path (preserves existing behavior)
1926+
expect(discoverSpy).toHaveBeenCalledWith('/tmp/old-worktree', expect.any(Function));
1927+
});
18591928
});
18601929

18611930
describe('workflowApproveCommand', () => {
@@ -1971,6 +2040,51 @@ describe('workflowApproveCommand', () => {
19712040
expect(conversationsDb.getConversationById).toHaveBeenCalledWith('db-uuid-original');
19722041
expect(conversationsDb.getOrCreateConversation).toHaveBeenCalledWith('cli', 'cli-original-123');
19732042
});
2043+
2044+
it('should discover workflows from codebase.default_cwd, not working_path', async () => {
2045+
// Regression test for #1663: auto-resume after approve must look up the
2046+
// workflow YAML in the source repo (codebase.default_cwd), not the
2047+
// worktree/workspace working_path that may lack the file.
2048+
const workflowDb = await import('@archon/core/db/workflows');
2049+
const codebaseDb = await import('@archon/core/db/codebases');
2050+
const workflowDiscovery = await import('@archon/workflows/workflow-discovery');
2051+
const core = await import('@archon/core');
2052+
2053+
(workflowDb.getWorkflowRun as ReturnType<typeof mock>).mockResolvedValueOnce({
2054+
id: 'run-approve-1663',
2055+
workflow_name: 'my-approval-workflow',
2056+
status: 'paused',
2057+
user_message: 'go',
2058+
working_path: '/tmp/worktree-without-yaml',
2059+
codebase_id: 'cb-with-yaml',
2060+
metadata: { approval: { nodeId: 'gate', message: 'Approve?' } },
2061+
});
2062+
2063+
(core.createWorkflowStore as ReturnType<typeof mock>).mockReturnValueOnce({
2064+
createWorkflowEvent: mock(() => Promise.resolve()),
2065+
});
2066+
2067+
(codebaseDb.getCodebase as ReturnType<typeof mock>).mockResolvedValueOnce({
2068+
id: 'cb-with-yaml',
2069+
name: 'owner/repo',
2070+
default_cwd: '/users/me/source-repo-with-yaml',
2071+
});
2072+
2073+
const discoverSpy = workflowDiscovery.discoverWorkflowsWithConfig as ReturnType<typeof mock>;
2074+
discoverSpy.mockClear();
2075+
discoverSpy.mockResolvedValueOnce({ workflows: [], errors: [] });
2076+
2077+
try {
2078+
await workflowApproveCommand('run-approve-1663');
2079+
} catch {
2080+
// downstream failure is acceptable
2081+
}
2082+
2083+
expect(discoverSpy).toHaveBeenCalledWith(
2084+
'/users/me/source-repo-with-yaml',
2085+
expect.any(Function)
2086+
);
2087+
});
19742088
});
19752089

19762090
describe('workflowAbandonCommand', () => {

packages/cli/src/commands/workflow.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export interface WorkflowRunOptions {
6363
noWorktree?: boolean;
6464
resume?: boolean;
6565
codebaseId?: string; // Passed by resume/approve to skip path-based lookup
66+
/**
67+
* Override the directory used for workflow YAML discovery. Resume/approve
68+
* pass `codebase.default_cwd` here so the source repo is searched even when
69+
* `working_path` is a worktree or workspace clone that lacks the file.
70+
*/
71+
discoveryCwd?: string;
6672
quiet?: boolean;
6773
verbose?: boolean;
6874
/** Platform conversation ID (e.g. `cli-{ts}-{rand}`), NOT a DB UUID. */
@@ -279,7 +285,7 @@ export async function workflowRunCommand(
279285
userMessage: string,
280286
options: WorkflowRunOptions = {}
281287
): Promise<void> {
282-
const { workflows: workflowEntries, errors } = await loadWorkflows(cwd);
288+
const { workflows: workflowEntries, errors } = await loadWorkflows(options.discoveryCwd ?? cwd);
283289

284290
if (workflowEntries.length === 0 && errors.length === 0) {
285291
throw new Error('No workflows found in .archon/workflows/');
@@ -1014,13 +1020,31 @@ export async function workflowResumeCommand(runId: string): Promise<void> {
10141020
console.log(`Path: ${run.working_path}`);
10151021
console.log('');
10161022

1023+
// Use the codebase's source path for workflow YAML discovery so the file is
1024+
// found even when working_path is a worktree or workspace clone that does
1025+
// not contain the user's local (often untracked) workflow YAML.
1026+
let discoveryCwd: string | undefined;
1027+
if (run.codebase_id) {
1028+
try {
1029+
const codebase = await codebaseDb.getCodebase(run.codebase_id);
1030+
if (codebase) discoveryCwd = codebase.default_cwd;
1031+
} catch (error) {
1032+
const err = error as Error;
1033+
getLog().warn(
1034+
{ err, errorType: err.constructor.name, runId, codebaseId: run.codebase_id },
1035+
'cli.workflow_resume_codebase_lookup_failed'
1036+
);
1037+
}
1038+
}
1039+
10171040
// Re-execute via workflowRunCommand with --resume.
10181041
// The executor's implicit findResumableRun detects the prior failed run
10191042
// and skips already-completed nodes.
10201043
try {
10211044
await workflowRunCommand(run.working_path, run.workflow_name, run.user_message ?? '', {
10221045
resume: true,
10231046
codebaseId: run.codebase_id ?? undefined,
1047+
discoveryCwd,
10241048
});
10251049
} catch (error) {
10261050
const err = error as Error;
@@ -1079,11 +1103,29 @@ export async function workflowApproveCommand(runId: string, comment?: string): P
10791103
);
10801104
}
10811105

1106+
// Use the codebase's source path for workflow YAML discovery so the file is
1107+
// found even when working_path is a worktree or workspace clone that does
1108+
// not contain the user's local (often untracked) workflow YAML.
1109+
let discoveryCwd: string | undefined;
1110+
if (result.codebaseId) {
1111+
try {
1112+
const codebase = await codebaseDb.getCodebase(result.codebaseId);
1113+
if (codebase) discoveryCwd = codebase.default_cwd;
1114+
} catch (error) {
1115+
const err = error as Error;
1116+
getLog().warn(
1117+
{ err, errorType: err.constructor.name, runId, codebaseId: result.codebaseId },
1118+
'cli.workflow_approve_codebase_lookup_failed'
1119+
);
1120+
}
1121+
}
1122+
10821123
try {
10831124
await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', {
10841125
resume: true,
10851126
codebaseId: result.codebaseId ?? undefined,
10861127
conversationId: platformConversationId,
1128+
discoveryCwd,
10871129
});
10881130
} catch (error) {
10891131
const err = error as Error;
@@ -1139,11 +1181,29 @@ export async function workflowRejectCommand(runId: string, reason?: string): Pro
11391181
);
11401182
}
11411183

1184+
// Use the codebase's source path for workflow YAML discovery so the file is
1185+
// found even when working_path is a worktree or workspace clone that does
1186+
// not contain the user's local (often untracked) workflow YAML.
1187+
let discoveryCwd: string | undefined;
1188+
if (result.codebaseId) {
1189+
try {
1190+
const codebase = await codebaseDb.getCodebase(result.codebaseId);
1191+
if (codebase) discoveryCwd = codebase.default_cwd;
1192+
} catch (error) {
1193+
const err = error as Error;
1194+
getLog().warn(
1195+
{ err, errorType: err.constructor.name, runId, codebaseId: result.codebaseId },
1196+
'cli.workflow_reject_codebase_lookup_failed'
1197+
);
1198+
}
1199+
}
1200+
11421201
try {
11431202
await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', {
11441203
resume: true,
11451204
codebaseId: result.codebaseId ?? undefined,
11461205
conversationId: platformConversationId,
1206+
discoveryCwd,
11471207
});
11481208
} catch (error) {
11491209
const err = error as Error;

0 commit comments

Comments
 (0)