Skip to content

INTR-626 - Let provisioners place several workflow deployments on one sidecar - #207

Merged
FarinM merged 40 commits into
mainfrom
intr-626-let-provisioners-place-several-workflow-deployments-on-one
Oct 12, 2026
Merged

FarinM merged 40 commits into
mainfrom
intr-626-let-provisioners-place-several-workflow-deployments-on-one

Conversation

@FarinM

@FarinM FarinM commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #199

Why

The Hub reserved every sidecar for a single allocation. A sidecar credential authenticated exactly one probe or allocation, and every generation change or release closed the socket. A provisioner therefore had to start new capacity for every deployment, even when it already ran a sidecar that could host the work.

Whether work shares a sidecar is the provisioner's decision, not the Hub's. This PR lets a provisioner answer ensure with a sidecar it already runs. To make that safe, the sidecar link, mail, history pushes, and undeploy no longer assume one deployment per sidecar. The Hub also stops trying to recover a lost or missing deployment. A worker that misses its reconnect deadline may still be running, and the Hub cannot restore the files it holds. Starting the deployment again elsewhere could run it twice or lose that state, so the Hub fails the deployment and releases it instead. docs/SIDECAR_PLACEMENT.md has the provisioner contract.

What this adds

  • Sidecar reuse in the provisioner contract. EnsureSidecarResult takes an optional sidecarId. The Hub accepts that sidecar only while it still hosts another probe or allocation of the same provisioner binding, counting work that is being released or replaced. Otherwise it rejects the placement with sidecar_reuse_rejected. destroy releases one allocation from the sidecar. The provisioner decides when to stop the sidecar, and it has to track which allocations run on which sidecar durably, because the Hub never re-announces them.

  • One connection, many bindings. A sidecar credential now authenticates the sidecar for every binding it currently hosts. Each binding keeps its own generation fence. Releasing an allocation detaches and undeploys only that allocation, without closing the socket. Adopting a probe's sidecar no longer disconnects the probe.

  • hello / welcome handshake. register and reconnect are replaced by hello, which reports a bounded inventory of the incarnations the sidecar holds, each stamped with its generation. The sidecar holds back anything the Hub must receive until welcome arrives. Deploy and undeploy replies are matched by request id, with separate agent.deploy.error and agent.undeploy.error frames. Undeploys are ordered by generation, so an undeploy for a superseded generation never touches the newer copy.

  • No Hub-side recovery of a running deployment. A lost worker is failed and released, not replaced. A connected sidecar that no longer holds a deployment has that deployment failed, not redeployed. Replacement remains only for provisioning, which has run nothing yet. Initialization now serves only first deploys. A workflow-run seed that would move existing history is refused as a conflict.

  • Route rules on a shared sidecar. A reconnecting sidecar gets a deployment's route back only if its first deploy completed and its run has not ended. A copy whose deploy is still uncertain is undeployed. A copy whose run ended on its own stays unrouted until the Hub releases it, so a reconnect does not cut its retention short. A copy of a run the Hub cancelled or failed is undeployed, since a restart that lost its stopped mark may be running that run again.

  • Self-terminated deployments. A child that stops or fails to restore is marked stopped in its run record and reported in a deployment.stopped frame. The Hub reconciles its history, then fails its runs and keeps the copy for inspection. A stop the Hub drives also marks the record stopped instead of removing it. Undeploy marks the record tearing-down and removes it last, so a crash in the middle of an undeploy still leaves a record for the next boot to report.

  • Isolation between co-hosted deployments.

    • The sidecar's Hub URL, id, and token are no longer passed into workflow children.
    • A child can send mail only as its own deployment.
    • Mail between deployments on the same sidecar, or from a deployment to itself, is relayed through the Hub. Before, it went into a local inbox nothing read, while the Hub recorded it as delivered.

    This does not isolate co-hosted deployments from each other. Every child still runs as the sidecar's OS user - so it's up to the provisioner plugin to properly advertise whether it shares the sidecar.

  • Per-address frame lanes. The sidecar used to handle every Hub frame on one serial queue, so one slow deploy could hold up every other deployment on the sidecar, including the heartbeat pong. Frames for one address are still handled in order, but different addresses no longer wait on each other. Pongs and probes run on their own. Sender-key updates and inbound signature checks share a separate ordered lane, so a mail is checked against exactly the keys that arrived before it.

  • Long-lived sidecar hygiene. Undeploy now cleans up everything the deployment left on the sidecar: its run repository, grants repository, local conversation copy, push state, and directories a tool left read-only. It runs every teardown step even when one fails, and answers with an error naming the failed steps. Pack transfer ids are random, so a late answer from before a restart cannot settle a new transfer. Shared closure-cache entries stay pinned while callers read them.

  • Mail and push reliability on a shared link.

    • The Hub answers every workflow-run push it receives.
    • A disconnected push stays pending for recovery.
    • A first push is resent only for the repository-bootstrap race.
    • When the outage queue is full, the sidecar drops correlation registrations and audit copies first, and fails a send only when nothing else is left to drop.
    • Pending mail is kept when its deployment loses its route.
    • A stale mail retry cannot resend a mail that was already redelivered.
    • When the Hub gives up on dispatched mail, the mail is left to its dispatch row instead of being reported undelivered.
  • Capacity limits. A sidecar hosts at most 128 deployments, the most its hello can report. The Hub refuses to place another on a full sidecar, and the sidecar refuses a deploy past the limit. At boot the sidecar restores 8 deployments at a time and restores at most 128. Records past the limit stay unspawned and are reported stopped after every welcome.

  • Lifecycle additions.

    • maxDisconnected sits next to maxLifetime in the lifecycle policy, with the same inheritance and caps, a 15m default, and the WORKFLOW_DEFAULT_MAX_DISCONNECTED env var. It replaces the fixed 2-minute window, and a Hub start restarts the clock.
    • A first deploy that keeps failing before its frame is sent is failed 1 minute after its first failure, instead of being retried forever.
    • Runs the Hub fails now record failureCode and failureMessage.
  • Removed dead paths. Removed: sync.request, the undeploy's agent-state push (statePushed is gone from agent.undeploy.ack), the Hub's unused session-address routes, and the disconnect queue. The remaining retention setting is renamed to mailHoldTTLMs.

  • E2E coverage. The local-process provisioner can share processes between allocations. With E2E_SHARE_SIDECARS=tenant, a spec checks that two deployments run on one process.

Example: a provisioner that reuses sidecars

function startSidecar(req: EnsureSidecarRequest) {
  const sidecar = Bun.spawn(["bun", "run", "apps/sidecar/src/index.ts"], {
    env: {
      ...process.env,
      HUB_WS_URL: req.hubWebSocketUrl,
      SIDECAR_ID: req.sidecarId,
      SIDECAR_TOKEN: req.token,
      SIDECAR_DATA_DIR: `/var/lib/intx/${req.sidecarId}`,
    },
  });
  return { id: req.sidecarId, stop: () => sidecar.kill() };
}

function createSingleSidecarProvisioner(): SidecarProvisioner {
  let sidecar: ReturnType<typeof startSidecar> | null = null;
  const allocations = new Set<string>();

  return {
    id: "single-sidecar",
    apiVersion: 1,
    bindingFingerprint: "single-sidecar:v1",
    capabilities: [],

    async ensure(req) {
      if (sidecar === null) sidecar = startSidecar(req);
      allocations.add(req.allocationId);
      return { kind: "accepted", sidecarId: sidecar.id };
    },

    async destroy(req) {
      allocations.delete(req.allocationId);
      if (allocations.size === 0 && sidecar !== null) {
        sidecar.stop();
        sidecar = null;
      }
      return { kind: "destroyed" };
    },
  };
}

Every ensure answers with the one sidecar this provisioner runs. The first ensure starts it, because the Hub mints a sidecar's identity and token per request. The provisioner stops the sidecar when its last allocation is destroyed, since the Hub only accepts a sidecar that still hosts other work.

API changes

  • Tenant and installed-workflow lifecycle policies accept maxDisconnected, and the lifecycle status route returns it.
  • Workflow run views include failureCode and failureMessage.
  • The allocation status docs describe recovering: the Hub is replacing capacity whose provisioning failed before the deployment first ran.

Database migrations

  • 0101_sidecar_reuse drops sidecar_allocation_active_sidecar_idx, which allowed only one active allocation per sidecar. It adds a partial index on workflow_run.sidecar_id, because accepting a reused sidecar deletes the identity the Hub minted, and without the index each delete scans every run.
  • 0102_workflow_run_failure_reason adds nullable failure_code and failure_message to workflow_run.
  • 0103_sidecar_allocation_max_disconnected adds max_disconnected_ms to sidecar_allocation. Existing rows are backfilled to the 15-minute default, and the column then becomes NOT NULL with a check that it is > 0.
  • 0104_sidecar_allocation_first_deploy_failure adds nullable first_deploy_failed_at to sidecar_allocation. It records when a first deploy first failed, so the 1-minute limit counts from the same point across Hub restarts.

Reviewer notes

  • The wire protocol between Hub and sidecar changes (hello / welcome, typed deploy and undeploy replies, generation stamps). Hubs and sidecars from before this PR cannot talk to ones from after it.
  • The stock sidecar does not isolate co-hosted deployments: every child runs as the sidecar's OS user over its data directory. Sharing is only appropriate for work that is allowed to see each other's state.
  • Work placed on an existing sidecar gets the same 2-minute connect window as a new sidecar, even if that sidecar is restarting. A deployment that misses it is failed, which costs little because it has not run yet.
  • The first-deploy limit is short on purpose. Failures before the deploy frame is sent, such as a removed catalog offering or a credential that will not decrypt, rarely fix themselves, so a quick failure gets 2 retries 30 seconds apart. A restore that times out after its 120-second acknowledgement window gets 1 retry.
  • Most of the logic is in packages/hub-sessions/src/ws/sidecar-handler.ts, packages/hub-agent/src/ws/hub-link.ts, apps/sidecar/src/workflow-host-wiring.ts, and packages/db/src/sidecar-allocation-store.ts. The drizzle snapshots inflate the line count by about 18k lines.
  • Reviewing commit by commit is probably easier than reviewing the whole diff. The first four commits remove Hub-side recovery. Next come the multi-binding connection (a4d9c7023) and the reuse contract (5e4d4361f). The isolation, lane, and cleanup fixes follow, and the lifecycle limits are at the top.

@FarinM
FarinM added this pull request to stack #208 October 6, 2026 14:18
@linear

linear Bot commented Oct 6, 2026

Copy link
Copy Markdown

INTR-626

@FarinM
FarinM marked this pull request as ready for review October 6, 2026 14:41
Base automatically changed from intr-556-add-workflow-lifetime-and-capacity-retention-policies to main October 6, 2026 15:51
@FarinM
FarinM force-pushed the intr-626-let-provisioners-place-several-workflow-deployments-on-one branch from 32fadc2 to 14c8999 Compare October 6, 2026 16:04

@alexanderguy alexanderguy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pull-request review done by an agent, following the repository's pull-request-review flow on 14c89996.

Requesting changes on the four comments. The branch reads clearly as it stands: a provisioner may keep several deployments on a sidecar it already runs, hello stays within the incarnation cap, and a stop that reached disk stays unrouted. CI on run 37492819281 is green. The comments are where a restart or a reuse can still release a copy that should stay, or accept a placement after destroy has stopped the sidecar.

Comment thread packages/hub-sessions/src/ws/sidecar-token-authenticator.ts Outdated
Comment thread packages/db/src/sidecar-reuse.ts
Comment thread packages/hub-sessions/src/ws/sidecar-handler.ts
Comment thread packages/workflow-host/src/deploy/workflow-host-wiring.ts
@FarinM
FarinM force-pushed the intr-626-let-provisioners-place-several-workflow-deployments-on-one branch 2 times, most recently from 69cb9a2 to bdc01b8 Compare October 9, 2026 10:51
@alexanderguy
alexanderguy force-pushed the intr-626-let-provisioners-place-several-workflow-deployments-on-one branch from bdc01b8 to 2672cb7 Compare October 10, 2026 18:56

@alexanderguy alexanderguy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pull-request review done by an agent, on the rebased head. Requesting changes on the comment about a deployment the hello left out.

Comment thread packages/hub-sessions/src/ws/sidecar-handler.ts

@alexanderguy alexanderguy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

This is a pull-request review done by an agent. The open comment is followed up in INTR-643.

@FarinM
FarinM force-pushed the intr-626-let-provisioners-place-several-workflow-deployments-on-one branch from 2672cb7 to 2fe3a20 Compare October 10, 2026 23:42
FarinM added 18 commits October 11, 2026 16:28
A worker that misses its reconnect deadline may only be cut off from
the Hub and still running its deployment, and the Hub cannot restore
the files it holds. Starting the deployment again on new capacity
could run it twice or lose that state, so the opt-in replacement goes
and a lost worker is always failed and released. Replacement stays for
provisioning, which has run nothing yet.
Redeploying a missing copy could repeat effects or mix its local state
with the Hub's history, so a connected sidecar's missing deployment is
failed and released. Initialization now serves only first deploys, whose
anchors have no previous public key to clear, capture or restore.

With no redeploy left, the docs and comments that described one now
say a deployment keeps its deploy tree for its life.
The Hub's copy of a deployment's history only seeds its first deploy.
A sidecar that already has one of its branches holds what an earlier
copy left there, and moving that branch would silently mix the two
histories, so the transfer is rejected as a conflict. A branch already
at the sent commit, or holding only the empty start an earlier attempt
created, is still taken, so a retried first deploy succeeds.
Nothing on the Hub sends sync.request, and the Hub rejects every
agent-state pack a sidecar pushes, so the sync.request handler and the
state push an undeploy made before deleting the agent directory could
only fail. With no push left to report on, agent.undeploy.ack drops
its statePushed field.
Pack transfer ids came from counters that restart with their process,
so after a Hub restart a sidecar's late answer to a transfer the
previous Hub process sent could settle a new transfer that reused its
id. Random ids avoid reusing the same sequence across process restarts
on either end.
A sidecar credential authenticated exactly one probe or allocation,
and every generation change or release closed the socket, so a
provisioner could not place more than one deployment on a sidecar it
already runs. The credential now authenticates the sidecar for every
binding it currently hosts. Each binding keeps its own generation
fence, and leaving a connection detaches and undeploys only that
allocation instead of closing a socket the others still use. Adopting
a probe's sidecar no longer disconnects the probe's binding. When an
allocation is not yet attached to its connected sidecar, the reconciler
syncs that sidecar's bindings before it checks readiness again, since
the sidecar may have registered before the allocation was placed on it.

For the same reason a sidecar that announces a deployment it no longer
hosts is told to undeploy it rather than disconnected, and a frame a
probe's connection may not send is dropped by the per-address checks
rather than closing a socket that may also carry allocations.
A late stop or undeploy answer can arrive after another connection has
taken the address, so teardown must remove the route only while its
connection still owns it.
Allocated deployments use workflow addresses from their first deploy,
so the legacy session-address set stays empty. Remove it and its unused
queue, and rename the remaining delivery retention setting to
mailHoldTTLMs to describe the pending mail it still holds.
A late answer naming only an address could settle a newer request, so
each deploy and undeploy now has its own typed reply and request id.
Bound error replies and shorten locally produced errors so a worker
cannot make the Hub retain an arbitrarily large failure message.
Whether a probe or deployment shares a sidecar is the provisioner's
decision, but the Hub reserved every sidecar for a single allocation.
A provisioner can now answer ensure with an existing sidecar it runs
for the same binding. Releasing work on a shared sidecar does not stop
the sidecar, so a reconnecting sidecar takes a deployment's route back
only once its first deploy completed and while its run has not ended.
The Hub undeploys a copy whose deploy is still uncertain. A copy of a
run that has ended takes no route either, even when its own history
never recorded the end. One whose run ended on its own stays unrouted
until the Hub releases the deployment, so a reconnect does not cut its
retention short, but one of a run the Hub cancelled is undeployed: a
restart that finds its run record still on disk may be running that
run again.

Accepting a reused sidecar deletes the identity the Hub minted for the
request. Workflow runs reference sidecars, so their sidecar column gets
an index; without it each such delete reads every run ever stored.

Probe synchronization and readiness share one connection deadline.
Expiry cancels pending work and releases the probe's capacity hold.

Serialize reuse checks with removal of hosting bindings, so a placement
cannot commit against ownership that disappears during its check. Lock
sidecars in identifier order before their probes or allocations, and
keep probe adoption under that same lock through the handoff.
The sidecar passed its Hub URL, id and bearer token into every
workflow child's environment, though no child uses them. The token
authenticates the sidecar for every deployment it hosts, so anything
in one deployment that reads or passes on its environment, such as a
tool's subprocess, could hand out what it takes to connect as the
sidecar and take over the others. This keeps the token out of what a
child is given; it does not isolate the child, which as the sidecar's
OS user can still read the sidecar's own environment.
A sidecar's mail transport holds the signing key of every deployment
it hosts, and the supervisor handed the transport whatever sender its
child named, so a child could send mail signed as another deployment
on the same sidecar.
Signing can yield while a local recipient is unregistered, and skipping
that recipient silently loses its mail. Resolve every intended local
inbox before delivery so the send fails before any mail is delivered.
A sidecar registers every deployment it hosts on one mail transport to
sign their mail, and the transport delivered mail for any registered
address into a local inbox nothing reads. Mail from one deployment to
another on the same sidecar, or to itself, was lost while the Hub
recorded it as delivered. The sidecar's transport now relays every
recipient to the Hub, which routes it like any other mail.

A deployment's copy to itself now reaches the Hub as a recipient, so a
mail naming another deployment and copying its own sender names two
deployments, which the Hub refuses. The sidecar refuses such a send
itself, so the sender sees the error.
The Hub left a push unanswered when its connection no longer routed
the address, or when receiving the pack failed. The sender holds a
transfer open until it is answered and queues later pushes to the same
repository behind it, so one unanswered push held back the
deployment's history until the connection closed. The Hub now answers
every transfer once.
The sidecar kept each deployment's push state for as long as it ran. A
sidecar shared by many deployments runs long, so every undeployed one
left its state behind, and a push still queued for it went on shipping
to a Hub that no longer routes the address. Undeploy now drops it.
Probe materialization and deployment applies use separate cache roots,
with one shared cache instance for the callers of each root. Pin entries
through extraction and wait for removals in progress so callers cannot
evict files another is reading, even when that temporarily exceeds the
cache's size limit.
A sidecar handled every Hub frame on one serial queue, so a slow
deploy, undeploy, or probe held up every other deployment on the
sidecar and the heartbeat pong with it: a co-hosted deploy waited past
the Hub's request timeout, and a pong held for two ping intervals
dropped the link and every allocation the sidecar hosts.

Frames for one address are still handled one at a time in arrival
order, but frames for different addresses no longer wait on each
other. Pongs are handled as they arrive and probes run on their own.
Sender-key refreshes and evictions, the sender keys run grants carry,
and each inbound mail's signature check run on a lane of their own, in
the order the Hub sent them, so a mail is checked against exactly the
keys that arrived before it. The link now caches the keys run grants
carry and tells the grants handler whether they landed. A workflow
control that times out is reported as still initializing only while a
deploy of its own address is unanswered.
FarinM added 22 commits October 11, 2026 16:28
Undeploy kept a deployment's local conversation copy, though only that
deployment's child respawns and sidecar restarts read it. A sidecar
shared by many deployments runs long, so every undeployed one left its
copy behind. The documented undeploy steps also said the agent's key
pair was left in place, though undeploy deletes the agent directory
with the key pair in it.
A sidecar that failed part of an undeploy logged the failure and
acknowledged the undeploy anyway, so the Hub took the deployment for
gone while its child or local files could remain. A failed step also
skipped every later one, leaving the deployment's address registered.
The sidecar now runs every teardown step even when an earlier one
fails and answers agent.undeploy.error naming each step that failed.
An agent directory that is already gone is not a failure.
When the Hub stopped redelivering an unacknowledged mail, because its
retries ran out or its retention expired, it reported the mail
undelivered. A workflow dispatch row stands behind dispatched mail and
decides what becomes of it: it delivers the mail again once the
deployment is next ready, or is abandoned when the deployment ends.
Reporting the mail undelivered as well could announce a loss that did
not happen.
Redelivering an address's pending mail cancels each entry's retry
timer and arms a new one, but a retry whose timer had already fired
could still be waiting on its sender-key lookup. It then sent the mail
a second time and took the entry's retry over from the redelivery.
Each entry now names the retry loop that owns it, and a retry from a
loop a redelivery replaced sends nothing.
A shared sidecar can outlive an allocation generation, so addresses
alone cannot identify which deployment a frame belongs to. Stamp each
incarnation with its generation and reconcile a bounded inventory before
welcome confirms its routes, preserving the other work on the socket.

The inventory needs a handshake that carries it, so hello and welcome
replace register and reconnect, and the sidecar holds back anything the
Hub must receive until welcome arrives. A deployment that stops stays
held with its local state until the Hub undeploys it, so the inventory
reports it instead of losing it.

Undeploy is ordered by generation for the same reason: the Hub drops a
deployment's routes before undeploying it, a later deploy of an address
waits for the answer to an earlier undeploy, an undeploy runs even after
its connection closed, and one for a superseded generation is answered
without touching the newer copy. Mail with no dispatch row follows its
deployment to the next routed generation.

A deployment's outbound mail is signed and stamped through its held
incarnation, so stop and undeploy refuse new sends and wait for those in
flight before the incarnation is released. A token whose sidecar hosts
nothing current still resolves, so its hello can report what it holds
and the Hub can undeploy that before turning it away.
A stop or undeploy can remove a route while mail still awaits an
acknowledgement. Apply the same retention as a disconnect, allowing a
later route to recover it instead of announcing a premature loss.

A retry with no route now reports mail without a dispatch row as
undelivered, while dispatched mail is left to its durable row.
Losing the connection says nothing about the deployment's local write,
so leave the push due for recovery without poisoning the next write.
An explicit flush rejects while work remains blocked or was abandoned
by teardown, rather than claiming that unsent history has drained.
A crash during undeploy needs a surviving record for the next boot to
report, so the Hub can request cleanup again. Delete the run record
after the agent directory and local state to preserve that recovery
path.
Sharing sidecars is a provisioner decision, so the local-process
provisioner now takes an optional share key and places work with the
same key on one running sidecar, stopping it when its last allocation
leaves. The e2e hub shares by tenant when E2E_SHARE_SIDECARS=tenant,
and a spec checks that two deployments then run on one process.
A deployment's first push to a repository is resent once if it fails,
to absorb the Hub creating the repository during the push, but every
failure triggered the resend, including a push the Hub refused
because it does not route the address. A rejected transfer now
carries the receiver's reason, and only a corrupt reject, which that
race produces, is resent.
The link queues what it owes the Hub while no connection is welcomed,
and dropped the oldest frame once the queue was full, so a long outage
could drop mail its sender had already been told was queued. The queue
is shared by every deployment on the sidecar, so one busy deployment
could cost another its mail. A full queue now drops correlation
registrations, which the next welcome re-sends, and then audit copies
of delivered mail, and fails a send when only mail still to be routed
remains.
A hello reports every incarnation a sidecar holds and is refused past a
bound, but nothing bounded how many deployments a provisioner could
place on one sidecar. A sidecar past the bound could never register
again, retrying forever with every deployment on it unroutable. The Hub
now refuses to place a deployment on a sidecar that already hosts 128,
and the sidecar refuses a deploy past them. The bound drops from 512 to
128 because a restarting sidecar restores every deployment it holds
before it connects.

A boot reads run records in name order and restores at most 128,
leaving the rest unspawned and unreported for the Hub to fail. The
same records are left out on every boot over an unchanged set.
An undeploy deleted the run record but left the deployment's
workflow-run repository on disk, so on a sidecar that keeps running
across deployments its data directory grew without bound. The
repository store can now remove a repository together with what it
keeps in memory about it, and undeploy removes a single-step
deployment's grants repository and, as its last step, the run
repository with the record inside it.
When the Hub fails a deployment's runs because it lost or gave up its
capacity, the reason was kept only on the allocation, which is then
released. The anchor run, and each run failed for that reason, now
keep the failure code and message too, and the run view reports them
once the failure no longer waits on the deployment's history.
A tool can leave read-only directories that make every cleanup retry
fail. Restore directory write permission as the owning sidecar user
before retrying removal, so undeploy can reclaim that scratch state.
A child that stopped or failed to restore could disappear from the
sidecar's inventory and restart from its old record. Persist and report
its stopped state, then reconcile its history before the Hub fails the
runs and retains the copy for inspection. Bound the reported error and
history tips so an untrusted sidecar cannot inflate what the Hub holds
until it fails the deployment.

A stop the Hub drives also marks the record stopped instead of removing
it, and undeploy marks it tearing-down until every other step succeeds,
so a restart reports what it holds rather than reviving a run the Hub
ended. A reconnecting copy of a run the Hub failed is undeployed like a
cancelled one, even when accepted history is still being projected and
its status remains live. A restart that lost its mark may be running it.

Serialize record updates and removal so a self-termination mark cannot
race a source rotation or cleanup. Update only sources during rotation
to preserve lifecycle state and sealed credentials, and reject updates
to missing records or another generation.

Records past the hello limit at boot stay unspawned and are reported
as stopped after every welcome rather than going unreported, so the Hub
fails them like any other stopped deployment.

Keep unrestored addresses reserved until teardown succeeds, and reject
undeploys for older generations before touching their local state.
Suppress delayed stop reports once the copy they describe is gone.
A sidecar connects only once it has restored every deployment it
holds, and it restored them one at a time, each child given up to 30
seconds to become ready, so a full sidecar could take over an hour to
come back. Restoring 8 at a time bounds a full sidecar's ready timeouts
to about 8 minutes in total. More at once compete for CPU and memory on
a small host until children miss their ready timeout.
The Hub gave every disconnected sidecar two minutes to come back before
it failed the sidecar's deployments, too short for a sidecar restoring
many deployments and not something a tenant could choose. The limit is
now maxDisconnected, next to maxLifetime in the lifecycle policy,
inherited and capped the same way, with a 15-minute platform default.
Each allocation records its deployment's limit. A Hub start restarts
the clock: a deadline stored before the Hub went down used to fail a
sidecar that had no chance to reconnect during the outage.
A first deploy that failed before its deploy frame was sent was retried
at the capped backoff for as long as it kept failing, holding its
capacity and leaving its run live with nothing running it. The Hub now
records when such a deploy first failed and, a minute later, fails
the deployment and releases its capacity like any other unrecoverable
loss. Failures around a deployment that already runs keep being
retried without a limit, as they say nothing about the deployment.
The test resumed a second run under the same run id while the first
was still relaying, so the two shared a commit buffer and the first
run's later commits could land inside the second.
A parked run read its log, then started tailing it for the signal. A
signal committed in between moved the step in the log but never
reached the tail, so the run waited forever. A restored run hits this
when the signal arrives while it re-parks. Start the wait first, then
read the log, and take a signal the log already gave the step.
@FarinM
FarinM force-pushed the intr-626-let-provisioners-place-several-workflow-deployments-on-one branch from 2fe3a20 to 847c405 Compare October 11, 2026 14:41
@FarinM
FarinM requested a review from alexanderguy October 11, 2026 15:00
@FarinM
FarinM merged commit f9c9401 into main Oct 12, 2026
6 checks passed
@FarinM
FarinM deleted the intr-626-let-provisioners-place-several-workflow-deployments-on-one branch October 12, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants