Skip to content

feat: Add WrappedAction.Cancelation.* wrap-hook template variables#313

Closed
leongdl wants to merge 1 commit into
OpenJobDescription:mainlinefrom
leongdl:rfc008-cancelation
Closed

feat: Add WrappedAction.Cancelation.* wrap-hook template variables#313
leongdl wants to merge 1 commit into
OpenJobDescription:mainlinefrom
leongdl:rfc008-cancelation

Conversation

@leongdl

@leongdl leongdl commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

RFC 0008 follow-up (openjd-specifications #148): a wrap hook (e.g. a container runtime wrapper) needs to know the cancelation contract of the action it replaced, so it can honor the same TERMINATE / NOTIFY_THEN_TERMINATE behavior the runtime would have enforced in the unwrapped case. The model had no way to surface the wrapped action's <Cancelation> to wrap hooks.

What was the solution? (How)

One squashed commit adding the WrappedAction.Cancelation.* template variables to the 2023-09 model:

  • WrappedAction.Cancelation.Mode (string?): "TERMINATE", "NOTIFY_THEN_TERMINATE", or null when the wrapped action declares no <Cancelation> — the null case is deliberately distinct from an explicit TERMINATE so wrap scripts can tell "author declared TERMINATE" apart from "author declared nothing".
  • WrappedAction.Cancelation.NotifyPeriodInSeconds (int?): the effective grace period with the Template Schemas 5.3.2 defaults applied; null when not applicable.
  • Format-string cancelation modes (FEATURE_BUNDLE_1) follow normal format-string semantics per review feedback on openjd-specifications feat!: compatibility with pydantic v2 #148: any format string is statically valid, the resolved value is checked against the two mode names at run time, and a whole-field expression resolving to null drops the cancelation object. Resolution is deferred to run time, mirroring the matching openjd-rs change.

What is the impact of this change?

Wrap hooks can forward the wrapped action's cancelation contract (e.g. into docker exec timeout flags). Purely additive to the model surface; gated on the FEATURE_BUNDLE_1 extension for the format-string mode.

How was this change tested?

  • Full unit suite: 5328 passed, 24 skipped, 3 xfailed (hatch run test on macOS), on a clean hatch env.
  • The openjd-sessions-for-python RFC 0008 branch consumes these variables and resolves them through its enforcement path; the four wrap-cancelation OpenJD conformance fixtures (wrap-cancelation-*) exercise the end-to-end behavior and pass once this change is available to the sessions/CLI stack.

Comment thread src/openjd/model/v2023_09/_model.py Outdated
# The mode is the schema selector, so partial interpolation
# like "TERMIN{{X}}" has no meaningful semantics: require a
# literal or a single whole-field expression.
if not (raw.startswith("{{") and raw.endswith("}}") and raw.count("{{") == 1):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The whole-field guard has a hole: it accepts any string that starts with {{, ends with }}, and contains exactly one {{. A value like "{{X}}Y}}" passes all three checks (startswith("{{") ✓, endswith("}}") ✓, count("{{") == 1 ✓) but is actually a partial interpolation — the expression {{X}} followed by the literal text Y}}. That is exactly the "partial interpolation has no meaningful semantics for a schema selector" case this validator is meant to reject (see test_partial_fmtstring_mode_rejected); such a value can never resolve to TERMINATE/NOTIFY_THEN_TERMINATE/null.

Since it starts with {{, _cancelation_discriminator still routes it to CancelationMethodDeferred, so it slips through parse-time validation and only fails (if at all) at runtime resolution. Consider also requiring raw.count("}}") == 1, or validating that the string is exactly one whole-field expression with no surrounding literal text.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ddaf000 — the guard now also requires raw.count("}}") == 1, so with both end anchors and exactly one opening and one closing brace pair, only {{ ... }} with no surrounding literal text is admitted; "{{X}}Y}}" (one open, two closes) is rejected at parse time with the whole-field error. The test_partial_fmtstring_mode_rejected test is now parametrized over both the leading-text (TERMIN{{X}}) and trailing-text ({{X}}Y}}) shapes.

The Rust guard in structure.rs had the identical hole and got the same fix (openjd-rs eec3f5e). Interesting asymmetry found while testing: Rust's expression parser already rejects the trailing }} on its own ("Missing opening braces"), so the stricter guard is defense-in-depth there — but Python's parser treats the trailing text as a literal, so this guard fix is load-bearing here.

@leongdl
leongdl force-pushed the rfc008-cancelation branch from 06ab186 to ddaf000 Compare July 17, 2026 05:06
cancelation: Optional[
Union[CancelationMethodNotifyThenTerminate, CancelationMethodTerminate]
] = Field(None, discriminator="mode")
cancelation: Optional[CancelationMethod] = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The RFC 0008 wrapped-variable scope check does not inspect cancelation. _action_referenced_namespaces (lines 544-548) only walks command, args, and timeout, so WrappedAction.* / WrappedEnv.* / WrappedStep.* referenced inside cancelation.mode or cancelation.notifyPeriodInSeconds are invisible to _validate_wrapped_variable_scope.

Concretely, a template that puts cancelation: {mode: "{{WrappedAction.Cancelation.Mode}}"} on an ordinary onEnter/onExit action (not a wrap hook) will be accepted, contradicting CancelationMethodDeferred's own docstring: "at parse time the validator still checks ... that WrappedAction.* is only referenced inside wrap hooks." The new deferred cancelation is precisely what makes such references possible in this field, so the scope enforcement should be extended to cover the cancelation sub-model's FormatString fields.

@seant-aws

Copy link
Copy Markdown
Contributor

this may be an issue:

RFC 0008 says the special variables WrappedAction.*, WrappedEnv.*, WrappedStep.* may only appear inside the three wrap hooks (onWrapEnvEnter, onWrapTaskRun, onWrapEnvExit). A validator enforces this at template-parse time. That validator delegates "which wrapped namespaces does this action reference?" to a helper, and the helper has a hardcoded list of fields to scan that predates this PR.

The PR added a new place format strings can live (cancelation.mode, cancelation.notifyPeriodInSeconds) but didn't add it to that list. Is this okay ?

@seant-aws seant-aws left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

one comment above

@AlexTranAmz

Copy link
Copy Markdown

I saw all tests stop at validate/decode and do not advance to create_job? could we do so as well to make sure the flow is good e2e?

@leongdl
leongdl force-pushed the rfc008-cancelation branch from be5e1cb to e780df9 Compare July 21, 2026 23:38
return "terminate"
if isinstance(v, CancelationMethodDeferred):
return "deferred"
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Switching cancelation from pydantic's native Field(..., discriminator="mode") (a Literal-tagged union) to this callable Discriminator degrades the error message for an invalid literal mode.

Previously, a template with cancelation: {mode: "TERMINATEX"} (or any typo / wrong-case value) produced a clear, actionable error like Input should be 'NOTIFY_THEN_TERMINATE' or 'TERMINATE'. Now _cancelation_discriminator falls through all branches and returns None for such a value, so pydantic raises the generic union_tag_invalid error (Unable to extract tag using discriminator ...), which does not tell the author what the valid modes are.

Since a bad mode string is a common authoring mistake, consider having the discriminator route unrecognized non-format-string values to one of the fixed-shape classes (e.g. terminate) so the field validator emits the specific enum error, or otherwise surface the allowed mode names in the failure.

…C 0008 follow-up (openjd-specifications OpenJobDescription#148): expose the wrapped action's cancelation description to wrap hooks so a wrapper (e.g. a container runtime) can honor the same cancelation contract the runtime would have enforced in the unwrapped case: - WrappedAction.Cancelation.Mode (string?): TERMINATE, NOTIFY_THEN_TERMINATE, or null when the wrapped action declares no <Cancelation> — deliberately distinct from an explicit TERMINATE. - WrappedAction.Cancelation.NotifyPeriodInSeconds (int?): the effective grace period, applying the Template Schemas 5.3.2 defaults when the field is omitted; null when not applicable. - Format-string cancelation modes (FEATURE_BUNDLE_1) follow normal format-string semantics: any format string is statically valid, the resolved value is checked against the two mode names at run time, and a whole-field expression resolving to null drops the cancelation object. Resolution is deferred to run time, mirroring openjd-rs.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
mode: FormatString
notifyPeriodInSeconds: Optional[Union[NotifyPeriodType, FormatString]] = None # noqa: N815

_job_creation_metadata = JobCreationMetadata(resolve_fields={"notifyPeriodInSeconds"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CancelationMethodDeferred declares resolve_fields={"notifyPeriodInSeconds"}, so create_job will eagerly resolve this field against the job-creation symbol table. But the whole point of the deferred cancelation is forwarding, e.g.

cancelation:
  mode: "{{WrappedAction.Cancelation.Mode}}"
  notifyPeriodInSeconds: "{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}"

WrappedAction.* symbols are seeded only at session runtime (they are not in the create_job symbol table — _create_job.py populates it from job parameters only). Resolving {{WrappedAction.Cancelation.NotifyPeriodInSeconds}} there will hit FullNameNode.evaluate’s "... has no value" / the EXPR undefined-symbol path and raise a FormatStringError out of create_job.

Note Action.command/args avoid this precisely because they are not in resolve_fields (which is why test_create_job_with_wrap_env_succeeds passes with {{WrappedAction.Command}} in args). The forwarding round-trip tests (test_full_cancelation_forwarding_accepted, test_notify_period_only_forwarding_accepted) only call _decode, never create_job, so this path is uncovered. Please add a create_job test over a forwarded-cancelation wrap env; I suspect notifyPeriodInSeconds must be kept unresolved at job-creation time (like command/args) and resolved by the sessions runtime instead — mirroring the timeout-forwarding case the docstring already defers.

leongdl added a commit to leongdl/openjd-model-for-python that referenced this pull request Jul 23, 2026
…Description#313

Port the test-only additions of PR OpenJobDescription#313: None-value rendering in
format strings and nodes (RFC 0005 null rendering) and the
TestCancelationRoundTripForwarding cases for wrap-hook cancelation
forwarding (RFC 0008). These pin behavior whose production code OpenJobDescription#318
already carries, so OpenJobDescription#313 can close as superseded.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl

leongdl commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #318: all of this PR's production code is contained in #318's tip (verified hunk-by-hunk — the model changes are byte-identical, and the WrappedAction.Cancelation.* injection there uses the stricter per-hook scoping this PR's first-cut comment deferred). The test additions from this PR (deferred-cancelation round-trip forwarding, None-rendering) were ported to #318 in commit 6edc75a. Closing once #318 merges; the 4 wrap-cancelation conformance fixtures need sessions-runtime eager validation (openjd-sessions follow-up), not this PR.

@leongdl

leongdl commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #318 (production code contained there, tests ported in 6edc75a — see previous comment for the containment audit). The rfc008-cancelation branch remains available if ever needed.

@leongdl leongdl closed this Jul 23, 2026
leongdl added a commit that referenced this pull request Jul 24, 2026
Bring the pure-Python (v0) 2023-09 model to EXPR (RFC 0007) and
WRAP_ACTIONS (RFC 0008) parity with openjd-rs, via the PyO3-bound Rust
expression engine. Base of the RFC 0008 stack (openjd-sessions-for-python
#333, openjd-cli #230). Supersedes #313 (production code contained here;
its tests ported).

- Deferred cancelation mode (FEATURE_BUNDLE_1): a format-string `mode`
  becomes CancelationMethodDeferred (mirrors CancelationMode::DeferredMode);
  the shape decision moves to run time so wrap hooks can forward
  "{{WrappedAction.Cancelation.Mode}}".
- Typed whole-field resolution (RFC 0006): FormatString.resolve_value +
  typed_resolve_fields metadata; range: "{{Param.Values}}" with a LIST[*]
  parameter instantiates to the native list, evaluated exactly once per
  field. §3.4's 1024-value range cap enforced at job creation for
  expression-driven ranges (parity with openjd-rs ranges.rs).
- Per-hook WRAP_ACTIONS variables via _template_field_inject
  (WrappedAction.*/WrappedEnv.Name/WrappedStep.Name visible only in their
  hook) and template-scope validation of timeout/cancelation via
  _template_field_scopes.
- EXPR runtime plumbing: step-level `let` at job creation (extends_symtab,
  preserved on Step.let), Job.Name seeding (§7.3.1), URI-form PATH values,
  exported parse-memoized openjd.model.evaluate_let_bindings.
- Performance: EXPR engine symbol table + host-context profile cached on
  SymbolTable keyed on a mutation version — 8.6 -> 1.9 ms per 20-arg
  action (removes the per-expression Rust-boundary rebuild).
- Crates pinned to the published releases containing DeferredMode:
  openjd-expr 0.2.1, openjd-model 0.4.0, openjd-sessions 0.4.0.

Validation tightenings (all matching openjd-rs and the spec; not purely
additive): onEnter required without WRAP_ACTIONS (§3.5); 1024-value range
caps (§3.4); 64-char identifier names without FEATURE_BUNDLE_1 (§7.1) and
decimals-requires-SPIN_BOX (§2.4).

Tested: 5394 passed / 24 skipped / 3 xfailed (coverage 94.4%); mypy,
black, ruff, clippy -D warnings clean; conformance 2023-09 at 1154/1158
(remaining 4 need sessions-runtime eager validation, tracked separately);
new §3.4/§7.3.1 conformance fixtures in openjd-specifications#155
validated against both implementations.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants