autonomous-sdlc-harness 0.4.0 → 0.4.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.
@@ -58,8 +58,12 @@
58
58
  # and are written only by the `hr_lane_*` functions.
59
59
  # 2. THE RUN REGISTRY writes the registry file its caller names. Fence: that
60
60
  # file is `<root>/<state_dir>/autonomous_logs/registry.json`, resolved
61
- # through `hr_state_path`, and it is written only by `hr_registry_init`
62
- # and `hr_registry_set`.
61
+ # through `hr_state_path`, plus the lock directory `<file>.lock` (and its
62
+ # `.stale.*` move-aside and `.break` mutex while a stale one is broken)
63
+ # and the temp files
64
+ # `.registry.*` in the registry's own directory. Written only by
65
+ # `hr_registry_init`, `hr_registry_set` and the `hr_registry_lock` /
66
+ # `hr_registry_unlock` pair `hr_registry_set` calls.
63
67
  # 3. THE REMOTE STATE BUNDLE writes the files its format lists. Fence: inside
64
68
  # `<root>/<state_dir>/` (resolved through `hr_state_dir`), only
65
69
  # `autonomous_logs/remote_status.json`, `clarifications/<branch>/`,
@@ -71,8 +75,8 @@
71
75
  # there but a writer's own failed temp file is ever removed.
72
76
  #
73
77
  # A caller that calls no `hr_lane_*`, `hr_registry_init`, `hr_registry_set`,
74
- # `hr_remote_status_write`, `hr_remote_bundle_write` or
75
- # `hr_remote_bundle_restore` function still gets a library that only reads. The
78
+ # `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
79
+ # `hr_remote_bundle_write` or `hr_remote_bundle_restore` function still gets a library that only reads. The
76
80
  # lane's ceilings are the only environment values here that carry policy, because
77
81
  # the lane is machine-scoped and has no configuration key to carry them; each is
78
82
  # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`,
@@ -165,7 +169,9 @@
165
169
  # because callers capture stdout. The one pass-through is the registry's two
166
170
  # writers, `hr_registry_init` and `hr_registry_set`, which leave the shell's,
167
171
  # `mktemp`'s and `jq`'s own stderr on a failed write to the caller, as the
168
- # watcher's bodies they replaced did — that stream is the watcher's log. That silence is why the lane reports a lock it BROKE through a
172
+ # watcher's bodies they replaced did — that stream is the watcher's log — and
173
+ # add one line of their own, naming the lock, when `hr_registry_set` cannot
174
+ # take the registry lock. That silence is why the lane reports a lock it BROKE through a
169
175
  # variable instead of a log line — the caller owns the log.
170
176
  #
171
177
  # NAMING. Every function is prefixed `hr_`; every variable this file touches
@@ -1110,31 +1116,187 @@ hr_push_env_files() {
1110
1116
  # Each function takes the registry file as its first argument — the caller
1111
1117
  # resolves it as `hr_state_path <root> autonomous_logs/registry.json` — and is
1112
1118
  # write exception 2 in the header. Every value is written as a JSON string;
1113
- # every write stamps `branch` and `updated_at` on the record and replaces the
1114
- # file through a `mktemp` + `mv`. No shell option is assumed: the caller may set
1115
- # `-e`, `-u` or neither.
1119
+ # every write stamps `branch` and `updated_at` on the record. No shell option is
1120
+ # assumed: the caller may set `-e`, `-u` or neither.
1121
+ #
1122
+ # EVERY WRITE IS A LOCKED READ-MODIFY-WRITE. Every script sharing the file
1123
+ # writes it, and so does the watcher's own `( … ) &` engine subshell, so two
1124
+ # unserialized writers each read the same record and the second `mv` silently
1125
+ # drops the first one's key. The lock is a `mkdir` of `<file>.lock` holding an
1126
+ # `owner` file with a per-call token — the `mktemp` name of that call's temp
1127
+ # file, because `$$` inside a subshell is the parent's pid and bash 3.2 has no
1128
+ # per-subshell pid variable — and only a call whose token is still in `owner`
1129
+ # releases it.
1130
+ #
1131
+ # STALENESS IS THE LOCK DIRECTORY'S AGE, NEVER A PID. The stall watchdog kills
1132
+ # the engine subshell mid-write, so a dead holder is expected, and a pid says
1133
+ # nothing about a lock the killed subshell took under its parent's `$$`. A write
1134
+ # is one `jq` over a file of a few records, well under a second, so a lock 10 s
1135
+ # old is a crashed holder; a waiter polls every 0.05 s for up to 12 s — past the
1136
+ # stale age, so a waiter that arrives just after a crash breaks the lock rather
1137
+ # than giving up. Both are constants here, not environment values: the header
1138
+ # allows policy-carrying environment values for the lane only.
1139
+ #
1140
+ # BREAKERS ARE SERIALIZED, AND NONE RENAMES A LIVE LOCK. A waiter that judges
1141
+ # the lock stale takes a second `mkdir` mutex, `<file>.lock.break`, judges the
1142
+ # lock's age again under it, and only then renames the lock aside and empties
1143
+ # it, as `hr_lane_acquire` does. A lock re-taken since the first judgement is
1144
+ # fresh, so the second leaves it alone. Between the second judgement and the
1145
+ # rename, nothing but a breaker or the lock's own holder frees the path:
1146
+ # breakers are serialized, and the holder of a lock that old is dead by the
1147
+ # stale rule. The watchdog can kill a breaker inside the mutex, so a mutex 10 s
1148
+ # old is removed by the next waiter. That removal is the one window left open.
1149
+ # Two waiters that judge one abandoned mutex old together can both remove it,
1150
+ # and the second can remove the fresh mutex the first has just taken. Two
1151
+ # breakers then judge the lock at once, so reaching it takes a breaker killed
1152
+ # inside the mutex, a stale lock and three concurrent writers.
1153
+ #
1154
+ # A write that cannot take the lock prints one stderr line naming it, writes
1155
+ # nothing and returns 1.
1156
+ #
1157
+ # ONE CALL, SEVERAL KEYS. `hr_registry_set <file> <branch> <key> <value> [<key>
1158
+ # <value> …]` writes every pair in one `jq` pass under one lock and one `mv`.
1159
+ # A caller writing two keys that a concurrent reader must never see apart — a
1160
+ # status and the reason for it — MUST pass them in one call.
1161
+ #
1162
+ # READERS TAKE NO LOCK. The temp file sits beside the registry, so the `mv` is a
1163
+ # same-filesystem rename and `hr_registry_get`, `hr_registry_branches` and any
1164
+ # other `jq` over the file see the old record or the new one, never a partial
1165
+ # one. `hr_registry_init` creates the file by `ln`-ing a complete temp file to
1166
+ # the registry name, which fails when the name exists, so a reader's create
1167
+ # never truncates a registry a writer has just created.
1116
1168
  # ---------------------------------------------------------------------------
1117
1169
 
1118
- # Create an empty registry at <file> when none exists.
1170
+ # The directory holding <file> — where its temp files go, so every `mv` over it
1171
+ # is a same-filesystem rename.
1172
+ hr_registry_dir() {
1173
+ local file="${1-}" dir
1174
+ case "$file" in
1175
+ */*)
1176
+ dir="${file%/*}"
1177
+ [ -n "$dir" ] || dir=/
1178
+ ;;
1179
+ *) dir=. ;;
1180
+ esac
1181
+ printf '%s\n' "$dir"
1182
+ }
1183
+
1184
+ # Create an empty registry at <file> when none exists. Atomic: the name is
1185
+ # either absent or a complete `{"runs":{}}` — never truncated, never partial.
1119
1186
  hr_registry_init() {
1120
- local file="${1-}"
1187
+ local file="${1-}" dir tmp
1121
1188
  [ -n "$file" ] || return 1
1122
- [ -f "$file" ] || printf '{"runs":{}}\n' >"$file"
1189
+ [ -f "$file" ] && return 0
1190
+ dir=$(hr_registry_dir "$file")
1191
+ tmp=$(mktemp "$dir/.registry.XXXXXX") || return 1
1192
+ if printf '{"runs":{}}\n' >"$tmp"; then
1193
+ ln "$tmp" "$file" 2>/dev/null || :
1194
+ fi
1195
+ rm -f "$tmp"
1196
+ [ -f "$file" ]
1197
+ }
1198
+
1199
+ # hr_registry_lock <file> <token> — take `<file>.lock` for <token>, breaking a
1200
+ # stale one (see the section comment). 0 when held; 1, with one stderr line,
1201
+ # when the wait ceiling passed.
1202
+ hr_registry_lock() {
1203
+ local lock="${1-}.lock" token="${2-}" polls=0 start="" now m stale bm
1204
+ # 12 — the wait ceiling and 10 — the stale age, both in seconds. The wait is
1205
+ # judged by the clock, because each poll's forks cost more than its sleep;
1206
+ # 240 polls is the ceiling only when `date` gives no epoch.
1207
+ while :; do
1208
+ if mkdir "$lock" 2>/dev/null; then
1209
+ if printf '%s\n' "$token" >"$lock/owner" 2>/dev/null; then
1210
+ return 0
1211
+ fi
1212
+ rm -f "$lock/owner" 2>/dev/null || :
1213
+ rmdir "$lock" 2>/dev/null || :
1214
+ break
1215
+ fi
1216
+ m=$(hr_lane_mtime "$lock")
1217
+ now=$(date +%s 2>/dev/null) || now=0
1218
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1219
+ [ -n "$start" ] || start="$now"
1220
+ if [ "$now" -gt 0 ]; then
1221
+ [ $((now - start)) -lt 12 ] || break
1222
+ else
1223
+ [ "$polls" -lt 240 ] || break
1224
+ fi
1225
+ if [ "$m" -gt 0 ] && [ "$now" -gt 0 ] && [ $((now - m)) -ge 10 ]; then
1226
+ if mkdir "$lock.break" 2>/dev/null; then
1227
+ # One breaker at a time: judge the age again under the mutex, so a
1228
+ # lock re-taken since the judgement above is fresh and left alone.
1229
+ m=$(hr_lane_mtime "$lock")
1230
+ now=$(date +%s 2>/dev/null) || now=0
1231
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1232
+ if [ "$m" -gt 0 ] && [ "$now" -gt 0 ] && [ $((now - m)) -ge 10 ]; then
1233
+ stale="$lock.stale.${token##*.}"
1234
+ if mv "$lock" "$stale" 2>/dev/null; then
1235
+ rm -f "$stale/owner" 2>/dev/null || :
1236
+ rmdir "$stale" 2>/dev/null || :
1237
+ fi
1238
+ fi
1239
+ rmdir "$lock.break" 2>/dev/null || :
1240
+ polls=$((polls + 1))
1241
+ continue
1242
+ fi
1243
+ # Another breaker holds the mutex. One the watchdog killed inside it
1244
+ # left it behind: a mutex 10 s old is removed here.
1245
+ bm=$(hr_lane_mtime "$lock.break")
1246
+ if [ "$bm" -gt 0 ] && [ $((now - bm)) -ge 10 ]; then
1247
+ rmdir "$lock.break" 2>/dev/null || :
1248
+ fi
1249
+ fi
1250
+ sleep 0.05
1251
+ polls=$((polls + 1))
1252
+ done
1253
+ printf 'hr_registry_set: could not take the registry lock %s\n' "$lock" >&2
1254
+ return 1
1255
+ }
1256
+
1257
+ # hr_registry_unlock <file> <token> — release `<file>.lock` only while its
1258
+ # `owner` still holds <token>; a lock broken and re-taken is someone else's.
1259
+ hr_registry_unlock() {
1260
+ local lock="${1-}.lock" token="${2-}" owner=""
1261
+ [ -r "$lock/owner" ] && { IFS= read -r owner <"$lock/owner" || :; } 2>/dev/null
1262
+ [ -n "$token" ] && [ "$owner" = "$token" ] || return 0
1263
+ rm -f "$lock/owner" 2>/dev/null || :
1264
+ rmdir "$lock" 2>/dev/null || :
1123
1265
  }
1124
1266
 
1125
- # hr_registry_set <file> <branch> <key> <value>
1267
+ # hr_registry_set <file> <branch> <key> <value> [<key> <value> …]
1268
+ # 1, writing nothing, on no key/value pair or an odd count of them. Each pair
1269
+ # reaches `jq` as `--arg kN` / `--arg vN` — jq 1.5 has no `$ARGS` — and only
1270
+ # those generated names enter the program text, never a value.
1126
1271
  hr_registry_set() {
1127
- local file="${1-}" branch="${2-}" key="${3-}" value="${4-}" tmp
1128
- hr_registry_init "$file" || :
1129
- tmp="$(mktemp)" || return 1
1130
- if jq --arg b "$branch" --arg k "$key" --arg v "$value" --arg now "$(date '+%Y-%m-%dT%H:%M:%S')" '
1131
- .runs[$b] = ((.runs[$b] // {}) + {($k): $v, "branch": $b, "updated_at": $now})
1132
- ' "$file" >"$tmp"; then
1133
- mv "$tmp" "$file"
1134
- else
1272
+ local file="${1-}" branch="${2-}" dir tmp fields="" i=0 status=0
1273
+ local -a args
1274
+ [ -n "$file" ] && [ "$#" -ge 4 ] && [ $(($# % 2)) -eq 0 ] || return 1
1275
+ shift 2
1276
+ args=(--arg b "$branch" --arg now "$(date '+%Y-%m-%dT%H:%M:%S')")
1277
+ while [ "$#" -gt 0 ]; do
1278
+ args+=(--arg "k$i" "$1" --arg "v$i" "$2")
1279
+ fields="$fields(\$k$i): \$v$i, "
1280
+ i=$((i + 1))
1281
+ shift 2
1282
+ done
1283
+ dir=$(hr_registry_dir "$file")
1284
+ tmp=$(mktemp "$dir/.registry.XXXXXX") || return 1
1285
+ if ! hr_registry_lock "$file" "$tmp"; then
1135
1286
  rm -f "$tmp"
1136
1287
  return 1
1137
1288
  fi
1289
+ hr_registry_init "$file" || :
1290
+ if jq "${args[@]}" "
1291
+ .runs[\$b] = ((.runs[\$b] // {}) + {$fields\"branch\": \$b, \"updated_at\": \$now})
1292
+ " "$file" >"$tmp"; then
1293
+ mv "$tmp" "$file" || status=1
1294
+ else
1295
+ status=1
1296
+ fi
1297
+ [ "$status" -eq 0 ] || rm -f "$tmp"
1298
+ hr_registry_unlock "$file" "$tmp"
1299
+ return "$status"
1138
1300
  }
1139
1301
 
1140
1302
  # hr_registry_get <file> <branch> <key> -> the value, or nothing
@@ -276,7 +276,7 @@
276
276
  # no job to stop and may complete before its cancel lands — trying every one even
277
277
  # after a failure. (3) Only when (1) and (2) all succeeded, and only when a
278
278
  # local registry record exists, it writes `remote_stopped_at` and sets `status`
279
- # to `failed` through `hr_registry_set`; a partial stop leaves the record alone
279
+ # to `failed` in one `hr_registry_set` call; a partial stop leaves the record alone
280
280
  # and exits 3, so running `stop` again is the remedy.
281
281
  #
282
282
  # `warm` dispatches action=warm on GitHub's OWN default branch (`gh repo view
@@ -811,8 +811,7 @@ verb_stop() {
811
811
 
812
812
  registry=$(hr_state_path "$root" autonomous_logs/registry.json) || registry=""
813
813
  if [ -n "$registry" ] && [ -f "$registry" ] && [ -n "$(hr_registry_get "$registry" "$branch" branch)" ]; then
814
- hr_registry_set "$registry" "$branch" remote_stopped_at "$stopped_at" \
815
- && hr_registry_set "$registry" "$branch" status failed \
814
+ hr_registry_set "$registry" "$branch" remote_stopped_at "$stopped_at" status failed \
816
815
  || echo "remote-run.sh: stopped on GitHub, but the local record of $branch could not be updated" >&2
817
816
  fi
818
817
  echo "remote-run.sh: stopped $branch"
@@ -904,6 +903,17 @@ set_or_fail() {
904
903
  }
905
904
  }
906
905
 
906
+ # set_many_or_fail <key> <value> [<key> <value> …] — every pair in one write, so
907
+ # a concurrent reader never sees an outcome half-applied.
908
+ set_many_or_fail() {
909
+ local keys="" i
910
+ hr_registry_set "$registry" "$branch" "$@" || {
911
+ for ((i = 1; i <= $#; i += 2)); do keys="$keys${keys:+, }${!i}"; done
912
+ echo "remote-run.sh: writing $keys of $branch to '$registry' failed" >&2
913
+ exit "$EXIT_USAGE"
914
+ }
915
+ }
916
+
907
917
  verb_status() {
908
918
  local runs finished_id synced_id field
909
919
  list_runs
@@ -935,18 +945,14 @@ verb_status() {
935
945
  sync_expired() {
936
946
  local line
937
947
  line=$(expired_line "$1")
938
- set_or_fail status paused
939
- set_or_fail pause_reason expired
940
- set_or_fail remote_run_id "$1"
941
- set_or_fail remote_run_url "$2"
942
- set_or_fail remote_detail "$line"
943
- set_or_fail remote_synced_at "$3"
948
+ set_many_or_fail status paused pause_reason expired remote_run_id "$1" \
949
+ remote_run_url "$2" remote_detail "$line" remote_synced_at "$3"
944
950
  echo "remote-run.sh: $line"
945
951
  }
946
952
 
947
953
  verb_sync() {
948
954
  local worktree runs newest id state url synced_id now older download status_file
949
- local status reason detail
955
+ local status reason detail resume_at cycles
950
956
  worktree=$(hr_registry_get "$registry" "$branch" worktree)
951
957
  if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then
952
958
  echo "remote-run.sh: refused, nothing written: the mirror working copy '$worktree' of $branch is missing" >&2
@@ -965,8 +971,7 @@ verb_sync() {
965
971
  now=$(date +%s)
966
972
 
967
973
  if [ "$state" != completed ]; then
968
- set_or_fail status running
969
- set_or_fail remote_synced_at "$now"
974
+ set_many_or_fail status running remote_synced_at "$now"
970
975
  echo "remote-run.sh: run $id of $branch is $state; the record is running, nothing downloaded"
971
976
  return 0
972
977
  fi
@@ -1039,14 +1044,11 @@ verb_sync() {
1039
1044
  detail="the job ended mid-run (its bundle still says running): $url"
1040
1045
  fi
1041
1046
  [ -n "$detail" ] || detail="synced from $url"
1042
- set_or_fail status "$status"
1043
- set_or_fail pause_reason "$reason"
1044
- set_or_fail usage_resume_at "$(hr_remote_status_get "$status_file" usage_resume_at || :)"
1045
- set_or_fail park_loop_cycles "$(hr_remote_status_get "$status_file" park_loop_cycles || :)"
1046
- set_or_fail remote_run_id "$id"
1047
- set_or_fail remote_run_url "$url"
1048
- set_or_fail remote_detail "$detail"
1049
- set_or_fail remote_synced_at "$now"
1047
+ resume_at=$(hr_remote_status_get "$status_file" usage_resume_at) || resume_at=""
1048
+ cycles=$(hr_remote_status_get "$status_file" park_loop_cycles) || cycles=""
1049
+ set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$resume_at" \
1050
+ park_loop_cycles "$cycles" remote_run_id "$id" remote_run_url "$url" \
1051
+ remote_detail "$detail" remote_synced_at "$now"
1050
1052
  echo "remote-run.sh: synced run $id of $branch: $status${reason:+ ($reason)}"
1051
1053
  return 0
1052
1054
  fi
@@ -1061,20 +1063,17 @@ verb_sync() {
1061
1063
  done
1062
1064
  fi
1063
1065
  if [ "$bundle_exists" -eq 1 ]; then
1064
- set_or_fail status paused
1065
- set_or_fail pause_reason killed
1066
- set_or_fail remote_run_id "$id"
1067
- set_or_fail remote_run_url "$url"
1068
- set_or_fail remote_detail "run $id ended with no state bundle (killed, cancelled or replaced): $url"
1069
- set_or_fail remote_synced_at "$now"
1066
+ set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
1067
+ remote_detail "run $id ended with no state bundle (killed, cancelled or replaced): $url" \
1068
+ remote_synced_at "$now"
1070
1069
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
1071
1070
  return 0
1072
1071
  fi
1073
1072
 
1074
1073
  # Case 5 — no bundle in any run.
1075
- set_or_fail status failed
1076
- set_or_fail remote_detail "no run of $branch ever uploaded a state bundle; newest: $url"
1077
- set_or_fail remote_synced_at "$now"
1074
+ set_many_or_fail status failed \
1075
+ remote_detail "no run of $branch ever uploaded a state bundle; newest: $url" \
1076
+ remote_synced_at "$now"
1078
1077
  echo "remote-run.sh: no run of $branch carries a state bundle; the record is failed"
1079
1078
  }
1080
1079
 
@@ -1,6 +1,6 @@
1
1
  # autonomous_logs/
2
2
 
3
- One readable transcript `<branch>.log` and one raw event log `<branch>.stream.jsonl` per dispatched run, plus the daemon's own `watcher.log`, the service manager's capture of its standard output and error as `watcher.out.log` and `watcher.err.log`, and `registry.json` — the run registry that indexes every run's status. All of it resolves to `<state_dir>/autonomous_logs/` **in the main checkout**, whichever sibling working copy a run actually executes in, so one repository has one set of these files however many runs it has in flight.
3
+ One readable transcript `<branch>.log` and one raw event log `<branch>.stream.jsonl` per dispatched run, plus the daemon's own `watcher.log`, the service manager's capture of its standard output and error as `watcher.out.log` and `watcher.err.log`, and `registry.json` — the run registry that indexes every run's status. All of it resolves to `<state_dir>/autonomous_logs/` **in the main checkout**, whichever sibling working copy a run actually executes in, so one repository has one set of these files however many runs it has in flight. A `registry.json.lock` directory beside the registry exists only for the width of one registry write; one left over after a crash is broken automatically by the next writer, and is never removed by hand while a watcher runs.
4
4
 
5
5
  Everything here is written by the run daemon and read by the operator watching or diagnosing a run: the transcript is formatted live and tailable while the run is going, and the raw stream beside it is the deep-debug copy and the one thing the usage gate can parse, since a run cannot read the stream it is itself producing. The registry has readers beyond the operator, and that is what makes it different in kind from its neighbours: it is the single record of which runs exist and what state each is in, and the harness's status command, its pause and resume commands, `<scripts_dir>/restart-watcher.sh` and `<scripts_dir>/cleanup-merged-worktrees.sh` all read it before they act, and `<scripts_dir>/remote-run.sh` reads and writes it for a run executing on GitHub Actions.
6
6