Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion cli/commands.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ decision add Append a structured decision. pmctl decision add --date <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 <result-file> experimental false false none pmctl gate verify .gate-results/result.json
gate wait Wait for a detached gate run. pmctl gate wait <gate-id> [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 <operation-id> --cd <dir> [--grace <seconds>] 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 <operation-id> --cd <dir> [--grace <seconds>] 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 <operation-id> --cd <dir> experimental false true --cd pmctl gate reconcile op-20260724T000000Z-abcdef --cd .
context index Build the repository context index. pmctl context index [<repo-root>] [--source <repo|memory>] experimental false true --source pmctl context index . --source repo
context update Update the repository context index. pmctl context update [<repo-root>] [<path>] experimental false true none pmctl context update . README.md
Expand Down
47 changes: 46 additions & 1 deletion core/schema/operation.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,52 @@
"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"],
"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
}
},
"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"],
"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
}
24 changes: 24 additions & 0 deletions runtime/bin/gate-supervisor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,30 @@ 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
}
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 ─
Expand Down
85 changes: 78 additions & 7 deletions runtime/bin/pr-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@
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

# 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
Expand Down Expand Up @@ -2796,6 +2821,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
Expand Down Expand Up @@ -2849,7 +2876,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
Expand Down Expand Up @@ -3350,12 +3388,45 @@ 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"
# 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=$!
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
Expand Down
65 changes: 62 additions & 3 deletions runtime/lib/pmctl-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -288,12 +300,59 @@ 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.
# 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
# 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
Expand Down
Loading