Context
The Fleet Manager repo-provisioning chain is complete on dev — deriveAgentRepoPath → inspectAgentRepo → provisionAgentRepo → ensureAgentRepo (#13145 / #13148 / #13155 / #13162). It is a four-leaf pipeline that, given an agent + a managed root + a clone URL, lands a stable, collision-free, traversal-safe checkout without ever clobbering an occupant.
But nothing consumes it yet. FleetLifecycleService.start(id) — the "run the agent" leaf — imports only FleetRegistryService; it never calls ensureAgentRepo. The chain I built is dead code until a caller wires it into the spawn path. This ticket is that wire.
The Problem
Two concrete gaps, both verified against origin/dev:
The spawned harness runs in an undefined working directory. start() spawns with this.getSpawnFn()(command, args, {stdio, env}) — no cwd. The child therefore inherits the Fleet Manager's own cwd, not the agent's checkout. An FM-spawned harness that is meant to operate on its repo has no idea where that repo is. This is the in-miniature form of the epic's stated friction (#13015): "if i start 5 claude desktop instances plus codex via terminal and manage 6 agent repos… brittle" — the paths are load-bearing (auto-memory is checkout-path-keyed) and right now the launch does not even set one.
Provisioning is manual. Starting an agent assumes someone already cloned its repo to the right path by hand. The whole point of the provisioning chain is to make "start an agent" turnkey: clone-or-reuse the canonical checkout, then launch the harness in it.
The Architectural Reality
ai/services/fleet/FleetLifecycleService.mjs — start(id) is synchronous, builds {stdio, env}, and spawns. The JSDoc explicitly scopes it to "process supervision + harness-auth provisioning" and defers repo/identity provisioning to "a separate provisioning leaf" — this ticket is that leaf. No external caller of start() exists yet (only doc-references in provisionAgentRepo.mjs), so widening its options object has zero blast radius.
ai/services/fleet/ensureAgentRepo.mjs — async ensureAgentRepo({managedRoot, agentId, repoSlug, cloneUrl, cloneRepo}) → {repoPath, state, action, cloned}. The composer feeds it from the agent's metadata + an FM-level managed root and uses the returned repoPath as the spawn cwd.
ai/services/fleet/FleetRegistryService.mjs — an agent definition is {id, githubUsername, harnessType, metadata, …} where metadata is documented free-form (the metadata.launch = {command, args} convention already lives there). The agent's working-repo coordinates ride the same channel: metadata.repo = {cloneUrl, repoSlug}. No registry-schema change.
The Fix
A new composer + a minimal primitive on the supervisor:
ai/services/fleet/startAgentProvisioned.mjs (new, standalone — sibling to ensureAgentRepo): async startAgentProvisioned({lifecycleService, managedRoot, agentId, cloneRepo, ensureRepo = ensureAgentRepo}). Reads the agent's metadata.repo; if present → await ensureRepo({managedRoot, agentId, repoSlug, cloneUrl, cloneRepo}) then lifecycleService.start(agentId, {cwd: repoPath}); if absent → lifecycleService.start(agentId) (backward-compatible plain start). A provisioning throw propagates and the harness is never spawned (fail-closed: no launch into an unprovisioned / conflicting directory). Short-circuits a redundant provision when the agent is already running.
FleetLifecycleService.start(id, opts = {}) gains an optional opts.cwd, passed through to the spawn options; absent → today's behavior exactly. A process supervisor letting its caller set the working directory is an obviously-correct primitive; the opinionated provisioning composition stays in the composer (and out of the registry-owned service).
ensureRepo is an injectable seam (default-real, mirroring provisionAgentRepo's cloneRepo and the service's spawnFn) so the composer is unit-testable without a git binary or the filesystem.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
FleetLifecycleService.start(id, opts) |
this ticket |
new optional opts.cwd → spawn option |
omitted ⇒ inherited cwd (unchanged) |
start() JSDoc |
origin/dev start() spawns {stdio, env}, no cwd (verified) |
startAgentProvisioned({…}) (new) |
this ticket |
ensureRepo → start(id, {cwd: repoPath}); no metadata.repo ⇒ plain start; provision-throw ⇒ no spawn |
fail-closed (no launch) |
module JSDoc + spec |
ensureAgentRepo signature verified on origin/dev |
agent.metadata.repo = {cloneUrl, repoSlug} |
this ticket (convention) |
the agent's working-repo coordinates the composer reads |
absent ⇒ no provisioning |
composer JSDoc |
metadata is free-form per FleetRegistryService.defineAgent |
Decision Record impact
none — no ADR governs FM spawn provisioning. Aligned-with the #13015 MVP decomposition (this is the repo-provisioning consumer leaf).
Acceptance Criteria
Out of Scope
- Spawn-time identity-env + wake-subscription provisioning (gated on cross-harness session-id canonicalization — the other deferred leaf named in the
start() JSDoc).
- Promoting
metadata.repo to a first-class registry-schema field (free-form metadata is the documented extension point; promotion is a later refinement if the convention proves load-bearing — flagged for the registry owner's input at review).
- Wiring
managedRoot to a concrete FM config source / the MCP tool / settings-pane entry point (the composer takes it as a param; the turnkey caller supplies it later).
- Any change to
provisionAgentRepo / inspectAgentRepo / clone semantics.
Avoided Traps
- Absorbing async provisioning into
start() (making it async) — rejected: it would break start()'s synchronous contract for all future callers and bury an opinionated FM convention inside the registry-owned supervisor. The composer keeps start() a pure, sync, cwd-aware process primitive.
- A first-class
repoUrl registry field — rejected for now: a schema change in a peer-owned service for a convention that metadata already carries cleanly.
Related
- Parent epic: #13015 (Fleet Manager MVP — define, start, observe). This is the leaf that makes "start" provision its repo.
- Consumes: #13162 (
ensureAgentRepo), #13145 / #13148 / #13155 (the provisioning trio).
- Touches
FleetLifecycleService (#13049) and reads the metadata convention from FleetRegistryService (#13031) — FYI to the registry/lifecycle author at review.
Decision Record impact
none.
Live latest-open sweep: checked the live tracker (open issues + in-flight A2A [lane-claim] window) immediately before filing; no equivalent — the only active claim is an unrelated marathon-benchmark lane.
Authored by Claude Opus 4.8 (Claude Code), @neo-opus-ada.
Origin Session ID: 4c598c8f-d8a7-4288-9420-e825a45d310e
Retrieval Hint: "Fleet Manager spawn provisioning startAgentProvisioned cwd ensureAgentRepo"
Context
The Fleet Manager repo-provisioning chain is complete on
dev—deriveAgentRepoPath→inspectAgentRepo→provisionAgentRepo→ensureAgentRepo(#13145 / #13148 / #13155 / #13162). It is a four-leaf pipeline that, given an agent + a managed root + a clone URL, lands a stable, collision-free, traversal-safe checkout without ever clobbering an occupant.But nothing consumes it yet.
FleetLifecycleService.start(id)— the "run the agent" leaf — imports onlyFleetRegistryService; it never callsensureAgentRepo. The chain I built is dead code until a caller wires it into the spawn path. This ticket is that wire.The Problem
Two concrete gaps, both verified against
origin/dev:The spawned harness runs in an undefined working directory.
start()spawns withthis.getSpawnFn()(command, args, {stdio, env})— nocwd. The child therefore inherits the Fleet Manager's own cwd, not the agent's checkout. An FM-spawned harness that is meant to operate on its repo has no idea where that repo is. This is the in-miniature form of the epic's stated friction (#13015): "if i start 5 claude desktop instances plus codex via terminal and manage 6 agent repos… brittle" — the paths are load-bearing (auto-memory is checkout-path-keyed) and right now the launch does not even set one.Provisioning is manual. Starting an agent assumes someone already cloned its repo to the right path by hand. The whole point of the provisioning chain is to make "start an agent" turnkey: clone-or-reuse the canonical checkout, then launch the harness in it.
The Architectural Reality
ai/services/fleet/FleetLifecycleService.mjs—start(id)is synchronous, builds{stdio, env}, and spawns. The JSDoc explicitly scopes it to "process supervision + harness-auth provisioning" and defers repo/identity provisioning to "a separate provisioning leaf" — this ticket is that leaf. No external caller ofstart()exists yet (only doc-references inprovisionAgentRepo.mjs), so widening its options object has zero blast radius.ai/services/fleet/ensureAgentRepo.mjs—async ensureAgentRepo({managedRoot, agentId, repoSlug, cloneUrl, cloneRepo})→{repoPath, state, action, cloned}. The composer feeds it from the agent's metadata + an FM-level managed root and uses the returnedrepoPathas the spawn cwd.ai/services/fleet/FleetRegistryService.mjs— an agent definition is{id, githubUsername, harnessType, metadata, …}wheremetadatais documented free-form (themetadata.launch = {command, args}convention already lives there). The agent's working-repo coordinates ride the same channel:metadata.repo = {cloneUrl, repoSlug}. No registry-schema change.The Fix
A new composer + a minimal primitive on the supervisor:
ai/services/fleet/startAgentProvisioned.mjs(new, standalone — sibling toensureAgentRepo):async startAgentProvisioned({lifecycleService, managedRoot, agentId, cloneRepo, ensureRepo = ensureAgentRepo}). Reads the agent'smetadata.repo; if present →await ensureRepo({managedRoot, agentId, repoSlug, cloneUrl, cloneRepo})thenlifecycleService.start(agentId, {cwd: repoPath}); if absent →lifecycleService.start(agentId)(backward-compatible plain start). A provisioning throw propagates and the harness is never spawned (fail-closed: no launch into an unprovisioned / conflicting directory). Short-circuits a redundant provision when the agent is already running.FleetLifecycleService.start(id, opts = {})gains an optionalopts.cwd, passed through to the spawn options; absent → today's behavior exactly. A process supervisor letting its caller set the working directory is an obviously-correct primitive; the opinionated provisioning composition stays in the composer (and out of the registry-owned service).ensureRepois an injectable seam (default-real, mirroringprovisionAgentRepo'scloneRepoand the service'sspawnFn) so the composer is unit-testable without a git binary or the filesystem.Contract Ledger Matrix
FleetLifecycleService.start(id, opts)opts.cwd→ spawn optionstart()JSDocorigin/devstart()spawns{stdio, env}, nocwd(verified)startAgentProvisioned({…})(new)ensureRepo→start(id, {cwd: repoPath}); nometadata.repo⇒ plainstart; provision-throw ⇒ no spawnensureAgentReposignature verified onorigin/devagent.metadata.repo = {cloneUrl, repoSlug}metadatais free-form perFleetRegistryService.defineAgentDecision Record impact
none— no ADR governs FM spawn provisioning. Aligned-with the #13015 MVP decomposition (this is the repo-provisioning consumer leaf).Acceptance Criteria
FleetLifecycleService.start(id, opts)passesopts.cwdto the spawn options when present; omitting it reproduces today's spawn exactly (covered by an extendedFleetLifecycleService.spec).startAgentProvisionedwith ametadata.repoprovisions via the injectedensureRepoand callsstartwith{cwd: repoPath}.startAgentProvisionedwith nometadata.repocallsstartwith no cwd (backward-compatible).ensureRepopropagates andstartis not called (fail-closed — no spawn into a bad dir).statuswithout a redundant provision.missing lifecycleService/agentId/ (repo present but)managedRooteach throw a clear error.test/playwright/unit/ai/startAgentProvisioned.spec.mjs, green via the custom unit config;node --checkon both touched files.Out of Scope
start()JSDoc).metadata.repoto a first-class registry-schema field (free-form metadata is the documented extension point; promotion is a later refinement if the convention proves load-bearing — flagged for the registry owner's input at review).managedRootto a concrete FM config source / the MCP tool / settings-pane entry point (the composer takes it as a param; the turnkey caller supplies it later).provisionAgentRepo/inspectAgentRepo/ clone semantics.Avoided Traps
start()(making it async) — rejected: it would breakstart()'s synchronous contract for all future callers and bury an opinionated FM convention inside the registry-owned supervisor. The composer keepsstart()a pure, sync, cwd-aware process primitive.repoUrlregistry field — rejected for now: a schema change in a peer-owned service for a convention thatmetadataalready carries cleanly.Related
ensureAgentRepo), #13145 / #13148 / #13155 (the provisioning trio).FleetLifecycleService(#13049) and reads themetadataconvention fromFleetRegistryService(#13031) — FYI to the registry/lifecycle author at review.Decision Record impact
none.Live latest-open sweep: checked the live tracker (open issues + in-flight A2A
[lane-claim]window) immediately before filing; no equivalent — the only active claim is an unrelated marathon-benchmark lane.Authored by Claude Opus 4.8 (Claude Code), @neo-opus-ada. Origin Session ID: 4c598c8f-d8a7-4288-9420-e825a45d310e Retrieval Hint: "Fleet Manager spawn provisioning startAgentProvisioned cwd ensureAgentRepo"