@ikon85/agent-workflow-kit 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/board-to-waves/SKILL.md +13 -3
- package/.agents/skills/to-issues/SKILL.md +102 -27
- package/.agents/skills/to-prd/SKILL.md +13 -3
- package/.agents/skills/to-waves/SKILL.md +16 -4
- package/.agents/skills/wrapup/SKILL.md +0 -1
- package/.claude/skills/board-to-waves/SKILL.md +13 -3
- package/.claude/skills/codex-build/SKILL.md +69 -23
- package/.claude/skills/codex-review/SKILL.md +47 -37
- package/.claude/skills/grill-me-codex/SKILL.md +45 -34
- package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
- package/.claude/skills/to-issues/SKILL.md +102 -27
- package/.claude/skills/to-prd/SKILL.md +13 -3
- package/.claude/skills/to-waves/SKILL.md +16 -4
- package/.claude/skills/wrapup/SKILL.md +0 -1
- package/README.md +27 -0
- package/agent-workflow-kit.package.json +56 -16
- package/docs/research/wave-152-consumer-acceptance.md +98 -0
- package/package.json +1 -1
- package/scripts/board-sync.py +3 -7
- package/scripts/build-kit.test.mjs +42 -1
- package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
- package/scripts/codex-exec.sh +566 -0
- package/scripts/codex-exec.test.mjs +815 -0
- package/scripts/codex_proc.py +602 -0
- package/scripts/find-by-marker.py +68 -0
- package/scripts/marker_lib.py +88 -0
- package/scripts/release-state.test.mjs +53 -1
- package/scripts/render-anchor.py +111 -0
- package/scripts/test_marker_lib.py +154 -0
- package/scripts/test_render_anchor.py +267 -0
- package/scripts/test_retro_wrapup_contract.py +6 -0
- package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
- package/src/lib/bundle.mjs +10 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -u
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
5
|
+
PROC_HELPER="$SCRIPT_DIR/codex_proc.py"
|
|
6
|
+
TESTED_VERSIONS=("0.137.0" "0.144.6")
|
|
7
|
+
STATE_ROOT=${CODEX_EXEC_STATE_ROOT:-${TMPDIR:-/tmp}/codex-exec-state}
|
|
8
|
+
|
|
9
|
+
emit_json() {
|
|
10
|
+
python3 - "$@" <<'PY'
|
|
11
|
+
import json, sys
|
|
12
|
+
fields = {}
|
|
13
|
+
for pair in sys.argv[1:]:
|
|
14
|
+
key, value = pair.split("=", 1)
|
|
15
|
+
fields[key] = value
|
|
16
|
+
print(json.dumps(fields, sort_keys=True))
|
|
17
|
+
PY
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fail() {
|
|
21
|
+
local error=$1 message=$2 status=${3:-EXEC_FAILED}
|
|
22
|
+
emit_json "status=$status" "error=$error" "message=$message"
|
|
23
|
+
return 1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fail_process() {
|
|
27
|
+
local error=$1 message=$2 status=$3 process_status=$4
|
|
28
|
+
python3 - "$error" "$message" "$status" "$process_status" <<'PY'
|
|
29
|
+
import json
|
|
30
|
+
import signal
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
error, message, status, raw_status = sys.argv[1:]
|
|
34
|
+
process_status = int(raw_status)
|
|
35
|
+
exit_status = process_status
|
|
36
|
+
signal_name = None
|
|
37
|
+
if process_status >= 128:
|
|
38
|
+
try:
|
|
39
|
+
signal_name = signal.Signals(process_status - 128).name
|
|
40
|
+
exit_status = None
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
print(json.dumps({
|
|
44
|
+
"status": status,
|
|
45
|
+
"error": error,
|
|
46
|
+
"message": message,
|
|
47
|
+
"originalExitStatus": exit_status,
|
|
48
|
+
"signal": signal_name,
|
|
49
|
+
}, sort_keys=True))
|
|
50
|
+
PY
|
|
51
|
+
return 1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
positive_number() {
|
|
55
|
+
python3 - "$1" <<'PY' >/dev/null 2>&1
|
|
56
|
+
import math, sys
|
|
57
|
+
try:
|
|
58
|
+
value = float(sys.argv[1])
|
|
59
|
+
assert math.isfinite(value) and value > 0
|
|
60
|
+
except (ValueError, AssertionError):
|
|
61
|
+
raise SystemExit(1)
|
|
62
|
+
PY
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
nonnegative_integer() {
|
|
66
|
+
[[ $1 =~ ^[0-9]+$ ]]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
find_state() {
|
|
70
|
+
local run_id=$1
|
|
71
|
+
[[ $run_id =~ ^[A-Za-z0-9]+$ ]] || return 1
|
|
72
|
+
local candidate="$STATE_ROOT/codex-exec.$run_id"
|
|
73
|
+
[[ -d $candidate && -f $candidate/run-id ]] || return 1
|
|
74
|
+
[[ $(<"$candidate/run-id") == "$run_id" ]] || return 1
|
|
75
|
+
printf '%s\n' "$candidate"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
preflight() {
|
|
79
|
+
local codex_bin=$1 quiet=${2:-false} version_text version auth auth_rc help resume_help platform allowed=false
|
|
80
|
+
if [[ ! -x $codex_bin ]] && ! command -v "$codex_bin" >/dev/null 2>&1; then
|
|
81
|
+
fail CODEX_NOT_FOUND "Codex executable not found"
|
|
82
|
+
return 1
|
|
83
|
+
fi
|
|
84
|
+
version_text=$("$codex_bin" --version 2>&1) || {
|
|
85
|
+
fail VERSION_CHECK_FAILED "Unable to read Codex version"
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
version=${version_text##* }
|
|
89
|
+
for tested in "${TESTED_VERSIONS[@]}"; do
|
|
90
|
+
[[ $version == "$tested" ]] && allowed=true
|
|
91
|
+
done
|
|
92
|
+
if [[ $allowed != true ]]; then
|
|
93
|
+
fail UNTESTED_VERSION "Codex version is not in the exact tested allowlist"
|
|
94
|
+
return 1
|
|
95
|
+
fi
|
|
96
|
+
platform=$(uname -s)
|
|
97
|
+
if [[ $platform != Linux && $platform != Darwin ]] || ! python3 -c 'import os; assert hasattr(os, "setsid")' >/dev/null 2>&1; then
|
|
98
|
+
fail MISSING_CAPABILITY "Platform cannot create an owned process group"
|
|
99
|
+
return 1
|
|
100
|
+
fi
|
|
101
|
+
help=$("$codex_bin" exec --help 2>&1) || true
|
|
102
|
+
if [[ $help != *--json* || $help != *--sandbox* || $help != *resume* ]]; then
|
|
103
|
+
fail MISSING_CAPABILITY "Codex exec lacks required JSON, sandbox, or resume capability"
|
|
104
|
+
return 1
|
|
105
|
+
fi
|
|
106
|
+
resume_help=$("$codex_bin" exec resume --help 2>&1) || true
|
|
107
|
+
if [[ $resume_help != *--config* || $resume_help != *--json* ]]; then
|
|
108
|
+
fail MISSING_CAPABILITY "Codex exec resume lacks required config or JSON capability"
|
|
109
|
+
return 1
|
|
110
|
+
fi
|
|
111
|
+
auth=$("$codex_bin" login status 2>&1)
|
|
112
|
+
auth_rc=$?
|
|
113
|
+
if ((auth_rc)); then
|
|
114
|
+
fail_process AUTH_REQUIRED "$auth" AUTH "$auth_rc"
|
|
115
|
+
return 1
|
|
116
|
+
fi
|
|
117
|
+
[[ $quiet == true ]] || emit_json status=OK "version=$version" "auth=$auth" "platform=$platform"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
parse_options() {
|
|
121
|
+
CODEX_BIN=codex PROFILE= MODE= SANDBOX= PROMPT= PROMPT_FILE= RUN_ID= TIMEOUT= PROBE_TIMEOUT= DEBUG_RETAIN=false
|
|
122
|
+
while (($#)); do
|
|
123
|
+
case $1 in
|
|
124
|
+
--codex-bin|--profile|--mode|--prompt|--prompt-file|--run-id|--timeout|--probe-timeout)
|
|
125
|
+
(($# >= 2)) || { fail INVALID_ARGUMENT "Missing value for $1"; return 1; }
|
|
126
|
+
case $1 in
|
|
127
|
+
--codex-bin) CODEX_BIN=$2 ;;
|
|
128
|
+
--profile) PROFILE=$2 ;;
|
|
129
|
+
--mode) MODE=$2 ;;
|
|
130
|
+
--prompt) PROMPT=$2 ;;
|
|
131
|
+
--prompt-file) PROMPT_FILE=$2 ;;
|
|
132
|
+
--run-id) RUN_ID=$2 ;;
|
|
133
|
+
--timeout) TIMEOUT=$2 ;;
|
|
134
|
+
--probe-timeout) PROBE_TIMEOUT=$2 ;;
|
|
135
|
+
esac
|
|
136
|
+
shift 2 ;;
|
|
137
|
+
--debug-retain) DEBUG_RETAIN=true; shift ;;
|
|
138
|
+
*) fail INVALID_ARGUMENT "Unknown option: $1"; return 1 ;;
|
|
139
|
+
esac
|
|
140
|
+
done
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
prepare_prompt() {
|
|
144
|
+
local state_dir=$1
|
|
145
|
+
TEMP_PROMPT="$state_dir/.prompt-input"
|
|
146
|
+
if [[ -n $PROMPT_FILE ]]; then
|
|
147
|
+
[[ -f $PROMPT_FILE ]] || { fail PROMPT_NOT_FOUND "Prompt file not found"; return 1; }
|
|
148
|
+
cp "$PROMPT_FILE" "$TEMP_PROMPT"
|
|
149
|
+
elif [[ -n $PROMPT ]]; then
|
|
150
|
+
printf '%s' "$PROMPT" >"$TEMP_PROMPT"
|
|
151
|
+
else
|
|
152
|
+
fail PROMPT_REQUIRED "A prompt or prompt file is required"
|
|
153
|
+
return 1
|
|
154
|
+
fi
|
|
155
|
+
chmod 600 "$TEMP_PROMPT"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
launch_round() {
|
|
159
|
+
local state_dir=$1 round=$2 lease_token=$3 thread_id=${4:-} rc
|
|
160
|
+
prepare_prompt "$state_dir" || return 1
|
|
161
|
+
local command
|
|
162
|
+
if [[ -z $thread_id ]]; then
|
|
163
|
+
command=("$CODEX_BIN" exec --json --sandbox "$SANDBOX" -)
|
|
164
|
+
else
|
|
165
|
+
command=("$CODEX_BIN" exec resume "$thread_id" -c "sandbox_mode=$SANDBOX" --json -)
|
|
166
|
+
fi
|
|
167
|
+
python3 "$PROC_HELPER" run --state-dir "$state_dir" --round "$round" \
|
|
168
|
+
--profile "$PROFILE" --sandbox "$SANDBOX" --timeout "$TIMEOUT" \
|
|
169
|
+
--probe-timeout "$PROBE_TIMEOUT" --prompt-file "$TEMP_PROMPT" \
|
|
170
|
+
--lease-token "$lease_token" -- "${command[@]}"
|
|
171
|
+
rc=$?
|
|
172
|
+
rm -f "$TEMP_PROMPT"
|
|
173
|
+
return "$rc"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
acquire_lease() {
|
|
177
|
+
python3 "$PROC_HELPER" lease-acquire --state-dir "$1"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
release_lease() {
|
|
181
|
+
python3 "$PROC_HELPER" lease-release --state-dir "$1" --token "$2" >/dev/null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
release_or_fail() {
|
|
185
|
+
local state_dir=$1 lease_token=$2 run_id=$3 original=${4:-} release_rc
|
|
186
|
+
release_lease "$state_dir" "$lease_token" && return 0
|
|
187
|
+
release_rc=$?
|
|
188
|
+
python3 - "$original" "$run_id" "$release_rc" <<'PY'
|
|
189
|
+
import json
|
|
190
|
+
import signal
|
|
191
|
+
import sys
|
|
192
|
+
|
|
193
|
+
raw, run_id, raw_rc = sys.argv[1:]
|
|
194
|
+
try:
|
|
195
|
+
output = json.loads(raw)
|
|
196
|
+
if not isinstance(output, dict) or not isinstance(output.get("status"), str):
|
|
197
|
+
raise ValueError
|
|
198
|
+
except (json.JSONDecodeError, ValueError):
|
|
199
|
+
rc = int(raw_rc)
|
|
200
|
+
exit_status = rc
|
|
201
|
+
signal_name = None
|
|
202
|
+
if rc >= 128:
|
|
203
|
+
try:
|
|
204
|
+
signal_name = signal.Signals(rc - 128).name
|
|
205
|
+
exit_status = None
|
|
206
|
+
except ValueError:
|
|
207
|
+
pass
|
|
208
|
+
output = {
|
|
209
|
+
"status": "EXEC_FAILED",
|
|
210
|
+
"error": "LEASE_RELEASE_FAILED",
|
|
211
|
+
"message": "Run lease could not be released",
|
|
212
|
+
"runId": run_id,
|
|
213
|
+
"originalExitStatus": exit_status,
|
|
214
|
+
"signal": signal_name,
|
|
215
|
+
}
|
|
216
|
+
else:
|
|
217
|
+
output["cleanupStatus"] = "FAILED"
|
|
218
|
+
output["cleanupError"] = "LEASE_RELEASE_FAILED"
|
|
219
|
+
output["cleanupMessage"] = "Run lease could not be released"
|
|
220
|
+
print(json.dumps(output, sort_keys=True))
|
|
221
|
+
PY
|
|
222
|
+
return 1
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
supervisor_failed() {
|
|
226
|
+
local run_id=$1 process_status=$2
|
|
227
|
+
python3 - "$run_id" "$process_status" <<'PY'
|
|
228
|
+
import json
|
|
229
|
+
import signal
|
|
230
|
+
import sys
|
|
231
|
+
|
|
232
|
+
run_id, raw_status = sys.argv[1:]
|
|
233
|
+
process_status = int(raw_status)
|
|
234
|
+
exit_status = process_status
|
|
235
|
+
signal_name = None
|
|
236
|
+
if process_status >= 128:
|
|
237
|
+
try:
|
|
238
|
+
signal_name = signal.Signals(process_status - 128).name
|
|
239
|
+
exit_status = None
|
|
240
|
+
except ValueError:
|
|
241
|
+
pass
|
|
242
|
+
print(json.dumps({
|
|
243
|
+
"status": "EXEC_FAILED",
|
|
244
|
+
"error": "ROUND_SUPERVISOR_FAILED",
|
|
245
|
+
"message": "Round supervisor exited without structured output",
|
|
246
|
+
"runId": run_id,
|
|
247
|
+
"originalExitStatus": exit_status,
|
|
248
|
+
"signal": signal_name,
|
|
249
|
+
}, sort_keys=True))
|
|
250
|
+
PY
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
new_run() {
|
|
254
|
+
parse_options "$@" || return 1
|
|
255
|
+
PROFILE=${PROFILE:-review}
|
|
256
|
+
MODE=${MODE:-read-only}
|
|
257
|
+
[[ $PROFILE == review || $PROFILE == build ]] || { fail INVALID_PROFILE "Profile must be review or build"; return 1; }
|
|
258
|
+
[[ $MODE != danger-full-access ]] || {
|
|
259
|
+
fail DANGER_FULL_ACCESS_REJECTED "danger-full-access is not a supported lifecycle mode"; return 1;
|
|
260
|
+
}
|
|
261
|
+
[[ $MODE == read-only || $MODE == workspace-write ]] || {
|
|
262
|
+
fail INVALID_MODE "Mode must be read-only or workspace-write"; return 1;
|
|
263
|
+
}
|
|
264
|
+
SANDBOX=$MODE
|
|
265
|
+
[[ -n $PROMPT || -n $PROMPT_FILE ]] || { fail PROMPT_REQUIRED "A prompt or prompt file is required"; return 1; }
|
|
266
|
+
TIMEOUT=${TIMEOUT:-$([[ $PROFILE == review ]] && echo 600 || echo 1800)}
|
|
267
|
+
PROBE_TIMEOUT=${PROBE_TIMEOUT:-$([[ $PROFILE == review ]] && echo 30 || echo 60)}
|
|
268
|
+
positive_number "$TIMEOUT" && positive_number "$PROBE_TIMEOUT" || {
|
|
269
|
+
fail INVALID_TIMEOUT "Timeouts must be finite positive numbers"; return 1;
|
|
270
|
+
}
|
|
271
|
+
local stale_seconds stale_max_delete
|
|
272
|
+
stale_seconds=${CODEX_EXEC_STALE_SECONDS:-604800}
|
|
273
|
+
stale_max_delete=${CODEX_EXEC_STALE_MAX_DELETE:-8}
|
|
274
|
+
positive_number "$stale_seconds" && nonnegative_integer "$stale_max_delete" || {
|
|
275
|
+
fail INVALID_STALE_CONFIG "Stale cleanup limits must be a positive finite age and nonnegative integer count"
|
|
276
|
+
return 1
|
|
277
|
+
}
|
|
278
|
+
preflight "$CODEX_BIN" true || return 1
|
|
279
|
+
mkdir -p "$STATE_ROOT" && chmod 700 "$STATE_ROOT"
|
|
280
|
+
python3 "$PROC_HELPER" cleanup --state-root "$STATE_ROOT" \
|
|
281
|
+
--stale-seconds "$stale_seconds" --max-delete "$stale_max_delete" || {
|
|
282
|
+
fail STALE_CLEANUP_FAILED "Bounded stale-state cleanup failed"; return 1;
|
|
283
|
+
}
|
|
284
|
+
local state_dir run_id lease_token rc output
|
|
285
|
+
state_dir=$(mktemp -d "$STATE_ROOT/codex-exec.XXXXXXXX") || return 1
|
|
286
|
+
chmod 700 "$state_dir"
|
|
287
|
+
run_id=${state_dir##*.}
|
|
288
|
+
printf '%s\n' "$run_id" >"$state_dir/run-id"
|
|
289
|
+
printf '%s\n' "$PROFILE" >"$state_dir/profile"
|
|
290
|
+
printf '%s\n' "$SANDBOX" >"$state_dir/sandbox"
|
|
291
|
+
printf '1\n' >"$state_dir/next-round"
|
|
292
|
+
chmod 600 "$state_dir"/*
|
|
293
|
+
lease_token=$(acquire_lease "$state_dir") || {
|
|
294
|
+
fail ACTIVE_RUN "Run state is already owned by another lifecycle action"
|
|
295
|
+
return 1
|
|
296
|
+
}
|
|
297
|
+
output=$(launch_round "$state_dir" 1 "$lease_token")
|
|
298
|
+
rc=$?
|
|
299
|
+
release_or_fail "$state_dir" "$lease_token" "$run_id" "$output" || return 1
|
|
300
|
+
if [[ -n $output ]]; then
|
|
301
|
+
printf '%s\n' "$output" || return 1
|
|
302
|
+
else
|
|
303
|
+
supervisor_failed "$run_id" "$rc"
|
|
304
|
+
return 1
|
|
305
|
+
fi
|
|
306
|
+
return "$rc"
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
resume_run_locked() {
|
|
310
|
+
local state_dir=$1 lease_token=$2 stored_profile stored_sandbox thread_id round
|
|
311
|
+
[[ ! -f $state_dir/debug-retain ]] || {
|
|
312
|
+
fail RUN_FINALIZED "Run was finalized with debug retention"; return 1;
|
|
313
|
+
}
|
|
314
|
+
[[ -f $state_dir/profile && ! -L $state_dir/profile \
|
|
315
|
+
&& -f $state_dir/sandbox && ! -L $state_dir/sandbox ]] || {
|
|
316
|
+
fail INVALID_STATE "Persisted profile or sandbox state is missing or unsafe"; return 1;
|
|
317
|
+
}
|
|
318
|
+
stored_profile=$(<"$state_dir/profile")
|
|
319
|
+
stored_sandbox=$(<"$state_dir/sandbox")
|
|
320
|
+
[[ $stored_profile == review || $stored_profile == build ]] || {
|
|
321
|
+
fail INVALID_STATE "Persisted profile is not in the exact allowlist"; return 1;
|
|
322
|
+
}
|
|
323
|
+
[[ $stored_sandbox == read-only || $stored_sandbox == workspace-write ]] || {
|
|
324
|
+
fail INVALID_STATE "Persisted sandbox is not in the exact allowlist"; return 1;
|
|
325
|
+
}
|
|
326
|
+
PROFILE=$stored_profile
|
|
327
|
+
if [[ -n $MODE && $MODE != "$stored_sandbox" ]]; then
|
|
328
|
+
fail MODE_MISMATCH "Resume mode differs from persisted mode"
|
|
329
|
+
return 1
|
|
330
|
+
fi
|
|
331
|
+
SANDBOX=$stored_sandbox
|
|
332
|
+
[[ -f $state_dir/thread-id ]] || { fail NO_THREAD "Run has no resumable thread" NO-THREAD; return 1; }
|
|
333
|
+
thread_id=$(<"$state_dir/thread-id")
|
|
334
|
+
round=$(<"$state_dir/next-round")
|
|
335
|
+
TIMEOUT=${TIMEOUT:-$([[ $PROFILE == review ]] && echo 600 || echo 1800)}
|
|
336
|
+
PROBE_TIMEOUT=${PROBE_TIMEOUT:-$([[ $PROFILE == review ]] && echo 30 || echo 60)}
|
|
337
|
+
positive_number "$TIMEOUT" && positive_number "$PROBE_TIMEOUT" || {
|
|
338
|
+
fail INVALID_TIMEOUT "Timeouts must be finite positive numbers"; return 1;
|
|
339
|
+
}
|
|
340
|
+
[[ ! -f $state_dir/runtime.json ]] || {
|
|
341
|
+
fail ACTIVE_RUN "Run has an unreconciled active runtime"; return 1;
|
|
342
|
+
}
|
|
343
|
+
preflight "$CODEX_BIN" true || return 1
|
|
344
|
+
launch_round "$state_dir" "$round" "$lease_token" "$thread_id"
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
resume_run() {
|
|
348
|
+
parse_options "$@" || return 1
|
|
349
|
+
[[ -n $RUN_ID ]] || { fail RUN_ID_REQUIRED "Resume requires --run-id"; return 1; }
|
|
350
|
+
local state_dir lease_token rc output
|
|
351
|
+
state_dir=$(find_state "$RUN_ID") || { fail RUN_NOT_FOUND "Run state does not exist"; return 1; }
|
|
352
|
+
lease_token=$(acquire_lease "$state_dir") || {
|
|
353
|
+
if [[ -d $state_dir ]]; then
|
|
354
|
+
fail ACTIVE_RUN "Run state is already owned by another lifecycle action"
|
|
355
|
+
else
|
|
356
|
+
fail RUN_NOT_FOUND "Run state does not exist"
|
|
357
|
+
fi
|
|
358
|
+
return 1
|
|
359
|
+
}
|
|
360
|
+
output=$(resume_run_locked "$state_dir" "$lease_token")
|
|
361
|
+
rc=$?
|
|
362
|
+
release_or_fail "$state_dir" "$lease_token" "$RUN_ID" "$output" || return 1
|
|
363
|
+
if [[ -z $output ]]; then
|
|
364
|
+
supervisor_failed "$RUN_ID" "$rc"
|
|
365
|
+
return 1
|
|
366
|
+
fi
|
|
367
|
+
printf '%s\n' "$output" || return 1
|
|
368
|
+
return "$rc"
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
finish_run() {
|
|
372
|
+
local action=$1; shift
|
|
373
|
+
parse_options "$@" || return 1
|
|
374
|
+
[[ -n $RUN_ID ]] || { fail RUN_ID_REQUIRED "$action requires --run-id"; return 1; }
|
|
375
|
+
local state_dir lease_token
|
|
376
|
+
state_dir=$(find_state "$RUN_ID") || { fail RUN_NOT_FOUND "Run state does not exist"; return 1; }
|
|
377
|
+
if [[ $action == abort ]]; then
|
|
378
|
+
lease_token=$(python3 "$PROC_HELPER" abort-claim --state-dir "$state_dir") || {
|
|
379
|
+
fail ABORT_FAILED "Run ownership could not be claimed for cleanup"; return 1;
|
|
380
|
+
}
|
|
381
|
+
else
|
|
382
|
+
lease_token=$(acquire_lease "$state_dir") || {
|
|
383
|
+
fail ACTIVE_RUN "Use abort for an active run"
|
|
384
|
+
return 1
|
|
385
|
+
}
|
|
386
|
+
if [[ -f $state_dir/runtime.json ]]; then
|
|
387
|
+
release_or_fail "$state_dir" "$lease_token" "$RUN_ID" || return 1
|
|
388
|
+
fail ACTIVE_RUN "Use abort for an active run"
|
|
389
|
+
return 1
|
|
390
|
+
fi
|
|
391
|
+
fi
|
|
392
|
+
if [[ $DEBUG_RETAIN == false ]]; then
|
|
393
|
+
if ! rm -rf -- "$state_dir"; then
|
|
394
|
+
fail CLEANUP_FAILED "Run state could not be removed"
|
|
395
|
+
return 1
|
|
396
|
+
fi
|
|
397
|
+
else
|
|
398
|
+
if ! : >"$state_dir/debug-retain"; then
|
|
399
|
+
release_or_fail "$state_dir" "$lease_token" "$RUN_ID" || return 1
|
|
400
|
+
fail DEBUG_RETAIN_FAILED "Debug-retain marker could not be created"
|
|
401
|
+
return 1
|
|
402
|
+
fi
|
|
403
|
+
release_or_fail "$state_dir" "$lease_token" "$RUN_ID" || return 1
|
|
404
|
+
fi
|
|
405
|
+
emit_json status=OK "action=$action" "runId=$RUN_ID" "retained=$DEBUG_RETAIN"
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
handle_failure() {
|
|
409
|
+
local result= fallback_run_id= result_set=false parsed kind resolved output cleanup_output cleanup_rc=0
|
|
410
|
+
while (($#)); do
|
|
411
|
+
case $1 in
|
|
412
|
+
--result|--run-id)
|
|
413
|
+
(($# >= 2)) || { fail INVALID_ARGUMENT "Missing value for $1"; return 1; }
|
|
414
|
+
if [[ $1 == --result ]]; then
|
|
415
|
+
result=$2
|
|
416
|
+
result_set=true
|
|
417
|
+
else
|
|
418
|
+
fallback_run_id=$2
|
|
419
|
+
fi
|
|
420
|
+
shift 2 ;;
|
|
421
|
+
*) fail INVALID_ARGUMENT "Unknown option: $1"; return 1 ;;
|
|
422
|
+
esac
|
|
423
|
+
done
|
|
424
|
+
[[ $result_set == true ]] || { fail RESULT_REQUIRED "handle-failure requires --result"; return 1; }
|
|
425
|
+
|
|
426
|
+
parsed=$(python3 - "$result" "$fallback_run_id" <<'PY'
|
|
427
|
+
import json
|
|
428
|
+
import sys
|
|
429
|
+
|
|
430
|
+
raw, fallback = sys.argv[1:]
|
|
431
|
+
known_statuses = {
|
|
432
|
+
"OK", "AUTH", "CANCELLED", "HUNG", "TIMEOUT", "SIGNALLED",
|
|
433
|
+
"EXEC_FAILED", "MALFORMED-JSON", "NO-THREAD", "NO-VERDICT",
|
|
434
|
+
}
|
|
435
|
+
try:
|
|
436
|
+
result = json.loads(raw)
|
|
437
|
+
if not isinstance(result, dict):
|
|
438
|
+
raise ValueError
|
|
439
|
+
status = result.get("status")
|
|
440
|
+
if not isinstance(status, str) or status not in known_statuses:
|
|
441
|
+
raise ValueError
|
|
442
|
+
except (json.JSONDecodeError, ValueError):
|
|
443
|
+
parsed = {
|
|
444
|
+
"kind": "malformed",
|
|
445
|
+
"resolved": fallback,
|
|
446
|
+
"output": {
|
|
447
|
+
"status": "MALFORMED_RESULT",
|
|
448
|
+
"error": "MALFORMED_RESULT",
|
|
449
|
+
"message": "Failure result was empty or malformed",
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
else:
|
|
453
|
+
cleanup_failed = result.get("cleanupStatus") == "FAILED"
|
|
454
|
+
if status == "OK" and not cleanup_failed:
|
|
455
|
+
parsed = {
|
|
456
|
+
"kind": "ok",
|
|
457
|
+
"resolved": "",
|
|
458
|
+
"output": {
|
|
459
|
+
"status": "EXEC_FAILED",
|
|
460
|
+
"error": "RESULT_NOT_FAILED",
|
|
461
|
+
"message": "handle-failure requires a non-OK result",
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
else:
|
|
465
|
+
malformed_fields = any(
|
|
466
|
+
key in result and not isinstance(result[key], str)
|
|
467
|
+
for key in (
|
|
468
|
+
"error", "message", "cleanupStatus", "cleanupError", "cleanupMessage",
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
result_run_id = result.get("runId")
|
|
472
|
+
malformed_run_id = (
|
|
473
|
+
"runId" in result
|
|
474
|
+
and (
|
|
475
|
+
not isinstance(result_run_id, str)
|
|
476
|
+
or not result_run_id
|
|
477
|
+
or not result_run_id.isascii()
|
|
478
|
+
or not result_run_id.isalnum()
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
if malformed_fields or malformed_run_id:
|
|
482
|
+
parsed = {
|
|
483
|
+
"kind": "malformed",
|
|
484
|
+
"resolved": fallback,
|
|
485
|
+
"output": {
|
|
486
|
+
"status": "MALFORMED_RESULT",
|
|
487
|
+
"error": "MALFORMED_RESULT",
|
|
488
|
+
"message": "Failure result was empty or malformed",
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
else:
|
|
492
|
+
output = result
|
|
493
|
+
if result_run_id:
|
|
494
|
+
resolved = result_run_id
|
|
495
|
+
else:
|
|
496
|
+
resolved = fallback
|
|
497
|
+
parsed = {"kind": "failure", "resolved": resolved, "output": output}
|
|
498
|
+
print(json.dumps(parsed, separators=(",", ":"), sort_keys=True))
|
|
499
|
+
PY
|
|
500
|
+
) || {
|
|
501
|
+
fail MALFORMED_RESULT "Failure result was empty or malformed" MALFORMED_RESULT
|
|
502
|
+
return 1
|
|
503
|
+
}
|
|
504
|
+
kind=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["kind"])' "$parsed")
|
|
505
|
+
resolved=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["resolved"])' "$parsed")
|
|
506
|
+
output=$(python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["output"], separators=(",", ":"), sort_keys=True))' "$parsed")
|
|
507
|
+
|
|
508
|
+
if [[ $kind != ok && -n $resolved ]]; then
|
|
509
|
+
cleanup_output=$(finish_run abort --run-id "$resolved") || cleanup_rc=$?
|
|
510
|
+
if ((cleanup_rc)); then
|
|
511
|
+
output=$(python3 - "$output" "$cleanup_output" <<'PY'
|
|
512
|
+
import json
|
|
513
|
+
import sys
|
|
514
|
+
|
|
515
|
+
original = json.loads(sys.argv[1])
|
|
516
|
+
try:
|
|
517
|
+
cleanup = json.loads(sys.argv[2])
|
|
518
|
+
except json.JSONDecodeError:
|
|
519
|
+
cleanup = {}
|
|
520
|
+
if original.get("cleanupStatus") == "FAILED":
|
|
521
|
+
original["recoveryCleanupStatus"] = "FAILED"
|
|
522
|
+
original["recoveryCleanupError"] = cleanup.get("error", "CLEANUP_FAILED")
|
|
523
|
+
original["recoveryCleanupMessage"] = cleanup.get("message", "Run recovery cleanup failed")
|
|
524
|
+
else:
|
|
525
|
+
original["cleanupStatus"] = "FAILED"
|
|
526
|
+
original["cleanupError"] = cleanup.get("error", "CLEANUP_FAILED")
|
|
527
|
+
original["cleanupMessage"] = cleanup.get("message", "Run cleanup failed")
|
|
528
|
+
print(json.dumps(original, separators=(",", ":"), sort_keys=True))
|
|
529
|
+
PY
|
|
530
|
+
)
|
|
531
|
+
fi
|
|
532
|
+
fi
|
|
533
|
+
printf '%s\n' "$output"
|
|
534
|
+
return 1
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
dispatch_run_id_action() {
|
|
538
|
+
local action=$1; shift
|
|
539
|
+
if (($#)) && [[ $1 != --* ]]; then
|
|
540
|
+
local positional_run_id=$1; shift
|
|
541
|
+
if [[ $action == resume ]]; then
|
|
542
|
+
resume_run --run-id "$positional_run_id" "$@"
|
|
543
|
+
else
|
|
544
|
+
finish_run "$action" --run-id "$positional_run_id" "$@"
|
|
545
|
+
fi
|
|
546
|
+
elif [[ $action == resume ]]; then
|
|
547
|
+
resume_run "$@"
|
|
548
|
+
else
|
|
549
|
+
finish_run "$action" "$@"
|
|
550
|
+
fi
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
main() {
|
|
554
|
+
local action=${1:-}
|
|
555
|
+
[[ -n $action ]] || { fail ACTION_REQUIRED "Expected preflight, new, resume, finalize, abort, or handle-failure"; return 1; }
|
|
556
|
+
shift
|
|
557
|
+
case $action in
|
|
558
|
+
preflight) parse_options "$@" && preflight "$CODEX_BIN" ;;
|
|
559
|
+
new) new_run "$@" ;;
|
|
560
|
+
resume|finalize|abort) dispatch_run_id_action "$action" "$@" ;;
|
|
561
|
+
handle-failure) handle_failure "$@" ;;
|
|
562
|
+
*) fail INVALID_ACTION "Unknown action: $action" ;;
|
|
563
|
+
esac
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
main "$@"
|