Workstream Extension¶
What happens when a workstream finishes the tree it was given without covering the objective it was given for.
This page is the authority on the extension mechanism. Initiative
Tail owns the tail machinery it fires alongside
(replan trigger, stall escalation, the integrate/evaluate stages); Recursive
Decomposition owns PlanItem.unsplit_reason,
the signal this mechanism reads; Plan Review owns the
pre-dispatch reading of the same field.
Not the build loop's Slice planning stage
The Build Loop names a future, unbuilt "slice planning" stage that plans the next slice of a tree before building it, with its own trunk merge, gate profile and finding channel. This mechanism ships today, acts strictly after a leaf has already been dispatched and completed, and reuses none of that design. The two names collided during review, so this page's mechanism is called an "extension" throughout, with "graft" kept as the verb for the mechanical append operation.
The problem¶
Approval-time decomposition plans a workstream's whole tree up front, and a
depth, session, or turn-budget backstop can stop a unit's split before it is
genuinely atomic (see Recursive Decomposition's
"The reported condition"). That unit still dispatches and can still
complete: the backstop bounded how far the split went, not whether the work
that shipped covers what the unit claimed. PlanItem.unsplit_reason records
exactly this gap, and until this mechanism existed, nothing read it back:
a workstream whose last such leaf completed was read as fully delivered, and
the two remedies available (an operator raising the backstop's bound, or a
reviewer narrowing the objective) both require a human to have noticed the
flag on the item's card before dispatch. A workstream that finishes
unattended never gets either.
Concept¶
Workstream: not a new entity, exactly PlanTree.workstreams, the plan's
top-level (parentless) PlanItems.
Extension: a further decomposition of one already-dispatched,
already-completed leaf's remaining claimed scope, grafted as new children
under that leaf. The leaf becomes a container; its own task, already
COMPLETED, is left untouched.
The trigger question is narrower than "is the workstream's objective met":
a workstream needs an extension once its entire known subtree is
terminal (workstream_needs_extension, in
engine/initiative/extension_state.py) and at least one of its completed
leaves still carries unsplit_reason (leaf_needs_extension). Both are
pure derivations over already-persisted facts, so neither is a judgement
call and neither needs a judged check.
Data model¶
No new persisted entity, no new table, no migration, no new PlanStatus.
Plan.items gains new rows per extension, appended under a version guard
(_append_extension in engine/initiative/extension_graft.py); PlanTree,
item_is_done, collect_item_progress, and the rollup all keep reading
Plan.items unchanged. A plan mid-extension is simply EXECUTING: the
newly-grafted items are not yet done, so derive_plan_status's own
all-done check naturally holds the plan there, with no branch added to that
function.
A workstream's extension count is derived, never stored:
workstream_extension_generation counts the workstream's descendants that
are both a container and still carry unsplit_reason. The field is never
cleared once written (see PlanItem.unsplit_reason's own docstring), which
is exactly what makes this count derivable at all: clearing it on graft
would erase the history the count depends on. The graft's own trailing
assembly child (below) never carries the field, so it cannot inflate the
count.
Trigger flow¶
Fired from ProjectRollupService.recompute(), in rollup_stages.py's
extensions_hold/drive_extensions, before derive_plan_status runs on the
same pass: that derivation promotes a plan to INTEGRATING the moment
every known item is done, with no workstream-level distinction. If
a workstream needs an extension, grafting adds new, not-yet-terminal items
first, so the same-pass derive_plan_status call correctly reads the plan
as not yet all-done.
For each of a workstream's leaves still needing an extension, the loop asks
whether this organisation may graft one unasked
(ReplanTriggerService.consider_extension, delegating to
extension_graft.consider_extension):
ALREADY_RUNNING: an extension is already in flight for this workstream (see in-flight keying below); this ask collapses into it.DISABLED: the automatic-authority switch (engine.auto_extension_enabled) is off.BUDGET_EXHAUSTED: the per-workstream generation cap (engine.auto_extension_max_generations) is spent.ASKED: the deterministic autonomy gate applies (below); a decision is parked and nothing is grafted automatically.GRAFTED: none of the above; a detached graft starts.UNAVAILABLE: the in-flight tracker could not start the detached work at this moment.
GRAFTED, ALREADY_RUNNING, and UNAVAILABLE
(EXTENSION_IN_PROGRESS_DISPOSITIONS) hold the plan at EXECUTING this
pass. ASKED holds the plan only if an escalation service is attached, by
parking one decision (below). DISABLED and BUDGET_EXHAUSTED end this
leaf's road: no automatic route remains for it, and the workstream is left
as delivered, with this leaf's remaining scope surfaced later at the judged
EVALUATING gate, the same place any other unmet objective surfaces,
rather than by a second parking mechanism.
Two doors¶
consider_extension and grant_extension are the same two-door pattern
ReplanTriggerService.consider/grant already use for a stall: the
organisation acting unasked applies every guard above, while a person's own
decision (approving a parked ask, or an operator granting one directly)
bypasses all three guards, since the switch, the cap, and the autonomy gate
all bound what the org does unasked, and somebody has just asked. Only
ALREADY_RUNNING still applies to grant_extension: granting one while
another is in flight for the same workstream would be a second, uncoordinated
dispatch rather than an answer to anything.
An APPROVED decision is re-applied through grant_extension on every
rollup pass it is still seen (rollup_stages.py's drive_extensions),
because unlike a stall's grant (which supersedes the whole plan and ends the
loop for it), granting an extension leaves the plan at EXECUTING and the
same leaf recurs in workstream_needs_extension's own answer until it either
gains children or is refused outright.
In-flight keying is per workstream, not per leaf¶
Both doors key their in-flight tracking on
f"extension:{plan.id}:{workstream.id}" (_in_flight_key), not the leaf.
The generation cap is read from the workstream's whole subtree, so two of
its oversized leaves considered on the same rollup pass would otherwise both
read the same pre-graft generation count and both pass the cap, landing one
extension too many between them. Keying on the workstream serialises them:
the second leaf's ask reads ALREADY_RUNNING and defers to a later pass, by
which time the first extension has either landed (changing the count) or
failed (freeing the key).
This also settles what would otherwise be a retry-budget question:
_append_extension retries a version conflict once (two attempts total).
That budget is sized for exactly one concurrent writer per workstream, which
the per-workstream keying enforces; it is not sized for N-way concurrency
across sibling leaves, because that concurrency no longer exists once two
leaves under one workstream cannot extend at the same time.
Deterministic gate¶
A new ActionType.PLAN_EXTEND_WORKSTREAM = "plan:extend_workstream"
(security/autonomy/enums.py) names "graft another extension onto a live,
still-executing workstream" as its own gated action, deliberately excluded
from WORKTREE_CONFINED_ACTION_TYPES: unlike an ordinary code or docs
change, this widens what the organisation may build unasked past what was
originally approved, so a bare "code"/"docs" grant under a built-in
autonomy preset never auto-approves it.
consider_extension decides GRAFTED vs. ASKED by explicit membership
only (auto_approved, in extension_autonomy.py): the action type must
appear in the plan's effective auto_approve_actions, never inferred from
its mere absence from human_approval_actions (that inference would
auto-graft under every preset that never names the action type at all,
which is every preset but LOCKED and FULL as shipped). An autonomy that
could not be resolved (no resolver wired, or the project row unreadable)
fails closed to the gate applying.
The assembly item¶
Grafting a leaf children makes it a container, but that leaf's own task is
already COMPLETED from its original dispatch, and item_progress.py maps
exactly one task per plan item by a derived id, so the leaf's existing task
cannot be rewritten into the assembly job a real container would need.
_extension_assembly_item (in extension_graft.py) solves this by minting
one more ordinary child alongside the extension's own new items: a
fresh-uuid4()-id PlanItem, parented under the leaf, depending on every
newly-grafted sibling so it runs last, carrying no children of its own so it
reads as an ordinary work item to assembly_of/task_from_item. It is
built through the same build_assembly/assembly_title machinery any other
container's assembly uses, addressed at the leaf's own tree position. It
never carries unsplit_reason, so it neither reads as a leaf still awaiting
its own extension nor inflates the generation count above.
Escalation¶
ExtensionEscalationService (extension_escalation.py) is parallel to
StallEscalationService, not a reuse of it: an extension decision and a
stall decision resolve differently (one grafts more work onto a live
workstream; the other replans or ends the whole initiative) and must not
share one idempotency key. It raises exactly one initiative:extension_ask
decision per leaf (re-checking its own idempotency inside escalate()
before writing, since the rollup's own pre-check and this write are not
atomic), and exposes open_decisions(plan), one store scan per rollup pass
reusable across every leaf found needing a check, plus the module-level
decision_for(decisions, leaf) filter, rather than one scan per leaf.
Answering the decision routes through
api/controllers/_approval_initiative_extension.py::try_initiative_extension_resume,
parallel to the stall controller and claimed before the review-gate flow in
signal_resume_intent's dispatch chain for the same reason: an unclaimed
decision carrying the objective task's id would otherwise reach the review
gate and be read as a plain completion review.
Subsystem and settings¶
CapabilityId.INITIATIVE_EXTENSION_ESCALATION attaches onto the already-wired
rollup once PROJECT_ROLLUP_SERVICE, PERSISTENCE, and APPROVAL_STORE are
all up, independently of the sibling stall-escalation subsystem: the two
raise different decisions under different idempotency keys, so one being
wired says nothing about the other, and each is read by its own liveness
probe (see Initiative Tail). Absent, the mechanism does
not hold the plan at EXECUTING for an ask it cannot park; the gap is
surfaced later at the judged EVALUATING gate instead.
| key | default | purpose |
|---|---|---|
coordination.jit_extension_planning_enabled |
false |
master switch, unvalidated by any live round, unlike recursion itself |
engine.auto_extension_enabled |
true |
automatic-authority switch, on the same shape auto_replan_enabled ships |
engine.auto_extension_max_generations |
2 |
per-workstream generation cap, mirroring auto_replan_max_generations |
The master switch is deliberately the only one of the three defaulting off.
Flipping it is the one conscious decision that arms unattended grafting;
the other two then behave exactly as their auto_replan_* counterparts
already do, rather than requiring a second gate an operator must also
remember to flip.
Out of scope¶
- The judged, escalate-only "is this extension a real departure from the forecast" check. The gate above is deterministic only.
- Retiring
INTEGRATING/EVALUATINGor the whole-tree final assembly. Untouched by this mechanism; a separate concern needing the trunk invariant the build loop's own aspirational design depends on. - Reconnaissance, the finding channel, and the build loop's own slice planning and gate-ratchet state machine (see the warning above).