From d45db70e99662a65fc2d3a4f106fc5d143933afc Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:12:11 +0900 Subject: [PATCH 1/9] fix(gate): cancel foreground producer process trees --- README.md | 2 +- core/schema/operation.schema.json | 38 +++- runtime/bin/gate-supervisor.sh | 23 +++ runtime/bin/pr-gate.sh | 22 ++- runtime/lib/pmctl-gate.sh | 64 ++++++- runtime/lib/pmctl-operation.sh | 279 ++++++++++++++++++++++++++-- tests/shell/test-pmctl-gate.sh | 145 ++++++++++++++- tests/shell/test-pmctl-operation.sh | 82 ++++++++ 8 files changed, 633 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 97d719e5..9f3acd25 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ router and [`cli/commands.tsv`](cli/commands.tsv). - `gate run` — Start the pull-request gate. [experimental; JSON: false; mutating: true] - `gate verify` — Verify a gate result artifact. [experimental; JSON: false; mutating: false] - `gate wait` — Wait for a detached gate run. [experimental; JSON: false; mutating: true] -- `gate cancel` — Cancel a gate parent operation and only its recorded child runs. [experimental; JSON: false; mutating: true] +- `gate cancel` — Stop the verified gate producer process tree, then cancel only its recorded child runs; partial termination stays indeterminate. [experimental; JSON: false; mutating: true] - `gate reconcile` — Reconcile a gate operation from trusted child terminal claims. [experimental; JSON: false; mutating: true] - `context index` — Build the repository context index. [experimental; JSON: false; mutating: true] - `context update` — Update the repository context index. [experimental; JSON: false; mutating: true] diff --git a/core/schema/operation.schema.json b/core/schema/operation.schema.json index a1a8c786..97a73e82 100644 --- a/core/schema/operation.schema.json +++ b/core/schema/operation.schema.json @@ -12,7 +12,43 @@ "state": {"enum": ["running", "cancelling", "completed", "failed", "cancelled", "indeterminate"]}, "created_ts": {"type": "string", "format": "date-time"}, "terminal_ts": {"type": ["string", "null"], "format": "date-time"}, - "cancellation": {"type": ["object", "null"]} + "producer": { + "type": ["object", "null"], + "required": ["status", "phase", "identity", "expected_ts", "registered_ts", "stopped_ts"], + "properties": { + "status": {"enum": ["pending", "running", "stopping", "stopped"]}, + "phase": {"const": "gate"}, + "identity": { + "type": ["object", "null"], + "required": ["pid", "state", "pgid", "starttime", "comm", "isolated", "boot_id"], + "properties": { + "pid": {"type": "integer", "minimum": 1}, + "state": {"type": "string", "minLength": 1}, + "pgid": {"type": "integer", "minimum": 1}, + "starttime": {"type": "string", "pattern": "^[0-9]+$"}, + "comm": {"type": "string"}, + "isolated": {"type": "boolean"}, + "boot_id": {"type": "string"} + }, + "additionalProperties": false + }, + "expected_ts": {"type": "string", "format": "date-time"}, + "registered_ts": {"type": ["string", "null"], "format": "date-time"}, + "stopped_ts": {"type": ["string", "null"], "format": "date-time"} + }, + "additionalProperties": false + }, + "cancellation": { + "type": ["object", "null"], + "required": ["requested_ts", "actor", "child_failures"], + "properties": { + "requested_ts": {"type": "string", "format": "date-time"}, + "actor": {"const": "pmctl"}, + "producer_failures": {"type": ["integer", "null"], "minimum": 0}, + "child_failures": {"type": ["integer", "null"], "minimum": 0} + }, + "additionalProperties": false + } }, "additionalProperties": false } diff --git a/runtime/bin/gate-supervisor.sh b/runtime/bin/gate-supervisor.sh index 2ac4e920..0749f610 100755 --- a/runtime/bin/gate-supervisor.sh +++ b/runtime/bin/gate-supervisor.sh @@ -114,6 +114,29 @@ done mkdir -p "$run_dir" || _die "failed to create run dir: $run_dir" +_cancel_supervisor() { + _write_sentinel "cancelled" 130 "" + exit 130 +} +trap _cancel_supervisor TERM INT + +if [[ -n "${PM_GATE_PARENT_OPERATION:-}" ]]; then + # Register the already-isolated supervisor as the producer before readiness + # or any pre-review work is published. A cancellation that won the launch + # race records the identity but refuses to release pr-gate.sh. + # shellcheck source=/dev/null + . "$REPO_ROOT/runtime/lib/pmctl-operation.sh" + _producer_register_rc=0 + pmctl_operation_register_producer "$REPO_ROOT" gate "$PM_GATE_PARENT_OPERATION" \ + "$cd_arg" "$$" || _producer_register_rc=$? + if [[ "$_producer_register_rc" -eq 130 ]]; then + _write_sentinel "cancelled" 130 "" + exit 130 + fi + [[ "$_producer_register_rc" -eq 0 ]] \ + || _die "failed to register detached producer identity" +fi + _write_ready || _die "failed to publish supervisor readiness evidence" # ── Run pr-gate.sh out-of-band, capturing its stdout for result-path discovery ─ diff --git a/runtime/bin/pr-gate.sh b/runtime/bin/pr-gate.sh index 765b4c03..3e78cc48 100755 --- a/runtime/bin/pr-gate.sh +++ b/runtime/bin/pr-gate.sh @@ -2,6 +2,13 @@ set -euo pipefail trap '' PIPE +GATE_CANCELLED=false +gate_cancel_signal() { + GATE_CANCELLED=true + exit 130 +} +trap gate_cancel_signal TERM INT + # say -- emit a progress/diagnostic line on stdout that tolerates a closed pipe. # # A consumer that reads a prefix of our stdout and closes the pipe early @@ -2796,6 +2803,8 @@ if [[ -n "$INITIAL_RESULT_RESOLVED" \ exit 2 fi _gate_assurance_destination_check "$ASSURANCE_FILE" || exit 2 +GATE_OUTPUT_EXISTED=false +[[ -e "$OUTPUT_FILE" ]] && GATE_OUTPUT_EXISTED=true touch "$OUTPUT_FILE" # Track all brief files for EXIT cleanup @@ -2849,7 +2858,18 @@ gate_exit_cleanup() { # Relocate first (preserves the result artifact out-of-repo for post-mortem on failure # paths), then drop transient briefs. Both are idempotent / no-ops on the success path # where relocation already ran inline. - relocate_gate_artifacts + if [[ "$GATE_CANCELLED" == true ]]; then + # Cancellation is an operation terminal, not a reviewer verdict. Do not + # publish an empty/partial result that a later consumer could mistake for a + # late gate outcome; operation state remains the cancellation evidence. + rm -f -- "$WORK_DIR/.gate-results/"*"${TIMESTAMP}"* 2>/dev/null || true + if [[ "$GATE_OUTPUT_EXISTED" != true ]]; then + rm -f -- "$OUTPUT_FILE" + fi + rmdir "$WORK_DIR/.gate-results" 2>/dev/null || true + else + relocate_gate_artifacts + fi cleanup_briefs } trap gate_exit_cleanup EXIT diff --git a/runtime/lib/pmctl-gate.sh b/runtime/lib/pmctl-gate.sh index 73e7c30e..04b7c788 100644 --- a/runtime/lib/pmctl-gate.sh +++ b/runtime/lib/pmctl-gate.sh @@ -175,7 +175,9 @@ pmctl_gate_run() { local _arg for _arg in "$@"; do if [[ "$_arg" == "-h" || "$_arg" == "--help" ]]; then - exec "$gate_script" "$@" + gate_rc=0 + "$gate_script" "$@" || gate_rc=$? + exit "$gate_rc" fi done @@ -214,6 +216,10 @@ pmctl_gate_run() { _i=$((_i + 1)) fi done + effective_cd="$(cd "$effective_cd" 2>/dev/null && pwd -P)" || { + printf 'pmctl gate run: --cd is not an accessible directory: %s\n' "$effective_cd" >&2 + return 2 + } # pmctl-owned workflow boundary: refresh the generic repo context cache # before dispatch. pr-gate.sh remains repo-agnostic and knows nothing about @@ -241,6 +247,12 @@ pmctl_gate_run() { return 2 } export PM_GATE_PARENT_OPERATION="$_gate_parent_operation" + if ! pmctl_operation_expect_producer "$repo_root" gate "$_gate_parent_operation" "$effective_cd"; then + printf 'pmctl gate run: failed to reserve producer ownership under %s\n' \ + "$_gate_parent_operation" >&2 + pmctl_operation_fail_if_childless "$repo_root" gate "$_gate_parent_operation" "$effective_cd" >&2 || true + return 2 + fi printf 'pmctl gate run: parent operation: %s\n' "$_gate_parent_operation" >&2 else # Standalone/copy-mode test fixtures deliberately carry only the gate shim. @@ -288,12 +300,58 @@ pmctl_gate_run() { # parent operation is converged from trusted child terminal claims. `exec` # here used to strand every foreground gate in `running` forever. local _gate_rc=0 _reconcile_rc=0 + local -a _foreground_args=("${run_dir_args[@]}") if [[ "$has_cd" == false ]]; then - "$gate_script" "${run_dir_args[@]}" --cd "$effective_cd" "$@" || _gate_rc=$? + _foreground_args+=(--cd "$effective_cd" "$@") else - "$gate_script" "${run_dir_args[@]}" "$@" || _gate_rc=$? + _foreground_args+=("$@") fi if [[ -n "${PM_GATE_PARENT_OPERATION:-}" ]]; then + if ! command -v setsid >/dev/null 2>&1; then + printf 'pmctl gate run: foreground producer isolation requires setsid; gate was not started\n' >&2 + pmctl_operation_fail_if_childless "$repo_root" gate "$PM_GATE_PARENT_OPERATION" "$effective_cd" >&2 || true + return 2 + fi + # Keep the synchronous user experience while isolating the complete gate + # producer tree from the invoking shell. The new session registers its + # own kernel identity before execing pr-gate.sh; cancellation can therefore + # stop preflight/timeout descendants without accepting a caller-supplied PID. + setsid bash -c ' + repo_root="$1"; work_dir="$2"; operation_id="$3"; gate_script="$4" + shift 4 + # shellcheck source=/dev/null + . "$repo_root/runtime/lib/pmctl-operation.sh" + register_rc=0 + pmctl_operation_register_producer "$repo_root" gate "$operation_id" "$work_dir" "$BASHPID" \ + || register_rc=$? + if [[ "$register_rc" -eq 130 ]]; then + exit 130 + fi + if [[ "$register_rc" -ne 0 ]]; then + printf "pmctl gate run: failed to register foreground producer identity for %s\n" \ + "$operation_id" >&2 + exit 2 + fi + exec "$gate_script" "$@" + ' _ "$repo_root" "$effective_cd" "$PM_GATE_PARENT_OPERATION" "$gate_script" \ + "${_foreground_args[@]}" & + local _producer_pid=$! + wait "$_producer_pid" || _gate_rc=$? + else + "$gate_script" "${_foreground_args[@]}" || _gate_rc=$? + fi + if [[ -n "${PM_GATE_PARENT_OPERATION:-}" ]]; then + if pmctl_operation_cancellation_requested "$repo_root" gate \ + "$PM_GATE_PARENT_OPERATION" "$effective_cd"; then + if [[ "${PMCTL_OPERATION_CANCELLATION_STATE:-}" == indeterminate ]]; then + printf 'pmctl gate run: operation %s cancellation is indeterminate; inspect operation evidence (exit 2)\n' \ + "$PM_GATE_PARENT_OPERATION" >&2 + return 2 + fi + printf 'pmctl gate run: operation %s cancelled; foreground producer stopped (exit 130)\n' \ + "$PM_GATE_PARENT_OPERATION" >&2 + return 130 + fi pmctl_operation_reconcile "$repo_root" gate "$PM_GATE_PARENT_OPERATION" --cd "$effective_cd" >&2 || _reconcile_rc=$? if [[ "$_reconcile_rc" -ne 0 ]]; then # A pre-flight failure can legitimately have no reviewer child. It is a diff --git a/runtime/lib/pmctl-operation.sh b/runtime/lib/pmctl-operation.sh index 1ef53d3c..9fbe9c84 100644 --- a/runtime/lib/pmctl-operation.sh +++ b/runtime/lib/pmctl-operation.sh @@ -23,6 +23,21 @@ _pmctl_operation_load_writer() { [[ "$(type -t operation_create 2>/dev/null)" == function ]] } +_pmctl_operation_load_detached_launch() { + local repo_root="$1" lib + [[ "$(type -t detached_launch_capture_identity 2>/dev/null)" == function \ + && "$(type -t detached_launch_verify_identity 2>/dev/null)" == function \ + && "$(type -t detached_launch_kill_process_group 2>/dev/null)" == function ]] \ + && return 0 + lib="$repo_root/runtime/lib/detached-launch.sh" + [[ -r "$lib" ]] || return 1 + # shellcheck source=/dev/null # repo-root-relative runtime library + . "$lib" + [[ "$(type -t detached_launch_capture_identity 2>/dev/null)" == function \ + && "$(type -t detached_launch_verify_identity 2>/dev/null)" == function \ + && "$(type -t detached_launch_kill_process_group 2>/dev/null)" == function ]] +} + # _pmctl_operation_ensure_loaded # Callers that already loaded this library use one common readiness check before # creating or reconciling a parent operation. Keeping the dependency check @@ -32,6 +47,8 @@ _pmctl_operation_ensure_loaded() { _pmctl_operation_load_writer "$repo_root" || return 1 declare -F pmctl_operation_create >/dev/null 2>&1 \ && declare -F pmctl_operation_attach_child >/dev/null 2>&1 \ + && declare -F pmctl_operation_expect_producer >/dev/null 2>&1 \ + && declare -F pmctl_operation_register_producer >/dev/null 2>&1 \ && declare -F pmctl_operation_reconcile >/dev/null 2>&1 } @@ -53,7 +70,7 @@ pmctl_operation_create() { for ((attempt = 0; attempt < 16; attempt += 1)); do id="op-$(_pmctl_operation_stamp)-$(_pmctl_operation_hex6)"; ts="$(_pmctl_operation_ts)" json="$(jq -cn --arg id "$id" --arg kind "$kind" --arg dir "$work_dir" --arg executor "$executor" --arg ts "$ts" \ - '{schema_version:1,id:$id,kind:$kind,working_dir:$dir,executor:(if $executor == "" then null else $executor end),state:"running",created_ts:$ts,terminal_ts:null,cancellation:null}')" || return 1 + '{schema_version:1,id:$id,kind:$kind,working_dir:$dir,executor:(if $executor == "" then null else $executor end),state:"running",created_ts:$ts,terminal_ts:null,producer:null,cancellation:null}')" || return 1 ( cd "$work_dir" && operation_create "$id" "$json" ) && { printf '%s\n' "$id"; return 0; } rc=$? [[ "$rc" -eq 3 ]] || return "$rc" @@ -164,23 +181,232 @@ _pmctl_operation_mark_cancelling_inner() { esac ts="$(_pmctl_operation_ts)" updated="$(jq -c --arg ts "$ts" \ - '.state="cancelling" | .cancellation={requested_ts:$ts,actor:"pmctl",child_failures:null}' \ + '.state="cancelling" + | .cancellation={requested_ts:$ts,actor:"pmctl",producer_failures:null,child_failures:null}' \ <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 ( cd "$work_dir" && operation_upsert "$operation_id" "$updated" ) } _pmctl_operation_finish_cancel_inner() { - local repo_root="$1" expected_kind="$2" work_dir="$3" operation_id="$4" failures="$5" record ts next updated + local repo_root="$1" expected_kind="$2" work_dir="$3" operation_id="$4" + local producer_failures="$5" child_failures="$6" record ts next updated record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return $? [[ "$PMCTL_OPERATION_RECORD_STATE" == cancelling ]] || return 3 - ts="$(_pmctl_operation_ts)"; next="cancelled"; [[ "$failures" -eq 0 ]] || next="indeterminate" - updated="$(jq -c --arg state "$next" --arg ts "$ts" --argjson failures "$failures" \ - '.state=$state | .terminal_ts=$ts | .cancellation.child_failures=$failures' \ + ts="$(_pmctl_operation_ts)"; next="cancelled" + [[ "$producer_failures" -eq 0 && "$child_failures" -eq 0 ]] || next="indeterminate" + updated="$(jq -c --arg state "$next" --arg ts "$ts" \ + --argjson producer_failures "$producer_failures" --argjson child_failures "$child_failures" \ + '.state=$state | .terminal_ts=$ts + | .cancellation.producer_failures=$producer_failures + | .cancellation.child_failures=$child_failures' \ + <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 + ( cd "$work_dir" && operation_upsert "$operation_id" "$updated" ) +} + +_pmctl_operation_identity_json() { + local repo_root="$1" pid="$2" snapshot line key value + local id_pid="" state="" pgid="" starttime="" comm="" isolated="" boot_id="" + _pmctl_operation_load_detached_launch "$repo_root" || return 2 + snapshot="$(detached_launch_capture_identity "$pid" 2>/dev/null)" || return 2 + while IFS= read -r line || [[ -n "$line" ]]; do + key="${line%%=*}" + value="${line#*=}" + case "$key" in + pid) id_pid="$value" ;; + state) state="$value" ;; + pgid) pgid="$value" ;; + starttime) starttime="$value" ;; + comm) comm="$value" ;; + isolated) isolated="$value" ;; + boot_id) boot_id="$value" ;; + esac + done <<<"$snapshot" + [[ "$id_pid" == "$pid" && "$pid" =~ ^[1-9][0-9]*$ \ + && "$pgid" =~ ^[1-9][0-9]*$ && "$starttime" =~ ^[0-9]+$ \ + && "$isolated" =~ ^[01]$ ]] || return 2 + jq -cn --argjson pid "$pid" --arg state "$state" --argjson pgid "$pgid" \ + --arg starttime "$starttime" --arg comm "$comm" --arg isolated "$isolated" \ + --arg boot_id "$boot_id" \ + '{pid:$pid,state:$state,pgid:$pgid,starttime:$starttime,comm:$comm, + isolated:($isolated == "1"),boot_id:$boot_id}' +} + +_pmctl_operation_expect_producer_inner() { + local repo_root="$1" expected_kind="$2" work_dir="$3" operation_id="$4" + local record ts updated + record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 + _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return $? + [[ "$PMCTL_OPERATION_RECORD_STATE" == running ]] || return 3 + [[ "$(jq -r '.producer == null' <<<"$PMCTL_OPERATION_RECORD_JSON")" == true ]] || return 3 + ts="$(_pmctl_operation_ts)" + updated="$(jq -c --arg ts "$ts" \ + '.producer={status:"pending",phase:"gate",identity:null, + expected_ts:$ts,registered_ts:null,stopped_ts:null}' \ <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 ( cd "$work_dir" && operation_upsert "$operation_id" "$updated" ) } +# Declare that this operation will launch one producer process. Gate calls +# this before exposing the operation id, so cancel can never mistake the small +# launch/registration window for a genuinely childless operation. +pmctl_operation_expect_producer() { + local repo_root="$1" expected_kind="$2" operation_id="$3" work_dir="$4" + _pmctl_operation_load_writer "$repo_root" || return 2 + _pmctl_operation_with_record_lock "$repo_root" "$work_dir" "$operation_id" \ + _pmctl_operation_expect_producer_inner "$repo_root" "$expected_kind" "$work_dir" "$operation_id" +} + +_pmctl_operation_register_producer_inner() { + local repo_root="$1" expected_kind="$2" work_dir="$3" operation_id="$4" identity="$5" + local record ts status next_status updated rc=0 + record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 + _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return $? + status="$(jq -r '.producer.status // ""' <<<"$PMCTL_OPERATION_RECORD_JSON")" + [[ "$status" == pending ]] || return 3 + case "$PMCTL_OPERATION_RECORD_STATE" in + running) next_status=running ;; + cancelling) next_status=stopping; rc=130 ;; + cancelled) next_status=stopped; rc=130 ;; + *) return 3 ;; + esac + ts="$(_pmctl_operation_ts)" + updated="$(jq -c --arg status "$next_status" --arg ts "$ts" --argjson identity "$identity" \ + '.producer.status=$status | .producer.identity=$identity + | .producer.registered_ts=$ts + | if $status == "stopped" then .producer.stopped_ts=$ts else . end' \ + <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 + ( cd "$work_dir" && operation_upsert "$operation_id" "$updated" ) || return 2 + return "$rc" +} + +# Register only the calling producer's kernel identity. The public cancel +# command never accepts a PID; this producer-side API captures /proc evidence +# before persisting it in the trusted operation record. +pmctl_operation_register_producer() { + local repo_root="$1" expected_kind="$2" operation_id="$3" work_dir="$4" producer_pid="$5" + local identity rc=0 + identity="$(_pmctl_operation_identity_json "$repo_root" "$producer_pid")" || return 2 + _pmctl_operation_with_record_lock "$repo_root" "$work_dir" "$operation_id" \ + _pmctl_operation_register_producer_inner "$repo_root" "$expected_kind" "$work_dir" \ + "$operation_id" "$identity" || rc=$? + return "$rc" +} + +_pmctl_operation_mark_producer_stopped_inner() { + local repo_root="$1" expected_kind="$2" work_dir="$3" operation_id="$4" + local record ts updated + record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 + _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return $? + [[ "$PMCTL_OPERATION_RECORD_STATE" == cancelling ]] || return 3 + [[ "$(jq -r '.producer.status // ""' <<<"$PMCTL_OPERATION_RECORD_JSON")" =~ ^(running|stopping|stopped)$ ]] \ + || return 3 + ts="$(_pmctl_operation_ts)" + updated="$(jq -c --arg ts "$ts" \ + '.producer.status="stopped" | .producer.stopped_ts=$ts' \ + <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 + ( cd "$work_dir" && operation_upsert "$operation_id" "$updated" ) +} + +_pmctl_operation_identity_file_from_json() { + local identity="$1" destination="$2" + jq -er ' + (.pid | tostring) as $pid + | (.pgid | tostring) as $pgid + | (.starttime | tostring) as $starttime + | (.isolated | if . then "1" else "0" end) as $isolated + | "pid=\($pid)\nstate=\(.state)\npgid=\($pgid)\nstarttime=\($starttime)\ncomm=\(.comm)\nisolated=\($isolated)\nboot_id=\(.boot_id)\n" + ' <<<"$identity" >"$destination" +} + +_pmctl_operation_stop_producer() { + local repo_root="$1" expected_kind="$2" work_dir="$3" operation_id="$4" grace="$5" + local record deadline status identity identity_file pid pgid isolated verify_rc + _pmctl_operation_load_detached_launch "$repo_root" || return 1 + record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 1 + deadline=$((SECONDS + grace + 1)) + while true; do + _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return 1 + status="$(jq -r '.producer.status // "none"' <<<"$PMCTL_OPERATION_RECORD_JSON")" + case "$status" in + none|stopped) return 0 ;; + pending) + if (( SECONDS >= deadline )); then + printf 'pmctl %s cancel: producer registration did not settle for %s; refusing cancelled terminal state\n' \ + "$expected_kind" "$operation_id" >&2 + return 1 + fi + sleep 0.05 + continue + ;; + running|stopping) : ;; + *) + printf 'pmctl %s cancel: invalid producer status %s for %s\n' \ + "$expected_kind" "$status" "$operation_id" >&2 + return 1 + ;; + esac + identity="$(jq -c '.producer.identity // null' <<<"$PMCTL_OPERATION_RECORD_JSON")" + [[ "$identity" != null ]] || { + if (( SECONDS >= deadline )); then return 1; fi + sleep 0.05 + continue + } + identity_file="$(mktemp /tmp/pm-operation-producer-XXXXXX.identity)" || return 1 + if ! _pmctl_operation_identity_file_from_json "$identity" "$identity_file" \ + || ! detached_launch_load_identity_file "$identity_file"; then + rm -f "$identity_file" + printf 'pmctl %s cancel: invalid trusted producer identity for %s\n' \ + "$expected_kind" "$operation_id" >&2 + return 1 + fi + pid="$DL_ID_PID"; pgid="$DL_ID_PGID"; isolated="$DL_ID_ISOLATED" + verify_rc=0 + detached_launch_verify_identity "$pid" "$identity_file" || verify_rc=$? + case "$verify_rc" in + 0) + if [[ "$isolated" != 1 || "$pid" != "$pgid" ]]; then + rm -f "$identity_file" + printf 'pmctl %s cancel: producer %s is not an isolated process-group leader; refusing signal\n' \ + "$expected_kind" "$operation_id" >&2 + return 1 + fi + if ! detached_launch_kill_process_group "$pgid" "$grace"; then + rm -f "$identity_file" + printf 'pmctl %s cancel: producer process group %s for %s remains live; refusing cancelled terminal state\n' \ + "$expected_kind" "$pgid" "$operation_id" >&2 + return 1 + fi + ;; + 1) : ;; + 2) + rm -f "$identity_file" + printf 'pmctl %s cancel: producer identity mismatch for %s (pid=%s); refusing signal\n' \ + "$expected_kind" "$operation_id" "$pid" >&2 + return 1 + ;; + *) rm -f "$identity_file"; return 1 ;; + esac + rm -f "$identity_file" + _pmctl_operation_with_record_lock "$repo_root" "$work_dir" "$operation_id" \ + _pmctl_operation_mark_producer_stopped_inner "$repo_root" "$expected_kind" \ + "$work_dir" "$operation_id" || return 1 + return 0 + done +} + +# Returns 0 only when cancellation owns the operation terminal transition. +pmctl_operation_cancellation_requested() { + local repo_root="$1" expected_kind="$2" operation_id="$3" work_dir="$4" record state + record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 + _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return 2 + state="$PMCTL_OPERATION_RECORD_STATE" + PMCTL_OPERATION_CANCELLATION_STATE="$state" + [[ "$state" == cancelling || "$state" == cancelled \ + || ( "$state" == indeterminate \ + && "$(jq -r '.cancellation != null' <<<"$PMCTL_OPERATION_RECORD_JSON")" == true ) ]] +} + _pmctl_operation_reconcile_inner() { local repo_root="$1" expected_kind="$2" work_dir="$3" operation_id="$4" local record op_dir line run_id child_dir missing=0 failed=0 cancelled=0 children=0 next ts updated @@ -195,6 +421,12 @@ _pmctl_operation_reconcile_inner() { printf 'operation: %s state: cancelling reconciliation: deferred\n' "$operation_id" return 1 ;; + indeterminate) + if [[ "$(jq -r '.cancellation != null' <<<"$PMCTL_OPERATION_RECORD_JSON")" == true ]]; then + printf 'operation: %s state: indeterminate cancellation: unresolved\n' "$operation_id" + return 1 + fi + ;; esac op_dir="$(_pmctl_operation_dir "$repo_root" "$work_dir" "$operation_id")" || return 2 if [[ -f "$op_dir/children.jsonl" ]]; then @@ -209,7 +441,11 @@ _pmctl_operation_reconcile_inner() { [[ "$children" -gt 0 ]] || missing=1 if [[ "$missing" -gt 0 ]]; then next="indeterminate"; elif [[ "$cancelled" -gt 0 ]]; then next="cancelled"; elif [[ "$failed" -gt 0 ]]; then next="failed"; else next="completed"; fi ts="$(_pmctl_operation_ts)" - updated="$(jq -c --arg state "$next" --arg ts "$ts" '.state=$state | .terminal_ts=$ts' <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 + updated="$(jq -c --arg state "$next" --arg ts "$ts" \ + '.state=$state | .terminal_ts=$ts + | if .producer != null then + .producer.status="stopped" | .producer.stopped_ts=$ts + else . end' <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 ( cd "$work_dir" && operation_upsert "$operation_id" "$updated" ) || return 2 printf 'operation: %s state: %s children: %s unresolved: %s\n' "$operation_id" "$next" "$children" "$missing" [[ "$next" != indeterminate ]] @@ -220,10 +456,18 @@ _pmctl_operation_fail_if_childless_inner() { record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return $? [[ "$PMCTL_OPERATION_RECORD_STATE" == running || "$PMCTL_OPERATION_RECORD_STATE" == indeterminate ]] || return 3 + if [[ "$PMCTL_OPERATION_RECORD_STATE" == indeterminate \ + && "$(jq -r '.cancellation != null' <<<"$PMCTL_OPERATION_RECORD_JSON")" == true ]]; then + return 3 + fi op_dir="$(_pmctl_operation_dir "$repo_root" "$work_dir" "$operation_id")" || return 2 [[ ! -s "$op_dir/children.jsonl" ]] || return 1 ts="$(_pmctl_operation_ts)" - updated="$(jq -c --arg ts "$ts" '.state="failed" | .terminal_ts=$ts' <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 + updated="$(jq -c --arg ts "$ts" \ + '.state="failed" | .terminal_ts=$ts + | if .producer != null then + .producer.status="stopped" | .producer.stopped_ts=$ts + else . end' <<<"$PMCTL_OPERATION_RECORD_JSON")" || return 2 ( cd "$work_dir" && operation_upsert "$operation_id" "$updated" ) } @@ -266,7 +510,11 @@ pmctl_operation_cancel() { *) printf 'pmctl %s cancel: foreign or invalid operation target refused\n' "$expected_kind" >&2; return 2 ;; esac - local line run_id child_dir rc failures=0 seen=" " + local producer_failures=0 + _pmctl_operation_stop_producer "$repo_root" "$expected_kind" "$work_dir" \ + "$operation_id" "$grace" || producer_failures=1 + + local line run_id child_dir rc child_failures=0 seen=" " if [[ -f "$op_dir/children.jsonl" ]]; then while IFS= read -r line; do run_id="$(jq -r '.run_id // ""' <<<"$line" 2>/dev/null || true)"; child_dir="$(jq -r '.working_dir // ""' <<<"$line" 2>/dev/null || true)" @@ -275,14 +523,17 @@ pmctl_operation_cancel() { rc=0; pmctl_dispatch_cancel "$repo_root" "$run_id" --cd "$child_dir" --grace "$grace" || rc=$? # A child already terminal is a valid cancel race outcome; unknown or # identity failures keep the parent non-successful and diagnosable. - [[ "$rc" -eq 0 || "$rc" -eq 1 ]] || failures=$((failures + 1)) + [[ "$rc" -eq 0 || "$rc" -eq 1 ]] || child_failures=$((child_failures + 1)) done < "$op_dir/children.jsonl" fi _pmctl_operation_with_record_lock "$repo_root" "$work_dir" "$operation_id" \ - _pmctl_operation_finish_cancel_inner "$repo_root" "$expected_kind" "$work_dir" "$operation_id" "$failures" || return $? - local next_state="cancelled"; [[ "$failures" -eq 0 ]] || next_state="indeterminate" - printf 'operation: %s state: %s child_failures: %s\n' "$operation_id" "$next_state" "$failures" - [[ "$failures" -eq 0 ]] + _pmctl_operation_finish_cancel_inner "$repo_root" "$expected_kind" "$work_dir" \ + "$operation_id" "$producer_failures" "$child_failures" || return $? + local next_state="cancelled" + [[ "$producer_failures" -eq 0 && "$child_failures" -eq 0 ]] || next_state="indeterminate" + printf 'operation: %s state: %s producer_failures: %s child_failures: %s\n' \ + "$operation_id" "$next_state" "$producer_failures" "$child_failures" + [[ "$producer_failures" -eq 0 && "$child_failures" -eq 0 ]] } # pmctl_operation_reconcile --cd diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index b8632fea..9193eec0 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -1310,7 +1310,7 @@ case_foreground_gate_reconciles_parent_operation() { mkdir -p "$fixture/runtime/lib" "$fixture/core/schema" "$target" git -C "$target" init -q _mk_fake_gate "$fixture" 0 - for lib in pmctl-gate pmctl-operation portable state-writer state-paths state-compat; do + for lib in pmctl-gate pmctl-operation portable detached-launch state-writer state-paths state-compat; do cp "$REPO_ROOT/runtime/lib/$lib.sh" "$fixture/runtime/lib/$lib.sh" done cp "$REPO_ROOT/core/schema/operation.schema.json" "$fixture/core/schema/operation.schema.json" @@ -1344,7 +1344,7 @@ case_detached_launcher_failure_terminalizes_childless_parent() { mkdir -p "$fixture/runtime/lib" "$fixture/core/schema" "$target" git -C "$target" init -q _mk_fake_gate "$fixture" 0 - for lib in pmctl-gate pmctl-operation portable state-writer state-paths state-compat; do + for lib in pmctl-gate pmctl-operation portable detached-launch state-writer state-paths state-compat; do cp "$REPO_ROOT/runtime/lib/$lib.sh" "$fixture/runtime/lib/$lib.sh" done cp "$REPO_ROOT/core/schema/operation.schema.json" "$fixture/core/schema/operation.schema.json" @@ -1392,6 +1392,145 @@ case_gate_operation_cli_unavailable_fallbacks() { if [[ "$cancel_rc" -eq 2 && "$reconcile_rc" -eq 2 && "$cancel_out" == *"gate cancel unavailable"* && "$reconcile_out" == *"gate reconcile unavailable"* ]]; then pass "$name"; else fail "$name" "cancel=$cancel_rc:$cancel_out reconcile=$reconcile_rc:$reconcile_out"; fi } +case_foreground_cancel_stops_preflight_process_tree() { + local name="gate cancel: foreground preflight process tree stops before cancelled terminal" + should_run "$name" || return 0 + local work="$tmp_root/foreground-cancel-work" state="$tmp_root/foreground-cancel-state" + local ready="$tmp_root/foreground-cancel-ready" release="$tmp_root/foreground-cancel-release" + local runner="$tmp_root/foreground-cancel-runner" pids="$tmp_root/foreground-cancel-pids" + local out="$tmp_root/foreground-cancel-out" err="$tmp_root/foreground-cancel-err" + local test_cmd operation record op_dir gate_pid gate_rc=0 cancel_out cancel_rc=0 + local ready_value producer_pid descendant_pid started elapsed + mkdir -p "$work" + git -C "$work" init -q + git -C "$work" config user.email test@example.com + git -C "$work" config user.name "Gate Test" + printf 'base\n' > "$work/input.txt" + git -C "$work" add input.txt + git -C "$work" commit -qm base + printf 'changed\n' > "$work/input.txt" + git -C "$work" add input.txt + git -C "$work" commit -qm changed + mkfifo "$ready" "$release" + cat > "$runner" <<'RUNNER' +#!/usr/bin/env bash +set -euo pipefail +ready="$1"; release="$2"; pids="$3" +sleep 300 & +descendant=$! +printf '%s %s\n' "$BASHPID" "$descendant" > "$pids" +printf 'ready\n' > "$ready" +IFS= read -r _ < "$release" +wait "$descendant" +RUNNER + chmod +x "$runner" + printf -v test_cmd 'bash %q %q %q %q' "$runner" "$ready" "$release" "$pids" + + PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ + "$PMCTL" gate run --lifecycle foreground --cd "$work" --base HEAD~1 \ + --tier express --mode sequential --reviewers critic,qa-tester \ + --executor codex --test-timeout 120 --test-cmd "$test_cmd" \ + >"$out" 2>"$err" & + gate_pid=$! + + ready_value="$(timeout 20 cat "$ready")" || { + kill "$gate_pid" 2>/dev/null || true + wait "$gate_pid" 2>/dev/null || true + fail "$name" "preflight never reached deterministic readiness; err=$(cat "$err" 2>/dev/null || true)" + return + } + operation="$(sed -n 's/.*parent operation: \\(op-[0-9TZ-]*[a-f0-9]\\{6\\}\\).*/\\1/p' "$err" | tail -1)" + if [[ "$ready_value" != ready || ! "$operation" =~ ^op-[0-9]{8}T[0-9]{6}Z-[a-f0-9]{6}$ ]]; then + kill "$gate_pid" 2>/dev/null || true + wait "$gate_pid" 2>/dev/null || true + fail "$name" "invalid readiness/operation: ready=$ready_value operation=$operation err=$(cat "$err" 2>/dev/null || true)" + return + fi + + started=$SECONDS + cancel_out="$(PM_DISPATCH_STATE_ROOT="$state" "$PMCTL" gate cancel "$operation" --cd "$work" --grace 2 2>&1)" || cancel_rc=$? + elapsed=$((SECONDS - started)) + wait "$gate_pid" || gate_rc=$? + read -r producer_pid descendant_pid < "$pids" + record="$(find "$state" -path "*/operations/$operation.json" -type f | head -1)" + op_dir="${record%.json}" + + if [[ "$cancel_rc" -eq 0 && "$gate_rc" -eq 130 && "$elapsed" -lt 10 ]] \ + && [[ -n "$record" && "$(jq -r .state "$record")" == cancelled ]] \ + && [[ "$(jq -r .producer.status "$record")" == stopped ]] \ + && ! kill -0 "$producer_pid" 2>/dev/null \ + && ! kill -0 "$descendant_pid" 2>/dev/null \ + && [[ ! -s "$op_dir/children.jsonl" ]] \ + && ! find "$state" -type f -name 'gate-result-*.md' -print -quit 2>/dev/null | grep -q . \ + && { [[ ! -d "$work/.gate-results" ]] \ + || ! find "$work/.gate-results" -type f -name 'gate-result-*.md' -print -quit 2>/dev/null | grep -q .; } \ + && [[ "$cancel_out" == *"producer_failures: 0"* ]] \ + && [[ "$(cat "$err")" == *"foreground producer stopped (exit 130)"* ]]; then + pass "$name" + else + fail "$name" "cancel=$cancel_rc:$cancel_out gate_rc=$gate_rc elapsed=$elapsed record=$(jq -c . "$record" 2>/dev/null || true) pids=$producer_pid,$descendant_pid err=$(cat "$err" 2>/dev/null || true)" + fi +} + +case_detached_cancel_surfaces_cancelled_wait_terminal() { + local name="gate cancel: detached supervisor publishes cancelled wait terminal" + should_run "$name" || return 0 + local fixture="$tmp_root/detached-cancel-fixture" work="$tmp_root/detached-cancel-work" + local state="$tmp_root/detached-cancel-state" ready="$tmp_root/detached-cancel-ready" + local release="$tmp_root/detached-cancel-release" pids="$tmp_root/detached-cancel-pids" + local err="$tmp_root/detached-cancel-err" gate_id operation cancel_out wait_out + local cancel_rc=0 wait_rc=0 producer_pid descendant_pid record op_dir + mkdir -p "$fixture/core/schema" "$work" + _mk_gate_cli_fixture "$fixture" + for lib in pmctl-operation state-writer state-compat; do + cp "$REPO_ROOT/runtime/lib/$lib.sh" "$fixture/runtime/lib/$lib.sh" + done + cp "$REPO_ROOT/core/schema/operation.schema.json" "$fixture/core/schema/operation.schema.json" + cat > "$fixture/runtime/bin/pr-gate.sh" <<'FAKE_GATE' +#!/usr/bin/env bash +set -euo pipefail +sleep 300 & +descendant=$! +printf '%s %s\n' "$BASHPID" "$descendant" > "$TEST_GATE_PIDS" +printf 'ready\n' > "$TEST_GATE_READY_FIFO" +IFS= read -r _ < "$TEST_GATE_RELEASE_FIFO" +wait "$descendant" +FAKE_GATE + chmod +x "$fixture/runtime/bin/pr-gate.sh" + git -C "$work" init -q + mkfifo "$ready" "$release" + + gate_id="$(PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ + TEST_GATE_READY_FIFO="$ready" TEST_GATE_RELEASE_FIFO="$release" TEST_GATE_PIDS="$pids" \ + "$fixture/cli/pmctl" gate run --lifecycle detached --cd "$work" 2>"$err")" + if [[ "$(timeout 20 cat "$ready")" != ready ]]; then + fail "$name" "detached producer never reached readiness; gate=$gate_id err=$(cat "$err" 2>/dev/null || true)" + return + fi + operation="$(sed -n 's/.*parent operation: \\(op-[0-9TZ-]*[a-f0-9]\\{6\\}\\).*/\\1/p' "$err" | tail -1)" + cancel_out="$(PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ + "$fixture/cli/pmctl" gate cancel "$operation" --cd "$work" --grace 2 2>&1)" || cancel_rc=$? + wait_out="$(PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ + PM_GATE_WAIT_POLL_INTERVAL=0.1 \ + "$fixture/cli/pmctl" gate wait "$gate_id" --cd "$work" --timeout 20 2>&1)" || wait_rc=$? + read -r producer_pid descendant_pid < "$pids" + record="$(find "$state" -path "*/operations/$operation.json" -type f | head -1)" + op_dir="${record%.json}" + + if [[ "$cancel_rc" -eq 0 && "$wait_rc" -eq 130 ]] \ + && [[ "$wait_out" == *"state: cancelled"* && "$wait_out" == *"exit: 130"* ]] \ + && [[ -n "$record" && "$(jq -r .state "$record")" == cancelled ]] \ + && [[ "$(jq -r .producer.status "$record")" == stopped ]] \ + && ! kill -0 "$producer_pid" 2>/dev/null \ + && ! kill -0 "$descendant_pid" 2>/dev/null \ + && [[ ! -s "$op_dir/children.jsonl" ]] \ + && [[ "$cancel_out" == *"producer_failures: 0"* ]]; then + pass "$name" + else + fail "$name" "cancel=$cancel_rc:$cancel_out wait=$wait_rc:$wait_out gate=$gate_id operation=$operation record=$(jq -c . "$record" 2>/dev/null || true) pids=$producer_pid,$descendant_pid err=$(cat "$err" 2>/dev/null || true)" + fi +} + case_explicit_cd_passthrough case_gate_run_refreshes_context_before_dispatch case_default_cd_injected @@ -1434,5 +1573,7 @@ case_foreground_gate_reconciles_parent_operation case_detached_launcher_failure_terminalizes_childless_parent case_gate_operation_routes_via_cli case_gate_operation_cli_unavailable_fallbacks +case_foreground_cancel_stops_preflight_process_tree +case_detached_cancel_surfaces_cancelled_wait_terminal th_summary diff --git a/tests/shell/test-pmctl-operation.sh b/tests/shell/test-pmctl-operation.sh index 72ce3e3f..8ace769b 100755 --- a/tests/shell/test-pmctl-operation.sh +++ b/tests/shell/test-pmctl-operation.sh @@ -202,6 +202,85 @@ case_cancel_deduplicates_repeated_child_records() { fi } +case_cancel_refuses_reused_producer_identity() { + local name="operation cancel: producer identity mismatch is indeterminate and never signalled" + should_run "$name" || return 0 + local work="$tmp_root/producer-mismatch-work" store="$tmp_root/producer-mismatch-state" + local op producer release record state tampered out rc=0 reconcile_rc=0 fail_rc=0 + make_repo "$work" + release="$tmp_root/producer-mismatch-release" + mkfifo "$release" + setsid bash -c 'IFS= read -r _ < "$1"' _ "$release" & + producer=$! + op="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_create "$REPO_ROOT" "$work" gate codex)" + PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_expect_producer "$REPO_ROOT" gate "$op" "$work" + PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_register_producer "$REPO_ROOT" gate "$op" "$work" "$producer" + state="$(PM_DISPATCH_STATE_ROOT="$store" bash -c '. "$1/runtime/lib/state-writer.sh"; cd "$2"; _sw_project_dir' _ "$REPO_ROOT" "$work")" + record="${state%/}/operations/$op.json" + tampered="$(jq -c '.producer.identity.starttime="1"' "$record")" + ( cd "$work" && PM_DISPATCH_STATE_ROOT="$store" operation_upsert "$op" "$tampered" ) + out="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_cancel "$REPO_ROOT" gate "$op" --cd "$work" --grace 0 2>&1)" || rc=$? + PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_reconcile "$REPO_ROOT" gate "$op" --cd "$work" >/dev/null 2>&1 || reconcile_rc=$? + PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_fail_if_childless "$REPO_ROOT" gate "$op" "$work" >/dev/null 2>&1 || fail_rc=$? + if [[ "$rc" -ne 0 && "$(jq -r .state "$record")" == indeterminate ]] \ + && kill -0 "$producer" 2>/dev/null \ + && [[ "$reconcile_rc" -ne 0 && "$fail_rc" -ne 0 ]] \ + && [[ "$out" == *"identity mismatch"* ]]; then + pass "$name" + else + fail "$name" "rc=$rc reconcile=$reconcile_rc fail_if_childless=$fail_rc producer=$producer state=$(jq -r .state "$record" 2>/dev/null || true) out=$out" + fi + kill -TERM -- "-$producer" 2>/dev/null || true + wait "$producer" 2>/dev/null || true +} + +case_cancel_accepts_producer_that_exited_before_signal() { + local name="operation cancel: an already-exited registered producer can terminalize cancelled" + should_run "$name" || return 0 + local work="$tmp_root/producer-gone-work" store="$tmp_root/producer-gone-state" + local op producer release record state out + make_repo "$work" + release="$tmp_root/producer-gone-release" + mkfifo "$release" + setsid bash -c 'IFS= read -r _ < "$1"' _ "$release" & + producer=$! + op="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_create "$REPO_ROOT" "$work" gate codex)" + PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_expect_producer "$REPO_ROOT" gate "$op" "$work" + PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_register_producer "$REPO_ROOT" gate "$op" "$work" "$producer" + printf 'release\n' > "$release" + wait "$producer" + out="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_cancel "$REPO_ROOT" gate "$op" --cd "$work" --grace 0)" + state="$(PM_DISPATCH_STATE_ROOT="$store" bash -c '. "$1/runtime/lib/state-writer.sh"; cd "$2"; _sw_project_dir' _ "$REPO_ROOT" "$work")" + record="${state%/}/operations/$op.json" + if [[ "$(jq -r .state "$record")" == cancelled ]] \ + && [[ "$(jq -r .producer.status "$record")" == stopped ]] \ + && [[ "$out" == *"producer_failures: 0"* ]]; then + pass "$name" + else + fail "$name" "state=$(jq -c . "$record" 2>/dev/null || true) out=$out" + fi +} + +case_repeated_cancel_preserves_cancelled_terminal() { + local name="operation cancel: repeated cancel preserves the first cancelled terminal" + should_run "$name" || return 0 + local work="$tmp_root/repeated-cancel-work" store="$tmp_root/repeated-cancel-state" + local op first second rc=0 state record + make_repo "$work" + op="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_create "$REPO_ROOT" "$work" gate codex)" + first="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_cancel "$REPO_ROOT" gate "$op" --cd "$work")" + second="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_cancel "$REPO_ROOT" gate "$op" --cd "$work" 2>&1)" || rc=$? + state="$(PM_DISPATCH_STATE_ROOT="$store" bash -c '. "$1/runtime/lib/state-writer.sh"; cd "$2"; _sw_project_dir' _ "$REPO_ROOT" "$work")" + record="${state%/}/operations/$op.json" + if [[ "$rc" -eq 1 && "$(jq -r .state "$record")" == cancelled ]] \ + && [[ "$first" == *"state: cancelled"* ]] \ + && [[ "$second" == *"already terminal"* ]]; then + pass "$name" + else + fail "$name" "rc=$rc first=$first second=$second state=$(jq -r .state "$record" 2>/dev/null || true)" + fi +} + case_unknown_operation_is_diagnosed_not_silent() { local name="operation cancel/reconcile: unknown id reports why instead of exiting silently" should_run "$name" || return 0 @@ -262,4 +341,7 @@ case_cancel_intent_blocks_reconcile_and_late_attachment case_childless_producer_failure_is_terminal case_concurrent_attach_preserves_complete_child_records case_cancel_deduplicates_repeated_child_records +case_cancel_refuses_reused_producer_identity +case_cancel_accepts_producer_that_exited_before_signal +case_repeated_cancel_preserves_cancelled_terminal th_summary From 528ca22c37e05fea4d079790c5eb99e9e6385aa8 Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:14:27 +0900 Subject: [PATCH 2/9] fix(state): validate nullable operation metadata --- core/schema/operation.schema.json | 67 ++++++++++++++++++------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/core/schema/operation.schema.json b/core/schema/operation.schema.json index 97a73e82..5d551da7 100644 --- a/core/schema/operation.schema.json +++ b/core/schema/operation.schema.json @@ -14,40 +14,49 @@ "terminal_ts": {"type": ["string", "null"], "format": "date-time"}, "producer": { "type": ["object", "null"], - "required": ["status", "phase", "identity", "expected_ts", "registered_ts", "stopped_ts"], - "properties": { - "status": {"enum": ["pending", "running", "stopping", "stopped"]}, - "phase": {"const": "gate"}, - "identity": { - "type": ["object", "null"], - "required": ["pid", "state", "pgid", "starttime", "comm", "isolated", "boot_id"], - "properties": { - "pid": {"type": "integer", "minimum": 1}, - "state": {"type": "string", "minLength": 1}, - "pgid": {"type": "integer", "minimum": 1}, - "starttime": {"type": "string", "pattern": "^[0-9]+$"}, - "comm": {"type": "string"}, - "isolated": {"type": "boolean"}, - "boot_id": {"type": "string"} + "if": {"type": "object"}, + "then": { + "required": ["status", "phase", "identity", "expected_ts", "registered_ts", "stopped_ts"], + "properties": { + "status": {"enum": ["pending", "running", "stopping", "stopped"]}, + "phase": {"const": "gate"}, + "identity": { + "type": ["object", "null"], + "if": {"type": "object"}, + "then": { + "required": ["pid", "state", "pgid", "starttime", "comm", "isolated", "boot_id"], + "properties": { + "pid": {"type": "integer", "minimum": 1}, + "state": {"type": "string", "minLength": 1}, + "pgid": {"type": "integer", "minimum": 1}, + "starttime": {"type": "string", "pattern": "^[0-9]+$"}, + "comm": {"type": "string"}, + "isolated": {"type": "boolean"}, + "boot_id": {"type": "string"} + }, + "additionalProperties": false + } }, - "additionalProperties": false + "expected_ts": {"type": "string", "format": "date-time"}, + "registered_ts": {"type": ["string", "null"], "format": "date-time"}, + "stopped_ts": {"type": ["string", "null"], "format": "date-time"} }, - "expected_ts": {"type": "string", "format": "date-time"}, - "registered_ts": {"type": ["string", "null"], "format": "date-time"}, - "stopped_ts": {"type": ["string", "null"], "format": "date-time"} - }, - "additionalProperties": false + "additionalProperties": false + } }, "cancellation": { "type": ["object", "null"], - "required": ["requested_ts", "actor", "child_failures"], - "properties": { - "requested_ts": {"type": "string", "format": "date-time"}, - "actor": {"const": "pmctl"}, - "producer_failures": {"type": ["integer", "null"], "minimum": 0}, - "child_failures": {"type": ["integer", "null"], "minimum": 0} - }, - "additionalProperties": false + "if": {"type": "object"}, + "then": { + "required": ["requested_ts", "actor", "child_failures"], + "properties": { + "requested_ts": {"type": "string", "format": "date-time"}, + "actor": {"const": "pmctl"}, + "producer_failures": {"type": ["integer", "null"], "minimum": 0}, + "child_failures": {"type": ["integer", "null"], "minimum": 0} + }, + "additionalProperties": false + } } }, "additionalProperties": false From 0b92cf845ff90ba4d924e713e367afc5326f4b25 Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:16:50 +0900 Subject: [PATCH 3/9] fix(state): require complete operation writer boundary --- runtime/lib/pmctl-operation.sh | 8 ++++++-- tests/shell/test-pmctl-operation.sh | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/runtime/lib/pmctl-operation.sh b/runtime/lib/pmctl-operation.sh index 9fbe9c84..d1683d3a 100644 --- a/runtime/lib/pmctl-operation.sh +++ b/runtime/lib/pmctl-operation.sh @@ -16,11 +16,15 @@ _pmctl_operation_load_writer() { local repo_root lib repo_root="$1" lib="$repo_root/runtime/lib/state-writer.sh" - [[ "$(type -t operation_create 2>/dev/null)" == function ]] && return 0 + [[ "$(type -t operation_create 2>/dev/null)" == function \ + && "$(type -t operation_upsert 2>/dev/null)" == function \ + && "$(type -t operation_child_append 2>/dev/null)" == function ]] && return 0 [[ -r "$lib" ]] || return 1 # shellcheck source=/dev/null # repo-root-relative runtime library . "$lib" - [[ "$(type -t operation_create 2>/dev/null)" == function ]] + [[ "$(type -t operation_create 2>/dev/null)" == function \ + && "$(type -t operation_upsert 2>/dev/null)" == function \ + && "$(type -t operation_child_append 2>/dev/null)" == function ]] } _pmctl_operation_load_detached_launch() { diff --git a/tests/shell/test-pmctl-operation.sh b/tests/shell/test-pmctl-operation.sh index 8ace769b..46d6ca7e 100755 --- a/tests/shell/test-pmctl-operation.sh +++ b/tests/shell/test-pmctl-operation.sh @@ -22,6 +22,29 @@ make_repo() { git -C "$dir" init -q } +case_writer_loader_repairs_partial_inherited_functions() { + local name="operation loader: partial inherited writer functions reload the complete boundary" + should_run "$name" || return 0 + local out rc=0 + _pmctl_operation_load_writer "$REPO_ROOT" + out="$( + export -f operation_create + export -n -f operation_upsert operation_child_append 2>/dev/null || true + bash -c ' + set -euo pipefail + . "$1/runtime/lib/pmctl-operation.sh" + _pmctl_operation_load_writer "$1" + declare -F operation_create operation_upsert operation_child_append + ' _ "$REPO_ROOT" + )" || rc=$? + if [[ "$rc" -eq 0 && "$out" == *"operation_create"* \ + && "$out" == *"operation_upsert"* && "$out" == *"operation_child_append"* ]]; then + pass "$name" + else + fail "$name" "rc=$rc out=$out" + fi +} + case_reconcile_uses_trusted_terminal_claims() { local name="operation reconcile: all trusted child claims converge parent to completed" should_run "$name" || return 0 @@ -328,6 +351,7 @@ case_relative_cd_resolves_to_the_same_operation() { fi } +case_writer_loader_repairs_partial_inherited_functions case_reconcile_uses_trusted_terminal_claims case_create_collision_never_overwrites_record case_unknown_operation_is_diagnosed_not_silent From 82e49423b023180803d32c79f5c154632a2df8be Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:18:42 +0900 Subject: [PATCH 4/9] fix(state): load writer before operation locks --- runtime/lib/pmctl-operation.sh | 4 ++++ tests/shell/test-pmctl-operation.sh | 24 +++++++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/runtime/lib/pmctl-operation.sh b/runtime/lib/pmctl-operation.sh index d1683d3a..37bd066b 100644 --- a/runtime/lib/pmctl-operation.sh +++ b/runtime/lib/pmctl-operation.sh @@ -167,6 +167,10 @@ _pmctl_operation_validate_record() { _pmctl_operation_with_record_lock() { local repo_root="$1" work_dir="$2" operation_id="$3"; shift 3 local lock_base record + # Path resolution below runs in command substitutions. Any lazy-loaded + # writer functions would otherwise disappear with those subshells before the + # locked callback uses operation_upsert/operation_child_append. + _pmctl_operation_load_writer "$repo_root" || return 2 record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 [[ -f "$record" ]] || return 5 lock_base="$(_pmctl_operation_record_lock_base "$repo_root" "$work_dir" "$operation_id")" || return 2 diff --git a/tests/shell/test-pmctl-operation.sh b/tests/shell/test-pmctl-operation.sh index 46d6ca7e..ebe14365 100755 --- a/tests/shell/test-pmctl-operation.sh +++ b/tests/shell/test-pmctl-operation.sh @@ -23,25 +23,35 @@ make_repo() { } case_writer_loader_repairs_partial_inherited_functions() { - local name="operation loader: partial inherited writer functions reload the complete boundary" + local name="operation lock: partial inherited writer functions reload before producer registration" should_run "$name" || return 0 - local out rc=0 + local work="$tmp_root/partial-writer-work" store="$tmp_root/partial-writer-state" + local op out rc=0 state record + make_repo "$work" + op="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_create "$REPO_ROOT" "$work" gate)" + PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_expect_producer \ + "$REPO_ROOT" gate "$op" "$work" _pmctl_operation_load_writer "$REPO_ROOT" out="$( export -f operation_create export -n -f operation_upsert operation_child_append 2>/dev/null || true - bash -c ' + PM_DISPATCH_STATE_ROOT="$store" setsid bash -c ' set -euo pipefail . "$1/runtime/lib/pmctl-operation.sh" - _pmctl_operation_load_writer "$1" + pmctl_operation_register_producer "$1" gate "$2" "$3" "$BASHPID" declare -F operation_create operation_upsert operation_child_append - ' _ "$REPO_ROOT" + ' _ "$REPO_ROOT" "$op" "$work" )" || rc=$? + state="$(PM_DISPATCH_STATE_ROOT="$store" bash -c \ + '. "$1/runtime/lib/state-writer.sh"; cd "$2"; _sw_project_dir' \ + _ "$REPO_ROOT" "$work")" + record="${state%/}/operations/$op.json" if [[ "$rc" -eq 0 && "$out" == *"operation_create"* \ - && "$out" == *"operation_upsert"* && "$out" == *"operation_child_append"* ]]; then + && "$out" == *"operation_upsert"* && "$out" == *"operation_child_append"* \ + && "$(jq -r .producer.status "$record")" == running ]]; then pass "$name" else - fail "$name" "rc=$rc out=$out" + fail "$name" "rc=$rc out=$out record=$(jq -c . "$record" 2>/dev/null || true)" fi } From dcded0c054f49c6a3f858c52d16fe3844b74b727 Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:25:54 +0900 Subject: [PATCH 5/9] test(gate): harden cancellation fixtures --- runtime/bin/gate-supervisor.sh | 1 + runtime/lib/pmctl-gate.sh | 1 + runtime/lib/pmctl-operation.sh | 1 + tests/shell/test-pmctl-gate.sh | 11 ++++++++--- tests/shell/test-pmctl-operation.sh | 3 +++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/runtime/bin/gate-supervisor.sh b/runtime/bin/gate-supervisor.sh index 0749f610..f5b20565 100755 --- a/runtime/bin/gate-supervisor.sh +++ b/runtime/bin/gate-supervisor.sh @@ -114,6 +114,7 @@ done mkdir -p "$run_dir" || _die "failed to create run dir: $run_dir" +# shellcheck disable=SC2317 # invoked indirectly by the TERM/INT trap. _cancel_supervisor() { _write_sentinel "cancelled" 130 "" exit 130 diff --git a/runtime/lib/pmctl-gate.sh b/runtime/lib/pmctl-gate.sh index 04b7c788..8820d963 100644 --- a/runtime/lib/pmctl-gate.sh +++ b/runtime/lib/pmctl-gate.sh @@ -316,6 +316,7 @@ pmctl_gate_run() { # producer tree from the invoking shell. The new session registers its # own kernel identity before execing pr-gate.sh; cancellation can therefore # stop preflight/timeout descendants without accepting a caller-supplied PID. + # shellcheck disable=SC2016 # variables are expanded by the spawned bash, not this shell. setsid bash -c ' repo_root="$1"; work_dir="$2"; operation_id="$3"; gate_script="$4" shift 4 diff --git a/runtime/lib/pmctl-operation.sh b/runtime/lib/pmctl-operation.sh index 37bd066b..a2da983b 100644 --- a/runtime/lib/pmctl-operation.sh +++ b/runtime/lib/pmctl-operation.sh @@ -409,6 +409,7 @@ pmctl_operation_cancellation_requested() { record="$(_pmctl_operation_record_path "$repo_root" "$work_dir" "$operation_id")" || return 2 _pmctl_operation_validate_record "$record" "$expected_kind" "$work_dir" || return 2 state="$PMCTL_OPERATION_RECORD_STATE" + # shellcheck disable=SC2034 # public output consumed by pmctl-gate after this function returns. PMCTL_OPERATION_CANCELLATION_STATE="$state" [[ "$state" == cancelling || "$state" == cancelled \ || ( "$state" == indeterminate \ diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index 9193eec0..7f48e2d0 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -1405,8 +1405,9 @@ case_foreground_cancel_stops_preflight_process_tree() { git -C "$work" init -q git -C "$work" config user.email test@example.com git -C "$work" config user.name "Gate Test" + printf '.pm-dispatch/\n' > "$work/.gitignore" printf 'base\n' > "$work/input.txt" - git -C "$work" add input.txt + git -C "$work" add .gitignore input.txt git -C "$work" commit -qm base printf 'changed\n' > "$work/input.txt" git -C "$work" add input.txt @@ -1439,7 +1440,7 @@ RUNNER fail "$name" "preflight never reached deterministic readiness; err=$(cat "$err" 2>/dev/null || true)" return } - operation="$(sed -n 's/.*parent operation: \\(op-[0-9TZ-]*[a-f0-9]\\{6\\}\\).*/\\1/p' "$err" | tail -1)" + operation="$(sed -n 's/.*parent operation: \(op-[0-9TZ-]*[a-f0-9]\{6\}\).*/\1/p' "$err" | tail -1)" if [[ "$ready_value" != ready || ! "$operation" =~ ^op-[0-9]{8}T[0-9]{6}Z-[a-f0-9]{6}$ ]]; then kill "$gate_pid" 2>/dev/null || true wait "$gate_pid" 2>/dev/null || true @@ -1507,7 +1508,11 @@ FAKE_GATE fail "$name" "detached producer never reached readiness; gate=$gate_id err=$(cat "$err" 2>/dev/null || true)" return fi - operation="$(sed -n 's/.*parent operation: \\(op-[0-9TZ-]*[a-f0-9]\\{6\\}\\).*/\\1/p' "$err" | tail -1)" + operation="$(sed -n 's/.*parent operation: \(op-[0-9TZ-]*[a-f0-9]\{6\}\).*/\1/p' "$err" | tail -1)" + if ! [[ "$operation" =~ ^op-[0-9]{8}T[0-9]{6}Z-[a-f0-9]{6}$ ]]; then + fail "$name" "detached operation id was not published: gate=$gate_id operation=$operation err=$(cat "$err" 2>/dev/null || true)" + return + fi cancel_out="$(PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ "$fixture/cli/pmctl" gate cancel "$operation" --cd "$work" --grace 2 2>&1)" || cancel_rc=$? wait_out="$(PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ diff --git a/tests/shell/test-pmctl-operation.sh b/tests/shell/test-pmctl-operation.sh index ebe14365..52f6c137 100755 --- a/tests/shell/test-pmctl-operation.sh +++ b/tests/shell/test-pmctl-operation.sh @@ -35,6 +35,7 @@ case_writer_loader_repairs_partial_inherited_functions() { out="$( export -f operation_create export -n -f operation_upsert operation_child_append 2>/dev/null || true + # shellcheck disable=SC2016 # variables are expanded by the spawned bash. PM_DISPATCH_STATE_ROOT="$store" setsid bash -c ' set -euo pipefail . "$1/runtime/lib/pmctl-operation.sh" @@ -243,6 +244,7 @@ case_cancel_refuses_reused_producer_identity() { make_repo "$work" release="$tmp_root/producer-mismatch-release" mkfifo "$release" + # shellcheck disable=SC2016 # $1 belongs to the spawned bash. setsid bash -c 'IFS= read -r _ < "$1"' _ "$release" & producer=$! op="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_create "$REPO_ROOT" "$work" gate codex)" @@ -275,6 +277,7 @@ case_cancel_accepts_producer_that_exited_before_signal() { make_repo "$work" release="$tmp_root/producer-gone-release" mkfifo "$release" + # shellcheck disable=SC2016 # $1 belongs to the spawned bash. setsid bash -c 'IFS= read -r _ < "$1"' _ "$release" & producer=$! op="$(PM_DISPATCH_STATE_ROOT="$store" pmctl_operation_create "$REPO_ROOT" "$work" gate codex)" From 84efbf167cd24b2aded64a1fb7e84a784b13464e Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:32:28 +0900 Subject: [PATCH 6/9] test(gate): treat reaped process states as stopped --- tests/shell/test-pmctl-gate.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index 7f48e2d0..04a4989e 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -36,6 +36,18 @@ git -C "$_GATE_VERIFY_REPO" init -q # Helpers # --------------------------------------------------------------------------- +# Match the runtime cancellation contract: a zombie remains visible to +# `kill -0` but cannot execute and is treated as gone by +# detached_launch_verify_identity. +_gate_test_pid_stopped() { + local pid="$1" snapshot line state="" + snapshot="$(detached_launch_capture_identity "$pid" 2>/dev/null)" || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" == state=* ]] && state="${line#state=}" + done <<<"$snapshot" + [[ "$state" == Z || "$state" == X ]] +} + # Install a fake pr-gate.sh into fixture/runtime/bin that writes a structurally # valid gate result under --run-dir (so gate_result_verify accepts it -- the # detached wait path now requires this, per CC-423's result-integrity fix) @@ -1459,8 +1471,8 @@ RUNNER if [[ "$cancel_rc" -eq 0 && "$gate_rc" -eq 130 && "$elapsed" -lt 10 ]] \ && [[ -n "$record" && "$(jq -r .state "$record")" == cancelled ]] \ && [[ "$(jq -r .producer.status "$record")" == stopped ]] \ - && ! kill -0 "$producer_pid" 2>/dev/null \ - && ! kill -0 "$descendant_pid" 2>/dev/null \ + && _gate_test_pid_stopped "$producer_pid" \ + && _gate_test_pid_stopped "$descendant_pid" \ && [[ ! -s "$op_dir/children.jsonl" ]] \ && ! find "$state" -type f -name 'gate-result-*.md' -print -quit 2>/dev/null | grep -q . \ && { [[ ! -d "$work/.gate-results" ]] \ @@ -1526,8 +1538,8 @@ FAKE_GATE && [[ "$wait_out" == *"state: cancelled"* && "$wait_out" == *"exit: 130"* ]] \ && [[ -n "$record" && "$(jq -r .state "$record")" == cancelled ]] \ && [[ "$(jq -r .producer.status "$record")" == stopped ]] \ - && ! kill -0 "$producer_pid" 2>/dev/null \ - && ! kill -0 "$descendant_pid" 2>/dev/null \ + && _gate_test_pid_stopped "$producer_pid" \ + && _gate_test_pid_stopped "$descendant_pid" \ && [[ ! -s "$op_dir/children.jsonl" ]] \ && [[ "$cancel_out" == *"producer_failures: 0"* ]]; then pass "$name" From 0ae1389a46e5ebe32dd50a53c0bf55f419f526a4 Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:43:05 +0900 Subject: [PATCH 7/9] fix(gate): reap active preflight before cancellation --- runtime/bin/pr-gate.sh | 59 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/runtime/bin/pr-gate.sh b/runtime/bin/pr-gate.sh index 3e78cc48..9f10d465 100755 --- a/runtime/bin/pr-gate.sh +++ b/runtime/bin/pr-gate.sh @@ -3,8 +3,26 @@ set -euo pipefail trap '' PIPE GATE_CANCELLED=false +GATE_ACTIVE_PREFLIGHT_PID="" +GATE_ACTIVE_PREFLIGHT_PGID="" + +gate_stop_active_preflight() { + local pid="${GATE_ACTIVE_PREFLIGHT_PID:-}" pgid="${GATE_ACTIVE_PREFLIGHT_PGID:-}" + [[ "$pid" =~ ^[1-9][0-9]*$ && "$pgid" =~ ^[1-9][0-9]*$ ]] || return 0 + + # Operation-owned preflight runs in its own session so timeout and its + # managed command share one killable group during cancellation. Stop and + # reap that group before pr-gate exits; the parent operation only + # reaches `cancelled` after this producer process is gone. + detached_launch_kill_process_group "$pgid" 1 || true + wait "$pid" 2>/dev/null || true + GATE_ACTIVE_PREFLIGHT_PID="" + GATE_ACTIVE_PREFLIGHT_PGID="" +} + gate_cancel_signal() { GATE_CANCELLED=true + gate_stop_active_preflight exit 130 } trap gate_cancel_signal TERM INT @@ -3370,12 +3388,41 @@ if [[ "$SKIP_PREFLIGHT_TESTS" != "true" && -n "$TEST_CMD_OVERRIDE" ]]; then say 'pr-gate: running pre-flight test suite (timeout %ss, command sha256:%s)\n' \ "$TEST_TIMEOUT" "${_preflight_command_digest:0:12}" _preflight_rc=0 - ( cd "$WORK_DIR" && PM_DISPATCH_PREFLIGHT_TEST_RESULT="$PREFLIGHT_RICH_RESULT_PATH" \ - PM_DISPATCH_PREFLIGHT_SUBJECT_FINGERPRINT="$_preflight_before" \ - PM_DISPATCH_PREFLIGHT_BASE_COMMIT="$_preflight_base_commit" \ - PM_DISPATCH_PREFLIGHT_HEAD_COMMIT="$_preflight_head_commit" \ - timeout --kill-after=15 "$TEST_TIMEOUT" bash -c "$TEST_CMD_OVERRIDE" ) \ - > "$PREFLIGHT_LOG_PATH" 2>&1 || _preflight_rc=$? + if [[ -n "${PM_GATE_PARENT_OPERATION:-}" ]]; then + _detached_launch_lib="$SCRIPT_DIR/../lib/detached-launch.sh" + if ! declare -F detached_launch_kill_process_group >/dev/null 2>&1; then + # shellcheck disable=SC1090,SC1091 # resolved repo-relative runtime library. + [[ -r "$_detached_launch_lib" ]] && . "$_detached_launch_lib" + fi + declare -F detached_launch_kill_process_group >/dev/null 2>&1 || { + printf 'Error: operation-owned pre-flight cleanup helper is unavailable\n' >&2 + exit 2 + } + command -v setsid >/dev/null 2>&1 || { + printf 'Error: operation-owned pre-flight isolation requires setsid\n' >&2 + exit 2 + } + ( + cd "$WORK_DIR" + export PM_DISPATCH_PREFLIGHT_TEST_RESULT="$PREFLIGHT_RICH_RESULT_PATH" + export PM_DISPATCH_PREFLIGHT_SUBJECT_FINGERPRINT="$_preflight_before" + export PM_DISPATCH_PREFLIGHT_BASE_COMMIT="$_preflight_base_commit" + export PM_DISPATCH_PREFLIGHT_HEAD_COMMIT="$_preflight_head_commit" + exec setsid timeout --kill-after=15 "$TEST_TIMEOUT" bash -c "$TEST_CMD_OVERRIDE" + ) > "$PREFLIGHT_LOG_PATH" 2>&1 & + GATE_ACTIVE_PREFLIGHT_PID=$! + GATE_ACTIVE_PREFLIGHT_PGID=$! + wait "$GATE_ACTIVE_PREFLIGHT_PID" || _preflight_rc=$? + GATE_ACTIVE_PREFLIGHT_PID="" + GATE_ACTIVE_PREFLIGHT_PGID="" + else + ( cd "$WORK_DIR" && PM_DISPATCH_PREFLIGHT_TEST_RESULT="$PREFLIGHT_RICH_RESULT_PATH" \ + PM_DISPATCH_PREFLIGHT_SUBJECT_FINGERPRINT="$_preflight_before" \ + PM_DISPATCH_PREFLIGHT_BASE_COMMIT="$_preflight_base_commit" \ + PM_DISPATCH_PREFLIGHT_HEAD_COMMIT="$_preflight_head_commit" \ + timeout --kill-after=15 "$TEST_TIMEOUT" bash -c "$TEST_CMD_OVERRIDE" ) \ + > "$PREFLIGHT_LOG_PATH" 2>&1 || _preflight_rc=$? + fi _preflight_finished="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" _preflight_after="$(_preflight_tree_fingerprint)" || exit 2 _preflight_log_digest="$(_preflight_sha256_file "$PREFLIGHT_LOG_PATH")" || exit 2 From d6823c550031403cc936e3fe8e1c160e73ef163e Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 15:50:17 +0900 Subject: [PATCH 8/9] fix(gate): isolate preflight operation ownership --- runtime/bin/pr-gate.sh | 4 ++++ tests/shell/test-pmctl-gate.sh | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/runtime/bin/pr-gate.sh b/runtime/bin/pr-gate.sh index 9f10d465..b6aac1a1 100755 --- a/runtime/bin/pr-gate.sh +++ b/runtime/bin/pr-gate.sh @@ -3408,6 +3408,10 @@ if [[ "$SKIP_PREFLIGHT_TESTS" != "true" && -n "$TEST_CMD_OVERRIDE" ]]; then export PM_DISPATCH_PREFLIGHT_SUBJECT_FINGERPRINT="$_preflight_before" export PM_DISPATCH_PREFLIGHT_BASE_COMMIT="$_preflight_base_commit" export PM_DISPATCH_PREFLIGHT_HEAD_COMMIT="$_preflight_head_commit" + # The test command is a subject of this gate, not another producer owned + # by the same parent operation. Do not let nested pmctl/pr-gate fixtures + # attach themselves to or infer ownership from the outer gate. + unset PM_GATE_PARENT_OPERATION exec setsid timeout --kill-after=15 "$TEST_TIMEOUT" bash -c "$TEST_CMD_OVERRIDE" ) > "$PREFLIGHT_LOG_PATH" 2>&1 & GATE_ACTIVE_PREFLIGHT_PID=$! diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index 04a4989e..70d10a6f 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -1437,7 +1437,8 @@ IFS= read -r _ < "$release" wait "$descendant" RUNNER chmod +x "$runner" - printf -v test_cmd 'bash %q %q %q %q' "$runner" "$ready" "$release" "$pids" + printf -v test_cmd 'test -z "${PM_GATE_PARENT_OPERATION:-}" && bash %q %q %q %q' \ + "$runner" "$ready" "$release" "$pids" PM_DISPATCH_STATE_ROOT="$state" XDG_RUNTIME_DIR="$_GATE_CLI_XDG_RUNTIME_DIR" \ "$PMCTL" gate run --lifecycle foreground --cd "$work" --base HEAD~1 \ From e7a83e9f266e928fcbad881244cf98f38a73ea22 Mon Sep 17 00:00:00 2001 From: screenleon Date: Tue, 28 Jul 2026 16:24:48 +0900 Subject: [PATCH 9/9] fix(cli): synchronize gate cancel description --- cli/commands.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/commands.tsv b/cli/commands.tsv index f39dab87..2ec90e3c 100644 --- a/cli/commands.tsv +++ b/cli/commands.tsv @@ -41,7 +41,7 @@ decision add Append a structured decision. pmctl decision add --date --ti gate run Start the pull-request gate. pmctl gate run [options] experimental false true --executor, --test-cmd, --cd pmctl gate run --executor codex gate verify Verify a gate result artifact. pmctl gate verify experimental false false none pmctl gate verify .gate-results/result.json gate wait Wait for a detached gate run. pmctl gate wait [options] experimental false true --cd, --timeout pmctl gate wait gate-123 --cd . -gate cancel Cancel a gate parent operation and only its recorded child runs. pmctl gate cancel --cd [--grace ] experimental false true --cd, --grace pmctl gate cancel op-20260724T000000Z-abcdef --cd . +gate cancel Stop the verified gate producer process tree, then cancel only its recorded child runs; partial termination stays indeterminate. pmctl gate cancel --cd [--grace ] experimental false true --cd, --grace pmctl gate cancel op-20260724T000000Z-abcdef --cd . gate reconcile Reconcile a gate operation from trusted child terminal claims. pmctl gate reconcile --cd experimental false true --cd pmctl gate reconcile op-20260724T000000Z-abcdef --cd . context index Build the repository context index. pmctl context index [] [--source ] experimental false true --source pmctl context index . --source repo context update Update the repository context index. pmctl context update [] [] experimental false true none pmctl context update . README.md