Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f993f2c
feat(loop): cross-repo auto mode with work-source and drain-complete fix
androidand Oct 3, 2026
14a200a
chore(fork): register work-source and auto-mode test files
androidand Oct 3, 2026
3c02359
feat(loop): per-repo gate options for cross-repo auto runs (D5 fallback)
androidand Oct 3, 2026
0107e34
test(loop): per-repo gate resolution test (D5 fallback)
androidand Oct 3, 2026
ca72316
test(loop): D5 failure proof — correct repo completes, wrong-config r…
androidand Oct 3, 2026
f6bb40f
fix(loop): correct terminal status type in D5 failure proof test
androidand Oct 3, 2026
4a42d91
feat(crew): routing decision function with unit tests (Phase 2a.1)
androidand Oct 3, 2026
c026659
test(loop): work-source client tests + null-output fix (Phase 3.1)
androidand Oct 3, 2026
3ce588f
feat(loop): skip missing repos in auto mode + Phase 3.2 test
androidand Oct 3, 2026
8a99d0b
feat(loop): capacity-bounded concurrency in auto mode (Phase 3.4)
androidand Oct 3, 2026
5d0c5d3
feat(loop): add environmental guard for consecutive halts (Phase 3.5)
androidand Oct 3, 2026
c509c05
feat(loop): add claim/release lifecycle for cancellation (Phase 3.6)
androidand Oct 3, 2026
7b31baa
fix(loop): fence auto runs with QueueDenyRules (Phase 3.7)
androidand Oct 3, 2026
70d6c7c
feat(loop): aggregated report covers all items (Phase 4.1)
androidand Oct 3, 2026
d5cf928
feat(loop): distinguish drained from found nothing (Phase 4.2)
androidand Oct 3, 2026
52516a5
feat(cli): add --auto flag to loop command (Phase 5.1, 5.2)
androidand Oct 3, 2026
1ecc4db
test(loop): two-repository fixture drains to completion (Phase 6.1)
androidand Oct 3, 2026
a97d9b7
test(loop): halt path end-to-end (Phase 6.2)
androidand Oct 3, 2026
899266b
feat(loop): add autoDirectory and autoRepos fields
androidand Oct 3, 2026
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: 6 additions & 0 deletions fork/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"packages/opencode/src/local/sync.ts",
"packages/opencode/src/loop/completion.ts",
"packages/opencode/src/loop/continuation.ts",
"packages/opencode/src/loop/crew/route.ts",
"packages/opencode/src/loop/loop.ts",
"packages/opencode/src/loop/similarity.ts",
"packages/opencode/src/loop/spec-queue/authority.ts",
Expand All @@ -78,6 +79,7 @@
"packages/opencode/src/loop/spec-queue/personas.ts",
"packages/opencode/src/loop/spec-queue/queue.ts",
"packages/opencode/src/loop/spec-queue/tasks-md.ts",
"packages/opencode/src/loop/spec-queue/work-source.ts",
"packages/opencode/src/loop/spec-queue/branch-read.ts",
"packages/opencode/src/loop/stop-reason.ts",
"packages/opencode/src/peer/lead-confirm.ts",
Expand Down Expand Up @@ -137,16 +139,20 @@
"packages/opencode/test/local/skein-independence.test.ts",
"packages/opencode/test/local/ctx-fit.test.ts",
"packages/opencode/test/local/llama-skein-client-smoke.test.ts",
"packages/opencode/test/loop/auto-mode.test.ts",
"packages/opencode/test/loop/completion.test.ts",
"packages/opencode/test/loop/continuation.test.ts",
"packages/opencode/test/loop/crew/route.test.ts",
"packages/opencode/test/loop/loop-args.test.ts",
"packages/opencode/test/loop/cross-repo-config.test.ts",
"packages/opencode/test/loop/loop.test.ts",
"packages/opencode/test/loop/personas.test.ts",
"packages/opencode/test/loop/queue-authority.test.ts",
"packages/opencode/test/loop/queue-mode.test.ts",
"packages/opencode/test/loop/similarity.test.ts",
"packages/opencode/test/loop/spec-queue.test.ts",
"packages/opencode/test/loop/branch-read.test.ts",
"packages/opencode/test/loop/work-source.test.ts",
"packages/opencode/test/util/git-branch-repo.test.ts",
"packages/opencode/test/mcp/fixtures.test.ts",
"packages/opencode/test/mcp/protocol-mode.test.ts",
Expand Down
30 changes: 27 additions & 3 deletions packages/opencode/src/cli/cmd/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ export const LoopCommand = effectCmd({
"Pass change slugs to restrict/order the queue; bare --queue takes every eligible change. " +
"Stuck changes are quarantined via .skein/blocker.md and the run continues.",
})
.option("auto", {
type: "boolean",
default: false,
describe:
"auto mode: work items across repositories from the work source (specsync or local openspec). " +
"Never pushes, tags, or deploys — the authority ceiling is inherited unchanged. " +
"Work comes from the work source: specsync query if bound, otherwise local openspec changes.",
})
.option("repos", {
type: "string",
array: true,
describe:
"auto mode: restrict which repositories to scan (comma-separated paths). " +
"By default, all sibling directories with openspec changes are scanned.",
})
.option("directory", {
type: "string",
describe:
"auto mode: directory to scan for repositories (default: the server's working directory). " +
"Sibling directories of this path with openspec changes are scanned.",
})
.option("max", {
type: "number",
alias: "n",
Expand Down Expand Up @@ -168,8 +189,9 @@ export const LoopCommand = effectCmd({
.command(LoopResumeCommand),
handler: Effect.fn("Cli.loop")(function* (args) {
const queueMode = args.queue !== undefined
const autoMode = args.auto === true
const prompt = args.prompt
if (!prompt && !queueMode) yield* fail("prompt is required (or pass --queue)")
if (!prompt && !queueMode && !autoMode) yield* fail("prompt is required (or pass --queue or --auto)")

const sdk = createOpencodeClient({ baseUrl: args.server })
const created = yield* Effect.promise(() =>
Expand All @@ -179,8 +201,8 @@ export const LoopCommand = effectCmd({
interval: args.interval,
noProgressLimit: args["stall-limit"],
completionToken: args["completion-token"],
mode: queueMode ? "queue" : undefined,
eternal: !queueMode && args.eternal === false ? false : undefined,
mode: queueMode ? "queue" : autoMode ? ("auto" as "prompt") : undefined,
eternal: !queueMode && !autoMode && args.eternal === false ? false : undefined,
queue: queueMode && args.queue && args.queue.length > 0 ? args.queue : undefined,
queueSync: queueMode && args.sync ? true : undefined,
queuePush: queueMode && args.push === false ? false : undefined,
Expand All @@ -194,6 +216,8 @@ export const LoopCommand = effectCmd({
verifyCommand: args["verify-command"],
}
: undefined,
autoDirectory: autoMode && args.directory ? args.directory : undefined,
autoRepos: autoMode && args.repos && args.repos.length > 0 ? args.repos : undefined,
}),
)
// Say WHY. "failed to create loop" on its own sent me hunting for a
Expand Down
82 changes: 82 additions & 0 deletions packages/opencode/src/loop/crew/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Routing a piece of work to warm capacity (crew-loop Phase 2a.1).
//
// Pure function over the peer roster and local host capacity. The decision
// order is: warm member (provider already serves the model, idle), then cloud
// member (not bound to a local host), then free host (reachable, free slot),
// otherwise leave it on the board.
//
// This is the "last branch" the design warns about: an agent that spawns when
// nothing is free feels productive and is not. "Leave it on the board" is a
// real outcome.

import type { PeerCandidate } from "../../peer/delegate"
import type { HostCapacity } from "../../local/placement"

export interface RouteInput {
/** The model ID this work needs. */
modelID: string
/** Live peers (opencode-skein and claude-code sessions). */
peers: readonly PeerCandidate[]
/** Local host capacity (reachable hosts with free slots). */
hosts: readonly HostCapacity[]
/** Provider IDs that are local (LAN/loopback). */
localProviderIDs: ReadonlySet<string>
}

export type RouteDecision =
| { kind: "warm"; peer: PeerCandidate }
| { kind: "cloud"; peer: PeerCandidate }
| { kind: "free-host"; host: HostCapacity }
| { kind: "board" }

/**
* Decide where to route a piece of work.
*
* Order: warm member > cloud member > free host > board.
* "Board" means: do not spawn; leave the item on the board for someone to
* pick up when capacity frees.
*/
export function route(input: RouteInput): RouteDecision {
const { modelID, peers, hosts, localProviderIDs } = input

// Warm: a peer whose provider already serves this model and is idle.
// "Serves the model" means the peer's provider is local and has the model
// loaded (or is idle — an idle local host can load it without a queue).
const warm = peers.filter((peer) => {
if (peer.status !== "idle") return false
if (!peer.provider) return false
if (!localProviderIDs.has(peer.provider)) return false
// The peer's provider is local and idle — it can serve the model.
return true
})
if (warm.length > 0) {
// Freshest warm member (most recently idle).
warm.sort((a, b) => (b.idleForMs ?? 0) - (a.idleForMs ?? 0))
return { kind: "warm", peer: warm[0] }
}

// Cloud: a peer not bound to a local host.
const cloud = peers.filter((peer) => {
if (peer.status !== "idle") return false
if (peer.owner === "claude-code") return true
if (peer.provider && !localProviderIDs.has(peer.provider)) return true
return false
})
if (cloud.length > 0) {
cloud.sort((a, b) => (b.idleForMs ?? 0) - (a.idleForMs ?? 0))
return { kind: "cloud", peer: cloud[0] }
}

// Free host: a reachable local host with a free slot.
const free = hosts.filter((h) => h.reachable && h.free > 0)
if (free.length > 0) {
// Most free slots first.
free.sort((a, b) => b.free - a.free)
return { kind: "free-host", host: free[0] }
}

// Nothing free: leave it on the board. Do not spawn into a queue.
return { kind: "board" }
}

export * as CrewRoute from "./route"
Loading
Loading