Skip to content

Commit 7d054c8

Browse files
committed
feat(workflows): per-workflow worktree.enabled policy
Introduces a declarative top-level `worktree:` block on a workflow so authors can pin isolation behavior regardless of invocation surface. Solves the case where read-only workflows (e.g. `repo-triage`) should always run in the live checkout, without every CLI/web/scheduled-trigger caller having to remember to set the right flag. Schema (packages/workflows/src/schemas/workflow.ts + loader.ts): - New optional `worktree.enabled: boolean` on `workflowBaseSchema`. Loader parses with the same warn-and-ignore discipline used for `interactive` and `modelReasoningEffort` — invalid shapes log and drop rather than killing workflow discovery. Policy reconciliation (packages/cli/src/commands/workflow.ts): - Three hard-error cases when YAML policy contradicts invocation flags: • `enabled: false` + `--branch` (worktree required by flag, forbidden by policy) • `enabled: false` + `--from` (start-point only meaningful with worktree) • `enabled: true` + `--no-worktree` (policy requires worktree, flag forbids it) - `enabled: false` + `--no-worktree` is redundant, accepted silently. - `--resume` ignores the pinned policy (it reuses the existing run's worktree even when policy would disable — avoids disturbing a paused run). Orchestrator wiring (packages/core/src/orchestrator/orchestrator-agent.ts): - `dispatchOrchestratorWorkflow` short-circuits `validateAndResolveIsolation` when `workflow.worktree?.enabled === false` and runs directly in `codebase.default_cwd`. Web chat/slack/telegram callers have no flag equivalent to `--no-worktree`, so the YAML field is their only control. - Logged as `workflow.worktree_disabled_by_policy` for operator visibility. First consumer (.archon/workflows/repo-triage.yaml): - `worktree: { enabled: false }` — triage reads issues/PRs and writes gh labels; no code mutations, no reason to spin up a worktree per run. Tests: - Loader: parses `worktree.enabled: true|false`, omits block when absent. - CLI: four new integration tests for the reconciliation matrix (skip when policy false, three hard-error cases, redundant `--no-worktree` accepted, `--no-worktree` + `enabled: true` rejected). Docs: authoring-workflows.md gets the new top-level field in the schema example with a comment explaining the precedence and the `enabled: true|false` semantics.
1 parent 19cd8f6 commit 7d054c8

10 files changed

Lines changed: 338 additions & 23 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# E2E smoke test — workflow-level worktree.enabled: false
2+
# Verifies: when a workflow pins worktree.enabled: false, runs happen in the
3+
# live repo checkout (no worktree created, cwd == repo root). Zero AI calls.
4+
name: e2e-worktree-disabled
5+
description: "Pinned-isolation-off smoke. Asserts cwd is the repo root rather than a worktree path, regardless of how the workflow is invoked."
6+
7+
worktree:
8+
enabled: false
9+
10+
nodes:
11+
# Print cwd so the operator can eyeball it, and capture for the assertion node.
12+
- id: print-cwd
13+
bash: "pwd"
14+
15+
# Assertion: cwd must NOT contain '/.archon/workspaces/' — if it does, the
16+
# policy was ignored and a worktree was created anyway. We also assert the
17+
# cwd ends with a git repo (has a .git directory or file visible).
18+
- id: assert-live-checkout
19+
bash: |
20+
cwd="$(pwd)"
21+
echo "assert-live-checkout cwd=$cwd"
22+
case "$cwd" in
23+
*/.archon/workspaces/*/worktrees/*)
24+
echo "FAIL: workflow ran inside a worktree ($cwd) despite worktree.enabled: false"
25+
exit 1
26+
;;
27+
esac
28+
if [ ! -e "$cwd/.git" ]; then
29+
echo "FAIL: cwd $cwd is not a git checkout root (.git missing)"
30+
exit 1
31+
fi
32+
echo "PASS: ran in live checkout (no worktree created by policy)"
33+
depends_on: [print-cwd]
34+
trigger_rule: all_success

.archon/workflows/repo-triage.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ description: >-
88
runs; safe to re-run; idempotent.
99
interactive: false
1010

11+
# Read-only triage runs directly in the live checkout. Creating a worktree
12+
# every run would be wasted work (nothing is mutated) and would scatter stale
13+
# branches under ~/.archon/workspaces/<owner>/<repo>/worktrees/.
14+
worktree:
15+
enabled: false
16+
1117
nodes:
1218
# ---------------------------------------------------------------------------
1319
# Issue triage — runs concurrently with pr-link (no depends_on between them).

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Workflow-level worktree policy (`worktree.enabled` in workflow YAML).** A workflow can now pin whether its runs use isolation regardless of how they were invoked: `worktree.enabled: false` always runs in the live checkout (CLI `--branch` / `--from` hard-error; web/chat/orchestrator short-circuits `validateAndResolveIsolation`), `worktree.enabled: true` requires isolation (CLI `--no-worktree` hard-errors). Omit the block to let the caller decide (current default). First consumer: `.archon/workflows/repo-triage.yaml` pinned to `enabled: false` since it's read-only.
13+
1214
- **Per-project worktree path (`worktree.path` in `.archon/config.yaml`).** Opt-in repo-relative directory (e.g. `.worktrees`) where Archon places worktrees for that repo, instead of the default `~/.archon/workspaces/<owner>/<repo>/worktrees/`. Co-locates worktrees with the project so they appear in the IDE file tree. Validated as a safe relative path (no absolute, no `..`); malformed values fail loudly at worktree creation. Users opting in are responsible for `.gitignore`ing the directory themselves — no automatic file mutation. Credits @joelsb for surfacing the need in #1117.
1315

1416
- **Three-path env model with operator-visible log lines.** The CLI and server now load env vars from `~/.archon/.env` (user scope) and `<cwd>/.archon/.env` (repo scope, overrides user) at boot, both with `override: true`. A new `[archon] loaded N keys from <path>` line is emitted per source (only when N > 0). `[archon] stripped N keys from <cwd> (...)` now also prints when stripCwdEnv removes target-repo env keys, replacing the misleading `[dotenv@17.3.1] injecting env (0) from .env` preamble that always reported 0. The `quiet: true` flag suppresses dotenv's own output. (#1302)

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

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,146 @@ describe('workflowRunCommand', () => {
867867
expect(createCallsAfter).toBe(createCallsBefore);
868868
});
869869

870+
// -------------------------------------------------------------------------
871+
// Workflow-level `worktree.enabled` policy
872+
// -------------------------------------------------------------------------
873+
874+
it('skips isolation when workflow YAML pins worktree.enabled: false', async () => {
875+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
876+
const { executeWorkflow } = await import('@archon/workflows/executor');
877+
const conversationDb = await import('@archon/core/db/conversations');
878+
const codebaseDb = await import('@archon/core/db/codebases');
879+
const isolation = await import('@archon/isolation');
880+
881+
const getIsolationProviderMock = isolation.getIsolationProvider as ReturnType<typeof mock>;
882+
const providerBefore = getIsolationProviderMock.mock.results.at(-1)?.value as
883+
| { create: ReturnType<typeof mock> }
884+
| undefined;
885+
const createCallsBefore = providerBefore?.create.mock.calls.length ?? 0;
886+
887+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
888+
workflows: [
889+
makeTestWorkflowWithSource({
890+
name: 'triage',
891+
description: 'Read-only triage',
892+
worktree: { enabled: false },
893+
}),
894+
],
895+
errors: [],
896+
});
897+
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
898+
id: 'conv-123',
899+
});
900+
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce({
901+
id: 'cb-123',
902+
default_cwd: '/test/path',
903+
});
904+
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
905+
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
906+
success: true,
907+
workflowRunId: 'run-123',
908+
});
909+
910+
// No flags — policy alone should disable isolation
911+
await workflowRunCommand('/test/path', 'triage', 'go', {});
912+
913+
const providerAfter = getIsolationProviderMock.mock.results.at(-1)?.value as
914+
| { create: ReturnType<typeof mock> }
915+
| undefined;
916+
const createCallsAfter = providerAfter?.create.mock.calls.length ?? 0;
917+
expect(createCallsAfter).toBe(createCallsBefore);
918+
});
919+
920+
it('throws when workflow pins worktree.enabled: false but caller passes --branch', async () => {
921+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
922+
923+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
924+
workflows: [
925+
makeTestWorkflowWithSource({
926+
name: 'triage',
927+
description: 'Read-only triage',
928+
worktree: { enabled: false },
929+
}),
930+
],
931+
errors: [],
932+
});
933+
934+
await expect(
935+
workflowRunCommand('/test/path', 'triage', 'go', { branchName: 'feat-x' })
936+
).rejects.toThrow(/worktree\.enabled: false/);
937+
});
938+
939+
it('throws when workflow pins worktree.enabled: false but caller passes --from', async () => {
940+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
941+
942+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
943+
workflows: [
944+
makeTestWorkflowWithSource({
945+
name: 'triage',
946+
description: 'Read-only triage',
947+
worktree: { enabled: false },
948+
}),
949+
],
950+
errors: [],
951+
});
952+
953+
await expect(
954+
workflowRunCommand('/test/path', 'triage', 'go', { fromBranch: 'dev' })
955+
).rejects.toThrow(/worktree\.enabled: false/);
956+
});
957+
958+
it('accepts worktree.enabled: false + --no-worktree as redundant (no error)', async () => {
959+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
960+
const { executeWorkflow } = await import('@archon/workflows/executor');
961+
const conversationDb = await import('@archon/core/db/conversations');
962+
const codebaseDb = await import('@archon/core/db/codebases');
963+
964+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
965+
workflows: [
966+
makeTestWorkflowWithSource({
967+
name: 'triage',
968+
description: 'Read-only triage',
969+
worktree: { enabled: false },
970+
}),
971+
],
972+
errors: [],
973+
});
974+
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
975+
id: 'conv-123',
976+
});
977+
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce({
978+
id: 'cb-123',
979+
default_cwd: '/test/path',
980+
});
981+
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
982+
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
983+
success: true,
984+
workflowRunId: 'run-123',
985+
});
986+
987+
// Should not throw — redundant, not contradictory
988+
await workflowRunCommand('/test/path', 'triage', 'go', { noWorktree: true });
989+
});
990+
991+
it('throws when workflow pins worktree.enabled: true but caller passes --no-worktree', async () => {
992+
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
993+
994+
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
995+
workflows: [
996+
makeTestWorkflowWithSource({
997+
name: 'build',
998+
description: 'Requires a worktree',
999+
worktree: { enabled: true },
1000+
}),
1001+
],
1002+
errors: [],
1003+
});
1004+
1005+
await expect(
1006+
workflowRunCommand('/test/path', 'build', 'go', { noWorktree: true })
1007+
).rejects.toThrow(/worktree\.enabled: true/);
1008+
});
1009+
8701010
it('throws when isolation cannot be created due to missing codebase', async () => {
8711011
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
8721012
const conversationDb = await import('@archon/core/db/conversations');

packages/cli/src/commands/workflow.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,37 @@ export async function workflowRunCommand(
261261
);
262262
}
263263

264+
// Reconcile workflow-level worktree policy with invocation flags.
265+
// The workflow YAML's `worktree.enabled` pins isolation regardless of caller —
266+
// a mismatch between policy and flags is a user error we surface loudly
267+
// rather than silently applying one side and ignoring the other.
268+
const pinnedEnabled = workflow.worktree?.enabled;
269+
if (pinnedEnabled === false) {
270+
if (options.branchName !== undefined) {
271+
throw new Error(
272+
`Workflow '${workflow.name}' sets worktree.enabled: false (runs in live checkout).\n` +
273+
' --branch requires an isolated worktree.\n' +
274+
" Drop --branch or change the workflow's worktree.enabled."
275+
);
276+
}
277+
if (options.fromBranch !== undefined) {
278+
throw new Error(
279+
`Workflow '${workflow.name}' sets worktree.enabled: false (runs in live checkout).\n` +
280+
' --from/--from-branch only applies when a worktree is created.\n' +
281+
" Drop --from or change the workflow's worktree.enabled."
282+
);
283+
}
284+
// --no-worktree is redundant but not contradictory — silently accept.
285+
} else if (pinnedEnabled === true) {
286+
if (options.noWorktree) {
287+
throw new Error(
288+
`Workflow '${workflow.name}' sets worktree.enabled: true (requires a worktree).\n` +
289+
' --no-worktree conflicts with the workflow policy.\n' +
290+
" Drop --no-worktree or change the workflow's worktree.enabled."
291+
);
292+
}
293+
}
294+
264295
console.log(`Running workflow: ${workflowName}`);
265296
console.log(`Working directory: ${cwd}`);
266297
console.log('');
@@ -403,8 +434,14 @@ export async function workflowRunCommand(
403434
console.log('');
404435
}
405436

406-
// Default to worktree isolation unless --no-worktree or --resume
407-
const wantsIsolation = !options.resume && !options.noWorktree;
437+
// Default to worktree isolation unless --no-worktree or --resume.
438+
// Workflow YAML `worktree.enabled` pins the decision — mismatches with CLI
439+
// flags are rejected above, so by this point the policy (if set) and flags
440+
// agree. `--resume` reuses an existing worktree and takes precedence over
441+
// the pinned policy to avoid disturbing a paused run.
442+
const flagWantsIsolation = !options.resume && !options.noWorktree;
443+
const wantsIsolation =
444+
!options.resume && pinnedEnabled !== undefined ? pinnedEnabled : flagWantsIsolation;
408445

409446
if (wantsIsolation && codebase) {
410447
// Auto-generate branch identifier from workflow name + timestamp when --branch not provided

packages/core/src/orchestrator/orchestrator-agent.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -228,31 +228,43 @@ async function dispatchOrchestratorWorkflow(
228228
codebase_id: codebase.id,
229229
});
230230

231-
// Validate and resolve isolation
231+
// Validate and resolve isolation.
232+
// A workflow with `worktree.enabled: false` short-circuits the resolver entirely
233+
// and runs in the live checkout — no worktree creation, no env row. This is the
234+
// declarative equivalent of CLI `--no-worktree` for workflows that should always
235+
// run live (e.g. read-only triage, docs generation on the main checkout).
232236
let cwd: string;
233-
try {
234-
const result = await validateAndResolveIsolation(
235-
{ ...conversation, codebase_id: codebase.id },
236-
codebase,
237-
platform,
238-
conversationId,
239-
isolationHints
237+
if (workflow.worktree?.enabled === false) {
238+
getLog().info(
239+
{ workflowName: workflow.name, conversationId, codebaseId: codebase.id },
240+
'workflow.worktree_disabled_by_policy'
240241
);
241-
cwd = result.cwd;
242-
} catch (error) {
243-
if (error instanceof IsolationBlockedError) {
244-
getLog().warn(
245-
{
246-
reason: error.reason,
247-
conversationId,
248-
codebaseId: codebase.id,
249-
workflowName: workflow.name,
250-
},
251-
'isolation_blocked'
242+
cwd = codebase.default_cwd;
243+
} else {
244+
try {
245+
const result = await validateAndResolveIsolation(
246+
{ ...conversation, codebase_id: codebase.id },
247+
codebase,
248+
platform,
249+
conversationId,
250+
isolationHints
252251
);
253-
return;
252+
cwd = result.cwd;
253+
} catch (error) {
254+
if (error instanceof IsolationBlockedError) {
255+
getLog().warn(
256+
{
257+
reason: error.reason,
258+
conversationId,
259+
codebaseId: codebase.id,
260+
workflowName: workflow.name,
261+
},
262+
'isolation_blocked'
263+
);
264+
return;
265+
}
266+
throw error;
254267
}
255-
throw error;
256268
}
257269

258270
// Dispatch workflow

packages/docs-web/src/content/docs/guides/authoring-workflows.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ model: sonnet
120120
modelReasoningEffort: medium # Codex only
121121
webSearchMode: live # Codex only
122122
interactive: true # Web only: run in foreground instead of background
123+
worktree: # Optional: pin isolation behavior regardless of caller
124+
enabled: false # false = always run in the live checkout (CLI --no-worktree
125+
# and web both honor it). Use for read-only workflows
126+
# like triage/reporting. true = must use a worktree;
127+
# CLI --no-worktree hard-errors. Omit to let the
128+
# caller decide (current default = worktree).
123129
124130
# Required for DAG-based
125131
nodes:

packages/workflows/src/loader.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,33 @@ describe('Workflow Loader', () => {
9393
expect(result.workflows[0].workflow.interactive).toBeUndefined();
9494
});
9595

96+
it('should parse worktree.enabled: false', async () => {
97+
const workflowDir = join(testDir, '.archon', 'workflows');
98+
await mkdir(workflowDir, { recursive: true });
99+
const yaml = `name: triage\ndescription: read-only\nworktree:\n enabled: false\nnodes:\n - id: n\n prompt: p\n`;
100+
await writeFile(join(workflowDir, 'triage.yaml'), yaml);
101+
const result = await discoverWorkflows(testDir, { loadDefaults: false });
102+
expect(result.workflows[0].workflow.worktree).toEqual({ enabled: false });
103+
});
104+
105+
it('should parse worktree.enabled: true', async () => {
106+
const workflowDir = join(testDir, '.archon', 'workflows');
107+
await mkdir(workflowDir, { recursive: true });
108+
const yaml = `name: build\ndescription: needs worktree\nworktree:\n enabled: true\nnodes:\n - id: n\n prompt: p\n`;
109+
await writeFile(join(workflowDir, 'build.yaml'), yaml);
110+
const result = await discoverWorkflows(testDir, { loadDefaults: false });
111+
expect(result.workflows[0].workflow.worktree).toEqual({ enabled: true });
112+
});
113+
114+
it('should omit worktree block when not present (policy is caller-decides)', async () => {
115+
const workflowDir = join(testDir, '.archon', 'workflows');
116+
await mkdir(workflowDir, { recursive: true });
117+
const yaml = `name: normal\ndescription: no policy\nnodes:\n - id: n\n prompt: p\n`;
118+
await writeFile(join(workflowDir, 'normal.yaml'), yaml);
119+
const result = await discoverWorkflows(testDir, { loadDefaults: false });
120+
expect(result.workflows[0].workflow.worktree).toBeUndefined();
121+
});
122+
96123
it('should parse valid DAG workflow YAML', async () => {
97124
const workflowDir = join(testDir, '.archon', 'workflows');
98125
await mkdir(workflowDir, { recursive: true });

0 commit comments

Comments
 (0)