Add parallel sidecars - #451
Conversation
Introduce running work across multiple sidecars at once: - chunk sidecar create --count N creates several sidecars in parallel and sets them as the active group (a single sidecar is just a group of one). - chunk sidecar run fans a command out across the active group (or an explicit --sidecar-ids set), with per-sidecar labelled output, an N/M passed summary, and --json results. - chunk sidecar sync builds one bundle and delivers it to every sidecar in the group in parallel. - chunk validate distributes remote-marked commands across a group of sidecars (one per command) and runs them in parallel, reusing an existing active group when the count matches, then runs local commands. Falls back to local execution on unreachable/misconfigured sidecars unless freshly created. Active-sidecar state stores a SidecarIDs slice (with legacy sidecar_id read compatibility) so single- and multi-sidecar flows share one representation and code path throughout sync, state, and the validate executor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- sidecar sync: route single-sidecar active state through incremental BundleSync instead of BundleSyncFanOut (len > 0 → len > 1), avoiding a full-bundle regression for existing single-sidecar users - setupSidecarGroup: save partially-created sidecars before returning an error so they aren't orphaned on partial creation failure - --checkout error: add suggestion pointing to --sidecar-id workaround Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Will be superseded by the async exec rewrite; the parallel validate and sync fan-out work in this PR stands on its own without it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| if err != nil { | ||
| return err | ||
| } | ||
| if len(ids) > 1 { |
There was a problem hiding this comment.
--sidecar-ids with exactly one ID is silently ignored. When resolveSidecarGroupIDs returns a one-element slice, this branch is skipped and execution falls through to resolveSidecarID, which ignores the provided ID and resolves from --sidecar-id (empty) or the active sidecar instead.
Concrete failure: chunk sidecar sync --sidecar-ids=sb-target with a different active sidecar syncs to the wrong sidecar with no warning.
| if len(ids) > 1 { | |
| if len(ids) == 1 { | |
| sidecarID = ids[0] | |
| } else if err := resolveSidecarID(cmd.Context(), &sidecarID); err != nil { | |
| return err | |
| } |
| io.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Deleted sidecar %s", sidecarID))) | ||
|
|
||
| if active, lerr := sidecar.LoadActive(cmd.Context()); lerr == nil && active != nil && active.SidecarID == sidecarID { | ||
| if active, lerr := sidecar.LoadActive(cmd.Context()); lerr == nil && active != nil && active.ID() == sidecarID { |
There was a problem hiding this comment.
Deleting a non-primary sidecar from an active group leaves stale active state. This check clears the active file only when the deleted ID matches active.ID() (the first element). For an active group [sb-1, sb-2], deleting sb-2 leaves the group file intact — subsequent sync or validate calls fan-out to both IDs and fail on the deleted one.
Should check whether sidecarID appears anywhere in active.SidecarIDs, not just the primary slot. Same issue exists in the snapshot delete command (~line 951).
| authSock := os.Getenv(config.EnvSSHAuthSock) | ||
| err := sidecar.BundleSyncFanOut(ctx, client, opts.sidecarIDs, opts.identityFile, authSock, opts.workdir, workDir, statusFn) | ||
| if err != nil { | ||
| return &userError{msg: "Could not sync to sidecars.", err: err} |
There was a problem hiding this comment.
syncToSidecarsOpts swallows the NoOriginRemoteError hint. All errors from BundleSyncFanOut are wrapped with the generic "Could not sync to sidecars." message, but the single-sidecar sync path and newSidecarSyncCmd's fan-out path both check for this error type and surface msgNoOriginRemote + suggestionAddOrigin. Group-mode validate in a repo without origin gives no actionable guidance.
| return &userError{msg: "Could not sync to sidecars.", err: err} | |
| if _, ok := errors.AsType[*sidecar.NoOriginRemoteError](err); ok { | |
| return &userError{msg: msgNoOriginRemote, suggestion: suggestionAddOrigin, err: err} | |
| } | |
| return &userError{msg: "Could not sync to sidecars.", err: err} |
… only checks primary ID, NoOriginRemoteError swallowed in group sync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
--count Ntochunk sidecar createto spin up multiple sidecars in parallel and set them as the active groupchunk sidecar syncto deliver one bundle to all sidecars in the active group in parallelchunk validateto automatically provision one sidecar per remote command when two or more remote commands are configured (reuses an existing group of the right size)activeinresolveSidecarID, missing error path in sync fan-out, and a race in parallel create result collectionTest plan
chunk sidecar create --count 3creates three sidecars and sets them as the active groupchunk sidecar syncdelivers the bundle to all sidecars in the active group in parallelchunk validatewith two or more remote commands creates matching sidecars and runs them in parallel--count, no group — existing behaviour unchanged)task testtask acceptance-test🤖 Generated with Claude Code