Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ config-docker.yaml
swarm.log

# Environment
# `.env.*` also covers hand-made backups such as `.env.bak-<date>`, which hold the same tokens as
# `.env` and would otherwise be staged by `git add -A`. The tracked template stays visible.
.env
.env.local
.env.*
!.env.example
nohup.out

# IDE
.idea/
Expand Down
13 changes: 7 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ graph LR

Admission under `unknownScopeAdmission: admit` (default, vela):

1. `includeBacklog: true` — Backlog is a queue.
2. `idle_fill` lifts `NEEDS_HUMAN` / `RETRY_AT` / legacy backoff when Linear still wants the card.
0. Before anything is fetched: the disk floor (`minFreeDiskGiB`, `support/hostHealth.ts`, AGT-4785) pauses the heartbeat. Right after the fetch, `partitionByReadiness` (`automation/readinessGate.ts`, AGT-4784) drops Backlog and skip-label issues, so they never reach `filterAlreadyProcessed`.
1. `includeBacklog: false` (default since AGT-4784) — Todo is the ready signal. A deployment that wants free slots to chew the Backlog (vela) must set `includeBacklog: true`.
2. `idle_fill` lifts `NEEDS_HUMAN` / `RETRY_AT` / legacy backoff when Linear still wants the card — except for an issue held by an `exec` orchestrator (`automation/execHold.ts`) or whose title and description are unchanged since its run started (`automation/dispatchFingerprint.ts`, AGT-4787).
3. Predicted file overlap does **not** defer. Worktrees isolate; merge later.
4. Still skipped: Linear Done/Canceled, parent/EPIC umbrellas, live `CLAIMED`/`EXECUTING` leases, unresolved deps, unmapped/disabled projects.
4. Still skipped: Linear Done/Canceled, parent/EPIC umbrellas, live `CLAIMED`/`EXECUTING` leases, unresolved deps, unmapped/disabled projects. `detectSafeCandidateIds` also drops an issue whose draft carries a confident "a person has to do or decide this" verdict before the claim.

`serialize` restores Codex-era fail-closed overlap holds.

Expand All @@ -71,7 +72,7 @@ Admission under `unknownScopeAdmission: admit` (default, vela):
- `RunState` (`automation/runLedgerTypes.ts`) — `READY`…`DONE` plus `RETRY_AT`, `NEEDS_HUMAN`, `NEEDS_RECONCILE`.
- `ParkResumeTrigger` — `idle_fill` | `explicit_dispatch` | `tracker_todo` | `sandbox_quarantine_dispatch` | `unspecified`.
- `UnknownScopeAdmission` — `'admit' | 'serialize'`.
- Config: `src/core/config.ts` `AutonomousConfigSchema` (`includeBacklog` default true, `unknownScopeAdmission` default admit).
- Config: `src/core/config.ts` `AutonomousConfigSchema` (`includeBacklog` default false since AGT-4784, `unknownScopeAdmission` default admit).

## 확장 지점

Expand All @@ -86,8 +87,8 @@ Admission under `unknownScopeAdmission: admit` (default, vela):
- **`vela-build.sh <sha>`는 `origin/main`만 fetch한다.** PR SHA는 `builds/4e8ba24`에서 `git fetch origin <branch>` 먼저.
- **PR SHA를 돌리는 동안 `.last-built-sha`는 `origin/main` tip을 유지**하지 않으면 autodeploy cron이 main으로 롤백한다.
- **`unknownScopeAdmission: admit`를 heartbeat만 바꾸고 claim 게이트를 안 바꾸면** 큐에는 들어가고 `claimRun`이 null — 슬롯이 다시 빈다.
- **`isActionableLinearState(state)` 헬퍼 기본은 Backlog park.** 엔진/config 기본은 `includeBacklog: true`. `undefined`를 spread하면 DEFAULT true를 덮는다 — constructor가 `?? true`로 고정한다.
- **AGT-4155 park loop**: Linear `In Progress`만으로 park를 열면 claim→exec→park가 heartbeat마다 돈다. 지금은 `idle_fill`이 의도적으로 연다 (싼 모델이 빈 슬롯을 씹도록). `serialize`/질문 대기를 되살릴 때 이 루프를 다시 열지 말 것.
- **`isActionableLinearState(state)` helper parks Backlog by default**, and since AGT-4784 so do the engine, config and runner (`includeBacklog: false`; every reader spells it `?? false`). `undefined` must keep meaning false: never spread an `undefined` over the default, and never reintroduce a `?? true`.
- **AGT-4155 park loop**: opening a park on Linear `In Progress` alone makes claim→exec→park spin every heartbeat. `idle_fill` opens parks on purpose (cheap models chew free slots), except where `execHold` or an unchanged `dispatchFingerprint` holds the park (AGT-4787). Do not reopen this loop when reviving `serialize` or question waits.
- **drafted `docs/integrations.md` 같은 광역 fileScope**는 한 레포의 카드를 전부 한 conflict group으로 묶는다. `admit`이 아니면 슬롯이 비어 있는 채로 1장만 산다.
- 로컬 워크스페이스 `fix/agt-4220-…`의 dirty `store.ts` / `autonomousRunner.ts`는 이 스택과 섞지 말 것.

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ DISCORD_CHANNEL_ID=your-channel-id
| `github` | Repos list for CI monitoring |
| `agents` | Agent definitions (name, projectPath, heartbeat interval) |
| `autonomous` | Schedule, pair mode, role models, decomposition settings (`maxChildrenPerTask` applies to both the runner and human `/plan`; `dailyLimit` paces the unsupervised runner only — AGT-4123) |
| `autonomous.includeBacklog` | Treat Linear Backlog as a work queue (default `true`) |
| `autonomous.includeBacklog` | Treat Linear Backlog as a work queue (default `false`: moving an issue to Todo is the ready signal; set `true` on a deployment that wants free slots to chew the Backlog) |
| `autonomous.unknownScopeAdmission` | `admit` (default): worktrees isolate — predicted file overlap does not defer. `serialize`: Codex-era fail-closed |
| `prProcessor` | PR auto-improvement schedule, retry limits, conflict resolver config |

Expand Down Expand Up @@ -403,7 +403,7 @@ The `openrouter` adapter runs OpenSwarm's own agentic tool loop (read/search/edi

Each autonomous run snapshots the current Claude Code instruction hierarchy (`~/.claude/CLAUDE.md`, user rules, repository `CLAUDE.md`/`AGENTS.md`, and matching `.claude/rules`) once and applies the same capsule to orchestrator, worker, reviewer, Codex, CC-Router, and Cursor. The dashboard shows the capsule digest and source/error counts, not the rule bodies.

`adapterRouting.primary` must name the adapter the worker actually runs (`adapter:` at the top of the config, or `roles.worker.adapter`); a policy whose primary is some other adapter is ignored, and the fallbacks never engage. Role MCP grants and the coordination tools (`coordination_read`, `coordination_peers`, `coordination_publish`, `coordination_thread_*`, `ask_human`) additionally require an adapter that runs OpenSwarm's own tool loop — `codex-responses`, `cc-router`, `gpt`, `openrouter`, `atlascloud`, `lmstudio`, `local`. The delegated CLIs (`codex`, `claude`, `cursor`) bring their own tool loop, so those grants do not reach them; OpenSwarm logs a warning rather than pretending they applied.
`adapterRouting.primary` must name the adapter the worker actually runs (`adapter:` at the top of the config, or `roles.worker.adapter`); a policy whose primary is some other adapter is ignored, and the fallbacks never engage. Role MCP grants and the coordination tools (`coordination_read`, `coordination_peers`, `coordination_publish`, `coordination_thread_*`, `ask_human`) additionally require an adapter that runs OpenSwarm's own tool loop — `codex-responses`, `cc-router`, `gpt`, `openrouter`, `atlascloud`, `lmstudio`, `local`. The delegated CLIs (`codex`, `claude`, `cursor`) bring their own tool loop, so those grants do not reach them; for interactive CLI use OpenSwarm logs a warning rather than pretending they applied. The daemon is stricter: a config with an `autonomous` section that names a delegated adapter anywhere (`adapter`, `defaultRoles`, `projectAgents` roles, `adapterRouting` primary or fallbacks, `orchestrator`, `loopAdvisor`) refuses to start unless `autonomous.allowDelegatedAdapters: true` accepts the missing fences on purpose.

A configured `coordinationBoardIssueId` turns one project-scoped tracker issue into the durable agent board. Worker advice/delegation, Discord questions, adapter routes, periodic reviews, and MCP denials are visible through `GET /api/coordination`, SSE, and the **AGENT COORDINATION** dashboard panel. Tool arguments, prompts, credentials, and rule bodies are redacted or omitted.

Expand All @@ -426,7 +426,7 @@ autonomous:
coordinationBoardIssueId: AGT-3993
adapterRouting:
primary: codex-responses
fallbacks: [cc-router, cursor]
fallbacks: [cc-router] # `cursor` is a delegated CLI: add it only with allowDelegatedAdapters: true
allowReasons: [quota, infra, capability]
mcpPolicies:
orchestrator:
Expand Down
11 changes: 7 additions & 4 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,16 @@ autonomous:
# `primary` must name the same adapter this file selects above (adapter:), or
# routing silently disables — the worker only consults a policy whose primary
# is the adapter it is actually running.
# Only typed failures may route to CC-Router or an authenticated Cursor CLI;
# a failing test or a reviewer verdict never does.
# Only typed failures may route to a fallback; a failing test or a reviewer
# verdict never does. `cursor` (and `codex`) are delegated CLIs that bring
# their own tool loop, so the daemon refuses to start with one listed here
# unless allowDelegatedAdapters: true (below) accepts that on purpose.
# quota — provider usage limit / 429 on the primary
# infra — spawn, auth, or timeout failure before the agent ran
# capability — the primary is not installed or not serving on this machine
adapterRouting:
primary: codex-responses
fallbacks: [cc-router, cursor]
fallbacks: [cc-router]
allowReasons: [quota, infra, capability]

# Exact role-scoped MCP grants. Reads are allowed from listed servers; writes
Expand Down Expand Up @@ -270,7 +272,8 @@ autonomous:

# OS fence for the worker's bash tool (AGT-4387). on (default): every bash
# call runs under sandbox-exec (macOS) / bwrap (Linux) with writes limited to
# the worktree, temp dir and package caches; network stays open. A host with
# the worktree, temp dir and package caches; network is loopback-only unless
# workerNetwork (below) says open. A host with
# neither binary refuses worker bash commands instead of running them open
# (AGT-4783). off: bash runs unfenced with only the regex denylist. Either way
# the shell gets an allowlisted env and a private HOME — no GitHub, cloud or
Expand Down
30 changes: 30 additions & 0 deletions src/adapters/ollamaCloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,36 @@ describe('OllamaCloudAdapter', () => {
expect(new OllamaCloudAdapter().getTransport()).toBe('local');
});

it('enforces the publication fence on the direct transport, as every other native adapter does (AGT-4783)', async () => {
// The direct route (OLLAMA_API_KEY set) built its loop options by hand and kept only `protectedFiles`,
// so a fenced worker could `git push` while the local transport refused it.
process.env.OLLAMA_API_KEY = 'test-key';
delete process.env.OLLAMA_CLOUD_BASE_URL;
const bodies: Array<{ messages: Array<{ role: string; content?: string }> }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (!String(url).endsWith('/chat/completions')) {
return new Response(JSON.stringify({ object: 'list', data: [] }), { status: 200 });
}
bodies.push(JSON.parse(String(init?.body)));
const sse = bodies.length === 1
? 'data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function",'
+ '"function":{"name":"bash","arguments":"{\\"command\\":\\"git push origin HEAD\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n'
+ 'data: [DONE]\n'
: 'data: {"choices":[{"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n';
return new Response(sse, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}));

const result = await new OllamaCloudAdapter().run({
prompt: 'push it', cwd: process.cwd(), model: 'deepseek-v4.1-flash:cloud',
enableTools: true, maxTurns: 3, forbidPublication: true,
});

expect(result.exitCode).toBe(0);
expect(bodies).toHaveLength(2);
const toolReply = bodies[1].messages.filter((m) => m.role === 'tool').at(-1);
expect(toolReply?.content).toMatch(/^PUBLICATION_FENCED:/);
}, 30_000);

it('retries a transient 5xx on the direct transport and sends the plain id to the chat endpoint', async () => {
process.env.OLLAMA_API_KEY = 'test-key';
delete process.env.OLLAMA_CLOUD_BASE_URL;
Expand Down
9 changes: 9 additions & 0 deletions src/adapters/ollamaCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,15 @@ export class OllamaCloudAdapter extends LocalModelAdapter implements CliAdapter
finishValidator: options.finishValidator,
finishValidatorMaxRetries: options.finishValidatorMaxRetries,
protectedFiles: options.protectedFiles,
// The fences OpenSwarm's own loop enforces for an unattended stage. The local transport
// forwards all of them (local.ts) and so does every other native adapter; this literal kept
// only `protectedFiles`, so a worker on the direct route (OLLAMA_API_KEY set) ran with no
// write fence, no loopback-only network and no publication fence while the boot log said
// "Worker sandbox: on" and unattendedPolicy counted ollama-cloud as fenced. (AGT-4783, AGT-4444)
forbidPublication: options.forbidPublication,
sandbox: options.sandbox,
scratchpadRunId: options.scratchpadRunId,
memoryContext: options.memoryContext,
bashTimeoutMs: options.bashTimeoutMs,
webTools: options.webTools,
memoryTools: options.memoryTools,
Expand Down
79 changes: 78 additions & 1 deletion src/adapters/unattendedPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
// `defaultRoles` but not `adapterRouting.fallbacks` would pass the first test
// and still route unattended work to a CLI that has no fences.

import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { fileURLToPath } from 'node:url';
import { loadConfig } from '../core/config.js';
import {
DELEGATED_ADAPTERS,
assertUnattendedAdapters,
collectUnattendedAdapterViolations,
providerSwitchViolation,
unattendedAdapterViolation,
type UnattendedAdapterConfig,
} from './unattendedPolicy.js';
Expand Down Expand Up @@ -92,6 +95,80 @@ describe('collectUnattendedAdapterViolations', () => {
});
});

describe('worker fan-out candidates', () => {
// `candidates[].adapter` accepts any adapter. In `execute` mode runWorkerFanout runs each candidate as an
// unattended worker on that adapter, so a delegated CLI there is the same hole as `role.adapter`.
const fanout = (over: Record<string, unknown> = {}) => ({
mode: 'execute', candidates: [{ adapter: 'openrouter' }, { adapter: 'claude' }], ...over,
});

it('refuses a delegated candidate in execute mode, naming the candidate, for a default and a project role', () => {
const viaDefault = collectUnattendedAdapterViolations(safe({ defaultRoles: { worker: { fanout: fanout() } } }));
expect(viaDefault).toHaveLength(1);
expect(viaDefault[0]).toContain('autonomous.defaultRoles.worker.fanout.candidates[1].adapter');
const viaProject = collectUnattendedAdapterViolations(safe({
projectAgents: [{ projectPath: '/repo', roles: { worker: { fanout: fanout() } } }],
}));
expect(viaProject[0]).toContain('autonomous.projectAgents[/repo].roles.worker.fanout.candidates[1].adapter');
});

it('still checks a role that names no adapter of its own', () => {
// The role-level check skipped any role without `adapter`, which is where a fan-out-only role lives.
const violations = collectUnattendedAdapterViolations(safe({
defaultRoles: { worker: { adapter: undefined, fanout: fanout({ candidates: [{ adapter: 'cursor' }] }) } },
}));
expect(violations).toHaveLength(1);
expect(violations[0]).toContain("'cursor'");
});

it('lets through what cannot run: report mode, a disabled fan-out, a disabled role, or in-process candidates', () => {
expect(collectUnattendedAdapterViolations(safe({ defaultRoles: { worker: { fanout: fanout({ mode: 'report' }) } } }))).toEqual([]);
expect(collectUnattendedAdapterViolations(safe({ defaultRoles: { worker: { fanout: fanout({ enabled: false }) } } }))).toEqual([]);
expect(collectUnattendedAdapterViolations(safe({ defaultRoles: { worker: { enabled: false, fanout: fanout() } } }))).toEqual([]);
expect(collectUnattendedAdapterViolations(safe({
defaultRoles: { worker: { fanout: fanout({ candidates: [{ adapter: 'openrouter' }, { adapter: 'codex-responses' }, {}] }) } },
}))).toEqual([]);
});

it('honours the opt-in', () => {
expect(collectUnattendedAdapterViolations(safe({ allowDelegatedAdapters: true, defaultRoles: { worker: { fanout: fanout() } } }))).toEqual([]);
});
});

describe('providerSwitchViolation', () => {
// assertUnattendedAdapters reads config.yaml once, at boot. The provider override, the dashboard toggle
// and `openswarm provider` re-point every role afterwards, so they meet the same policy and opt-in.
it('refuses a delegated CLI and lets an in-process adapter through, unless the operator opted in', () => {
for (const name of DELEGATED_ADAPTERS) {
expect(providerSwitchViolation(name, false), name).toContain(`'${name}'`);
expect(providerSwitchViolation(name, undefined), `${name} unset is not an opt-in`).not.toBeNull();
expect(providerSwitchViolation(name, true), `${name} opted in`).toBeNull();
}
expect(providerSwitchViolation('codex-responses', false)).toBeNull();
expect(providerSwitchViolation('openrouter', undefined)).toBeNull();
});
});

describe('the shipped example config', () => {
it('passes the policy it ships next to', () => {
// `cp config.example.yaml config.yaml` is the documented install (README, docker-compose). Its
// adapterRouting once listed `cursor` as a fallback, which this policy refuses, so a deployment
// copied from the example never came up. The refusal happens only after the web server, Discord
// and the scheduler have started, and nothing else loads the example through the policy.
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const config = loadConfig(fileURLToPath(new URL('../../config.example.yaml', import.meta.url)));
expect(config.autonomous).toBeDefined();
expect(collectUnattendedAdapterViolations(config)).toEqual([]);
expect(() => assertUnattendedAdapters(config)).not.toThrow();
} finally {
log.mockRestore();
warn.mockRestore();
}
});
});

describe('assertUnattendedAdapters', () => {
it('does not throw for a safe config', () => {
expect(() => assertUnattendedAdapters(safe())).not.toThrow();
Expand Down
Loading
Loading