diff --git a/.conductor/registry/workflows/apex-item-dispatch.yaml b/.conductor/registry/workflows/apex-item-dispatch.yaml index 467db188..a3e3db78 100644 --- a/.conductor/registry/workflows/apex-item-dispatch.yaml +++ b/.conductor/registry/workflows/apex-item-dispatch.yaml @@ -146,11 +146,20 @@ output: # ── Renegotiation bubble-up (PR #144 — plan-level outputs) ── # Only plan-level emits these; the other lifecycles leave them # undefined. The `is defined` guards collapse to safe defaults. + # + # `renegotiation_request` is bubbled up from + # PlanExtractRenegotiationFlagResult.RenegotiationRequest which is + # `string?` and OMITTED when null. plan-level.yaml's output: block + # always emits the field as a string (post-fix at the plan-level + # source) but if plan-level itself crashes mid-run, the .output + # dict can be partial. Two-level guard is defense-in-depth against + # any future plan-level failure mode that leaves outputs incomplete. + # Cf. plan-level.yaml output: block for the source-of-truth fix. renegotiation_pending: >- {%- if plan_level_dispatch is defined -%}{{ (plan_level_dispatch.output.renegotiation_pending | default(false)) | string | lower }} {%- else -%}false{%- endif -%} renegotiation_request: >- - {%- if plan_level_dispatch is defined -%}{{ plan_level_dispatch.output.renegotiation_request | default('') }} + {%- if plan_level_dispatch is defined and plan_level_dispatch.output.renegotiation_request is defined -%}{{ plan_level_dispatch.output.renegotiation_request }} {%- else -%}{%- endif -%} validate_scope_verdict: >- {%- if plan_level_dispatch is defined -%}{{ plan_level_dispatch.output.validate_scope_verdict | default('') }} diff --git a/.conductor/registry/workflows/github-pr.yaml b/.conductor/registry/workflows/github-pr.yaml index b9e243af..38b4f4d8 100644 --- a/.conductor/registry/workflows/github-pr.yaml +++ b/.conductor/registry/workflows/github-pr.yaml @@ -55,8 +55,18 @@ output: # Lowercase-coerce so the parent workflow's _maybe_parse_json sees # 'true'/'false' rather than the capital 'True'/'False' Jinja produces # for Python booleans (see conductor-mechanics M7). - merged: "{{ (pr_merger.output.merged | default(false)) | string | lower }}" - pr_url: "{{ pr_merger.output.pr_url | default('') }}" + # + # pr_merger may not run — review_router routes around it on + # changes_requested, on rev-cap exhaustion (force-abort), and on + # human-gate abort. Per M3 (StrictUndefined), guard `pr_merger is + # defined` first; without the guard, accessing pr_merger.output + # raises TemplateError on every non-merge terminal. + merged: >- + {%- if pr_merger is defined -%}{{ (pr_merger.output.merged | default(false)) | string | lower }} + {%- else -%}false{%- endif -%} + pr_url: >- + {%- if pr_merger is defined -%}{{ pr_merger.output.pr_url | default('') }} + {%- else -%}{%- endif -%} agents: # ── PR reviewer ──────────────────────────────────────────────────────── diff --git a/.conductor/registry/workflows/implement-pg.yaml b/.conductor/registry/workflows/implement-pg.yaml index b4ebf456..369e0db9 100644 --- a/.conductor/registry/workflows/implement-pg.yaml +++ b/.conductor/registry/workflows/implement-pg.yaml @@ -69,8 +69,18 @@ output: # several routes (pr_router action='all_complete', pr_lifecycle merged= # false, dependency_gate reassign, user_acceptance abort) reach $end # without running scope_closer. + # + # pr_submit is an LLM agent whose `pr_url` field is NOT marked + # `required: true` in its output schema, so the LLM may emit a + # well-formed envelope without the field. The `pr_submit is defined` + # guard alone is insufficient; we also need to check + # `pr_submit.output.pr_url is defined` to avoid StrictUndefined on a + # missing dict key. Cf. plan-level.yaml output: block for the same + # trap on a verb-side nullable field. merged: "{% if scope_closer is defined %}{{ (scope_closer.output.exit_code == 0) | string | lower }}{% else %}false{% endif %}" - pr_url: "{% if pr_submit is defined %}{{ pr_submit.output.pr_url | default('') }}{% else %}{% endif %}" + pr_url: >- + {%- if pr_submit is defined and pr_submit.output.pr_url is defined -%}{{ pr_submit.output.pr_url }} + {%- else -%}{%- endif -%} agents: # ── PG router ────────────────────────────────────────────────────────── diff --git a/.conductor/registry/workflows/plan-level.yaml b/.conductor/registry/workflows/plan-level.yaml index de4fd6e2..b14cc6fe 100644 --- a/.conductor/registry/workflows/plan-level.yaml +++ b/.conductor/registry/workflows/plan-level.yaml @@ -144,20 +144,41 @@ tools: # M7 booleans are piped through `| string | lower` so the parent receives # a real bool rather than capital "True"/"False". # +# StrictUndefined trap (2026-05-10 dogfood AB#3067, plan-PR #260): +# ` is defined` only checks the agent variable, NOT the dict keys +# under `.output`. When a verb's Result model declares `string? Foo` +# (omitted from JSON when null per WhenWritingNull), accessing +# `.output.foo` RAISES TemplateError — `| default('')` does not +# rescue, because StrictUndefined fires on the missing dict key BEFORE +# the filter runs. So nullable verb fields require a TWO-LEVEL guard: +# ` is defined and .output. is defined`. Required +# fields (e.g. `flag_present`, `verdict`) are always emitted and only +# need the agent-level guard. Cf. PlanDeriveAncestorChainResult.cs which +# uses `[JsonIgnore(Condition = Never)]` to dodge the same trap from the +# verb side. +# # Design rationale — option (C) in docs/decisions/scope-renegotiation.md # (handler design — workflow output bubble-up). Today plan-level has no # caller, so these outputs go nowhere; that is intentional. apex-driver # (a downstream PR) consumes them. output: + # FlagPresent is `required bool` in PlanExtractRenegotiationFlagResult + # — always emitted, agent-level guard suffices. renegotiation_pending: >- {% if extract_renegotiation_flag is defined %}{{ (extract_renegotiation_flag.output.flag_present | default(false)) | string | lower }} {%- else %}false{% endif %} + # RenegotiationRequest is `string?` — OMITTED from JSON when no flag + # was present. Two-level guard is mandatory; see the trap note above. renegotiation_request: >- - {% if extract_renegotiation_flag is defined %}{{ extract_renegotiation_flag.output.renegotiation_request | default('') }} + {% if extract_renegotiation_flag is defined and extract_renegotiation_flag.output.renegotiation_request is defined %}{{ extract_renegotiation_flag.output.renegotiation_request }} {%- else %}{% endif %} + # Verdict is `required string` in PlanValidateScopeResult — always + # emitted, agent-level guard suffices. validate_scope_verdict: >- {% if validate_scope is defined %}{{ validate_scope.output.verdict | default('') }} {%- else %}{% endif %} + # FilesOutOfScope is non-nullable IReadOnlyList with default + # `Array.Empty()` — always emitted, agent-level guard suffices. scope_violation_files: >- {% if validate_scope is defined %}{{ validate_scope.output.files_out_of_scope | default([]) | tojson }} {%- else %}[]{% endif %}