loki-mode 9.17.0 → 9.18.2
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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/issue-providers.sh +0 -24
- package/autonomy/lib/agent_readiness.py +1 -79
- package/autonomy/lib/outcome_ledger.py +0 -122
- package/autonomy/lib/proof-generator.py +4 -71
- package/autonomy/lib/verdict.py +4 -25
- package/autonomy/loki +444 -264
- package/autonomy/notify.sh +1 -70
- package/autonomy/queue-consumer.sh +18 -290
- package/autonomy/run.sh +280 -189
- package/autonomy/trigger-server.py +518 -17
- package/autonomy/verify.sh +100 -12
- package/bin/loki +7 -1
- package/completions/_loki +0 -3
- package/completions/loki.bash +2 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +1 -168
- package/dashboard/static/index.html +222 -249
- package/docs/COMPETITIVE-SCORECARD.md +38 -0
- package/docs/COMPETITOR-DEPLOYMENT-MODELS.md +475 -0
- package/docs/DEPLOYMENT.md +542 -0
- package/docs/STALE-STATE-AUDIT.md +174 -0
- package/docs/VERIFICATION-COST.md +46 -166
- package/loki-ts/dist/loki.js +311 -305
- package/mcp/__init__.py +1 -1
- package/mcp/_sdk_loader.py +0 -25
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/autonomy/lib/gate_policy.py +0 -166
- package/docs/QUEUE-OPERATIONS.md +0 -107
package/autonomy/notify.sh
CHANGED
|
@@ -86,68 +86,6 @@ _get_discord_color() {
|
|
|
86
86
|
# Slack Notifications
|
|
87
87
|
#===============================================================================
|
|
88
88
|
|
|
89
|
-
# Verdict fields for a Slack card, from the newest Evidence Receipt.
|
|
90
|
-
#
|
|
91
|
-
# Roadmap item 9: "put the receipt where review already happens". Slack
|
|
92
|
-
# notifications carried the event name and the project name and nothing else --
|
|
93
|
-
# grep for receipt/verdict/proof in this file returned 0. A team watching a
|
|
94
|
-
# channel saw "build finished" and had to go somewhere else to learn whether it
|
|
95
|
-
# was verified, which is the surface problem item 9 names: verification nobody
|
|
96
|
-
# sees does not build trust.
|
|
97
|
-
#
|
|
98
|
-
# Emits Slack `fields` entries (the payload already has that array), so this
|
|
99
|
-
# adds to the card rather than restructuring it.
|
|
100
|
-
#
|
|
101
|
-
# SILENT AND EMPTY ON ANY DOUBT. No receipt, unreadable JSON, no python -- emit
|
|
102
|
-
# nothing. A notification is a side channel; it must never fail a build, and it
|
|
103
|
-
# must never guess a verdict. An absent receipt yields no verdict field at all
|
|
104
|
-
# rather than a reassuring default.
|
|
105
|
-
_slack_verdict_fields() {
|
|
106
|
-
local loki_dir="${LOKI_DIR:-.loki}"
|
|
107
|
-
[ -d "$loki_dir/proofs" ] || return 0
|
|
108
|
-
command -v python3 >/dev/null 2>&1 || return 0
|
|
109
|
-
python3 - "$loki_dir" <<'PY' 2>/dev/null || true
|
|
110
|
-
import glob, json, os, sys
|
|
111
|
-
d = sys.argv[1]
|
|
112
|
-
paths = sorted(glob.glob(os.path.join(d, "proofs", "*", "proof.json")))
|
|
113
|
-
if not paths:
|
|
114
|
-
sys.exit(0)
|
|
115
|
-
try:
|
|
116
|
-
r = json.load(open(paths[-1]))
|
|
117
|
-
except (OSError, ValueError):
|
|
118
|
-
sys.exit(0)
|
|
119
|
-
|
|
120
|
-
def esc(s):
|
|
121
|
-
return json.dumps(str(s))[1:-1]
|
|
122
|
-
|
|
123
|
-
out = []
|
|
124
|
-
qg = r.get("quality_gates") or {}
|
|
125
|
-
gates = qg.get("gates") or []
|
|
126
|
-
if gates:
|
|
127
|
-
passed = sum(1 for g in gates if str(g.get("status", "")).startswith("pass"))
|
|
128
|
-
out.append(("Gates", f"{passed}/{len(gates)} passed"))
|
|
129
|
-
|
|
130
|
-
git = (r.get("facts") or {}).get("git") or {}
|
|
131
|
-
# base_sha empty means the receipt cannot be anchored, so it cannot be
|
|
132
|
-
# verified. Say that plainly rather than showing a diff count that implies it.
|
|
133
|
-
if not git.get("base_sha"):
|
|
134
|
-
out.append(("Verified", "no (unanchored receipt)"))
|
|
135
|
-
else:
|
|
136
|
-
diff = git.get("diff") or {}
|
|
137
|
-
if diff.get("count") is not None:
|
|
138
|
-
out.append(("Files changed", str(diff["count"])))
|
|
139
|
-
|
|
140
|
-
run_id = r.get("run_id")
|
|
141
|
-
if run_id:
|
|
142
|
-
out.append(("Receipt", esc(run_id)))
|
|
143
|
-
|
|
144
|
-
print(",".join(
|
|
145
|
-
'{"title": "%s", "value": "%s", "short": true}' % (esc(t), esc(v))
|
|
146
|
-
for t, v in out
|
|
147
|
-
))
|
|
148
|
-
PY
|
|
149
|
-
}
|
|
150
|
-
|
|
151
89
|
_notify_slack() {
|
|
152
90
|
local event="$1"
|
|
153
91
|
local title="$2"
|
|
@@ -168,13 +106,6 @@ _notify_slack() {
|
|
|
168
106
|
escaped_event="$(_json_escape "$event")"
|
|
169
107
|
escaped_project="$(_json_escape "$project")"
|
|
170
108
|
|
|
171
|
-
# Receipt-derived fields. Empty on any doubt, and the ${var:+,...} expansion
|
|
172
|
-
# below means an empty value adds no trailing comma -- a malformed payload
|
|
173
|
-
# would make Slack reject the whole card, so the failure mode of "no receipt"
|
|
174
|
-
# must be a card without verdict fields, never a card that does not send.
|
|
175
|
-
local verdict_fields
|
|
176
|
-
verdict_fields="$(_slack_verdict_fields 2>/dev/null || true)"
|
|
177
|
-
|
|
178
109
|
# Build Slack payload with attachment
|
|
179
110
|
local payload
|
|
180
111
|
payload=$(cat <<PAYLOAD
|
|
@@ -185,7 +116,7 @@ _notify_slack() {
|
|
|
185
116
|
"text": "$escaped_message",
|
|
186
117
|
"fields": [
|
|
187
118
|
{"title": "Event", "value": "$escaped_event", "short": true},
|
|
188
|
-
{"title": "Project", "value": "$escaped_project", "short": true}
|
|
119
|
+
{"title": "Project", "value": "$escaped_project", "short": true}
|
|
189
120
|
],
|
|
190
121
|
"footer": "Loki Mode",
|
|
191
122
|
"ts": $(date +%s)
|
|
@@ -30,30 +30,11 @@
|
|
|
30
30
|
# - Only redis and file are shipped. SQS, Pub/Sub, RabbitMQ, Kafka, etc. are
|
|
31
31
|
# BRING-YOUR-OWN: override queue.command in values.yaml with your own
|
|
32
32
|
# consumer. They are documented, not implemented here.
|
|
33
|
-
# - The redis backend is
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# It was at-most-once (LPOP-then-run) until this was fixed; a crash after
|
|
39
|
-
# the pop lost the build with no record anywhere.
|
|
40
|
-
# A VISIBILITY TIMEOUT now exists: `queue-consumer.sh --reap` requeues
|
|
41
|
-
# in-flight items whose claim is older than LOKI_QUEUE_VISIBILITY_SEC
|
|
42
|
-
# (default 7200). Run it on a schedule (a CronJob) or by hand. It is a
|
|
43
|
-
# separate mode, not folded into the consume loop, because a consumer that
|
|
44
|
-
# reaped on every poll would race its peers on a busy queue.
|
|
45
|
-
# `--reap` works on BOTH backends. It was redis-only until this was fixed,
|
|
46
|
-
# which left the file backend with no crash recovery at all: a dead worker's
|
|
47
|
-
# item sat in processing/ forever and an unattended fleet stalled on it.
|
|
48
|
-
# A FRESH claim is never reaped -- requeuing a live item would build the
|
|
49
|
-
# user's work twice -- and an in-flight item with NO claim is treated as
|
|
50
|
-
# infinitely old, since that means the worker died between the LMOVE and the
|
|
51
|
-
# claim stamp, which is exactly the case that must be recoverable.
|
|
52
|
-
# LOKI_QUEUE_ACK=0 restores the legacy lossy path.
|
|
53
|
-
# The file backend's claim time is the item file's MTIME, stamped on claim,
|
|
54
|
-
# since a flat directory carries no claim hash. The limit is real and worth
|
|
55
|
-
# stating: a consumer that rewrote its own item file would reset that clock
|
|
56
|
-
# and look fresh forever. See file_reap.
|
|
33
|
+
# - The redis backend is at-most-once (LPOP-then-run). It has no visibility
|
|
34
|
+
# timeout / dead-letter requeue. If a build crashes after the item is popped,
|
|
35
|
+
# that item is lost from the queue. For at-least-once delivery use the file
|
|
36
|
+
# backend (a crashed build leaves the item in processing/ for manual
|
|
37
|
+
# re-drive) or bring a real broker.
|
|
57
38
|
# - The file backend's atomicity relies on `mv` being atomic within a single
|
|
58
39
|
# filesystem (true for a normal PVC). Two consumers racing the same pending
|
|
59
40
|
# dir is safe (mv either wins or fails-and-skips), but is not load-balanced.
|
|
@@ -74,8 +55,6 @@
|
|
|
74
55
|
# LOKI_QUEUE_POLL_SEC loop-mode empty-poll wait, seconds (default: 5)
|
|
75
56
|
# LOKI_QUEUE_BLOCK_SEC redis BLPOP block timeout, seconds (default: 5)
|
|
76
57
|
# LOKI_TERMINAL_EXIT run.sh terminal-failure exit code (default: 20)
|
|
77
|
-
# LOKI_QUEUE_VISIBILITY_SEC --reap requeues in-flight items older than this,
|
|
78
|
-
# both backends, seconds (default: 7200)
|
|
79
58
|
#===============================================================================
|
|
80
59
|
|
|
81
60
|
set -uo pipefail
|
|
@@ -197,136 +176,17 @@ redis_cli() {
|
|
|
197
176
|
redis-cli -u "$QUEUE_URL" "$@"
|
|
198
177
|
}
|
|
199
178
|
|
|
200
|
-
# Pop one item from the redis list
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
# RPUSH loki-builds '{"spec":"build a todo app"}' -> LLEN 1
|
|
205
|
-
# LPOP loki-builds -> worker holds the item
|
|
206
|
-
# <worker is OOM-killed / node evicted / kill -9>
|
|
207
|
-
# LLEN loki-builds -> 0
|
|
208
|
-
#
|
|
209
|
-
# The user's build is gone and NOTHING anywhere records that it existed. For a
|
|
210
|
-
# product whose entire thesis is "we hand you a receipt you can check", silently
|
|
211
|
-
# losing the work is the worst failure mode available: there is no receipt, no
|
|
212
|
-
# error, and no queue entry to retry.
|
|
213
|
-
#
|
|
214
|
-
# LMOVE (redis 6.2+) pops and pushes to a processing list in ONE atomic step, so
|
|
215
|
-
# a crash between the two is impossible. A dead worker leaves its item in
|
|
216
|
-
# <key>:processing where `loki queue reap` (or an operator) can re-drive it.
|
|
217
|
-
# RPOPLPUSH is the pre-6.2 equivalent and is tried automatically.
|
|
218
|
-
#
|
|
219
|
-
# Kept OPT-OUT rather than opt-in (LOKI_QUEUE_ACK=0 restores LPOP). At-least-once
|
|
220
|
-
# is the safer default: its failure mode is a duplicate build, which the
|
|
221
|
-
# idempotence work already handles, versus silent data loss. But an operator
|
|
222
|
-
# running a broker that already guarantees delivery should be able to turn it
|
|
223
|
-
# off rather than maintain two in-flight records.
|
|
224
|
-
#
|
|
225
|
-
# In loop mode with acking we use a short-poll LMOVE plus a sleep instead of
|
|
226
|
-
# BLMOVE, because BLMOVE blocks the connection and the stop-flag check has to
|
|
227
|
-
# stay responsive; the empty-poll backoff below already bounds the cost.
|
|
179
|
+
# Pop one item from the redis list. In loop mode use BLPOP (blocks up to
|
|
180
|
+
# BLOCK_SEC, then returns empty so we can check the stop flag); in one-shot use
|
|
181
|
+
# LPOP (non-blocking, exits immediately on an empty queue).
|
|
182
|
+
# Prints the popped item to stdout, or nothing if the queue was empty.
|
|
228
183
|
redis_pop() {
|
|
229
|
-
if [ "$
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
redis-cli -u "$QUEUE_URL" BLPOP "$QUEUE_KEY" "$BLOCK_SEC" 2>/dev/null | sed -n '2p'
|
|
235
|
-
fi
|
|
236
|
-
return 0
|
|
237
|
-
fi
|
|
238
|
-
|
|
239
|
-
# RAW output, not --no-raw. `--no-raw` escapes the INNER quotes of a JSON
|
|
240
|
-
# payload ({"spec":"x"} comes back as "{\"spec\":\"x\"}"), and
|
|
241
|
-
# _redis_unquote only strips the outer pair -- so the item reaching
|
|
242
|
-
# extract_spec was mangled AND the string handed to LREM no longer matched
|
|
243
|
-
# what redis stored, silently acking nothing. Both were caught by running
|
|
244
|
-
# against a real redis 8.6.3; neither is visible in a mock.
|
|
245
|
-
local out
|
|
246
|
-
out="$(redis-cli -u "$QUEUE_URL" LMOVE "$QUEUE_KEY" "${QUEUE_KEY}:processing" LEFT RIGHT 2>/dev/null)"
|
|
247
|
-
# Pre-6.2 servers reject LMOVE as an unknown command (stderr, empty stdout),
|
|
248
|
-
# so fall back rather than reporting an empty queue.
|
|
249
|
-
if [ -z "$out" ]; then
|
|
250
|
-
out="$(redis-cli -u "$QUEUE_URL" RPOPLPUSH "$QUEUE_KEY" "${QUEUE_KEY}:processing" 2>/dev/null)"
|
|
251
|
-
fi
|
|
252
|
-
# Record WHEN this item was claimed. A bare list carries no time, so nothing
|
|
253
|
-
# could tell a stale entry (dead worker) from a running one (long build) --
|
|
254
|
-
# and requeuing a live item duplicates the user's build. The claim hash is
|
|
255
|
-
# what makes the reaper below safe to run at all.
|
|
256
|
-
#
|
|
257
|
-
# Written AFTER the move, so a crash between them leaves the item in-flight
|
|
258
|
-
# with no claim time. The reaper treats a missing claim as INFINITELY OLD
|
|
259
|
-
# rather than infinitely young: an item nothing is tracking is exactly the
|
|
260
|
-
# case that must be recoverable.
|
|
261
|
-
if [ -n "$out" ]; then
|
|
262
|
-
redis-cli -u "$QUEUE_URL" HSET "${QUEUE_KEY}:claims" "$out" "$(date +%s)" >/dev/null 2>&1 || true
|
|
184
|
+
if [ "$ONESHOT" = "1" ]; then
|
|
185
|
+
redis-cli -u "$QUEUE_URL" --no-raw LPOP "$QUEUE_KEY" 2>/dev/null | _redis_unquote
|
|
186
|
+
else
|
|
187
|
+
# BLPOP returns two lines: the key name, then the value. Take the value.
|
|
188
|
+
redis-cli -u "$QUEUE_URL" BLPOP "$QUEUE_KEY" "$BLOCK_SEC" 2>/dev/null | sed -n '2p'
|
|
263
189
|
fi
|
|
264
|
-
printf '%s' "$out"
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
# Remove a completed item from the in-flight list. Called ONLY after the build
|
|
268
|
-
# finished; until then the item stays recoverable.
|
|
269
|
-
#
|
|
270
|
-
# LREM with count 0 removes every equal element. Two identical specs queued twice
|
|
271
|
-
# would both clear on the first ack -- accepted deliberately: the alternative is
|
|
272
|
-
# a per-item token, which needs a producer change, and the failure mode here is
|
|
273
|
-
# one duplicate re-drive rather than a lost build.
|
|
274
|
-
redis_ack() {
|
|
275
|
-
[ "${LOKI_QUEUE_ACK:-1}" = "0" ] && return 0
|
|
276
|
-
[ -n "${1:-}" ] || return 0
|
|
277
|
-
redis-cli -u "$QUEUE_URL" LREM "${QUEUE_KEY}:processing" 0 "$1" >/dev/null 2>&1 || true
|
|
278
|
-
# Drop the claim too, or the hash grows without bound and every completed
|
|
279
|
-
# item looks like a candidate for reaping forever.
|
|
280
|
-
redis-cli -u "$QUEUE_URL" HDEL "${QUEUE_KEY}:claims" "$1" >/dev/null 2>&1 || true
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
# Requeue in-flight items whose claim is older than the visibility timeout.
|
|
284
|
-
#
|
|
285
|
-
# THE GAP THIS CLOSES. LMOVE made a crashed worker's item RECOVERABLE, but
|
|
286
|
-
# nothing recovered it: re-drive was an operator action, so an unattended fleet
|
|
287
|
-
# still stalled on every dead worker. That was documented rather than claimed;
|
|
288
|
-
# this closes it.
|
|
289
|
-
#
|
|
290
|
-
# SAFETY IS THE WHOLE DESIGN. Requeuing an item a worker is still building
|
|
291
|
-
# duplicates the user's build, so the timeout must exceed the longest legitimate
|
|
292
|
-
# build. Default 2h (LOKI_MAX_DURATION territory), tunable.
|
|
293
|
-
#
|
|
294
|
-
# An item with NO claim entry is treated as INFINITELY OLD, not young: it means
|
|
295
|
-
# the worker died between the LMOVE and the HSET, which is precisely the case
|
|
296
|
-
# that must be recoverable. Erring the other way would strand exactly the items
|
|
297
|
-
# this exists to rescue.
|
|
298
|
-
#
|
|
299
|
-
# Reports what it did on stdout. Requeues nothing when the timeout has not
|
|
300
|
-
# elapsed, and says so rather than printing a silent zero.
|
|
301
|
-
redis_reap() {
|
|
302
|
-
[ "${LOKI_QUEUE_ACK:-1}" = "0" ] && { echo "reap: acking disabled, nothing to reap"; return 0; }
|
|
303
|
-
local timeout now items requeued=0 kept=0
|
|
304
|
-
timeout="${LOKI_QUEUE_VISIBILITY_SEC:-7200}"
|
|
305
|
-
now="$(date +%s)"
|
|
306
|
-
items="$(redis-cli -u "$QUEUE_URL" LRANGE "${QUEUE_KEY}:processing" 0 -1 2>/dev/null)"
|
|
307
|
-
[ -n "$items" ] || { echo "reap: nothing in flight"; return 0; }
|
|
308
|
-
while IFS= read -r it; do
|
|
309
|
-
[ -n "$it" ] || continue
|
|
310
|
-
local claimed age
|
|
311
|
-
claimed="$(redis-cli -u "$QUEUE_URL" HGET "${QUEUE_KEY}:claims" "$it" 2>/dev/null)"
|
|
312
|
-
if [ -z "$claimed" ]; then
|
|
313
|
-
age="$((timeout + 1))" # unclaimed == infinitely old, see above
|
|
314
|
-
else
|
|
315
|
-
age="$((now - claimed))"
|
|
316
|
-
fi
|
|
317
|
-
if [ "$age" -gt "$timeout" ]; then
|
|
318
|
-
# LREM then RPUSH, not LMOVE: the item may appear more than once and
|
|
319
|
-
# LREM 0 clears every copy, so the queue cannot gain duplicates.
|
|
320
|
-
redis-cli -u "$QUEUE_URL" LREM "${QUEUE_KEY}:processing" 0 "$it" >/dev/null 2>&1 || true
|
|
321
|
-
redis-cli -u "$QUEUE_URL" HDEL "${QUEUE_KEY}:claims" "$it" >/dev/null 2>&1 || true
|
|
322
|
-
redis-cli -u "$QUEUE_URL" RPUSH "$QUEUE_KEY" "$it" >/dev/null 2>&1 || true
|
|
323
|
-
requeued=$((requeued + 1))
|
|
324
|
-
log "reaped stale item (age ${age}s > ${timeout}s), requeued"
|
|
325
|
-
else
|
|
326
|
-
kept=$((kept + 1))
|
|
327
|
-
fi
|
|
328
|
-
done <<< "$items"
|
|
329
|
-
echo "reap: requeued $requeued, left $kept in flight (timeout ${timeout}s)"
|
|
330
190
|
}
|
|
331
191
|
|
|
332
192
|
# --no-raw LPOP wraps the value in quotes; strip a single surrounding pair and
|
|
@@ -347,21 +207,10 @@ redis_consume_one() {
|
|
|
347
207
|
if [ -z "$item" ]; then
|
|
348
208
|
return 100 # sentinel: queue empty
|
|
349
209
|
fi
|
|
350
|
-
local spec
|
|
210
|
+
local spec
|
|
351
211
|
spec="$(extract_spec "$item")"
|
|
352
212
|
run_build "$spec"
|
|
353
|
-
|
|
354
|
-
# ACK ONLY ON SUCCESS, and the order is load-bearing: acking before the
|
|
355
|
-
# build would reintroduce the exact data loss this exists to prevent, and
|
|
356
|
-
# acking a FAILED build would discard work that a re-drive could complete.
|
|
357
|
-
# A failed item stays in <key>:processing so `loki queue reap` can requeue
|
|
358
|
-
# it -- the same shape as the file backend leaving it in processing/.
|
|
359
|
-
if [ "$rc" -eq 0 ]; then
|
|
360
|
-
redis_ack "$item"
|
|
361
|
-
else
|
|
362
|
-
log "item left in ${QUEUE_KEY}:processing for re-drive (exit $rc)"
|
|
363
|
-
fi
|
|
364
|
-
return "$rc"
|
|
213
|
+
return $?
|
|
365
214
|
}
|
|
366
215
|
|
|
367
216
|
# =============================================================================
|
|
@@ -392,18 +241,6 @@ file_claim_oldest() {
|
|
|
392
241
|
base="$(basename "$f")"
|
|
393
242
|
dest="$QUEUE_DIR/processing/$base"
|
|
394
243
|
if mv "$f" "$dest" 2>/dev/null; then
|
|
395
|
-
# Stamp the CLAIM TIME. This is the file-backend analogue of the
|
|
396
|
-
# `HSET <key>:claims` that redis_pop does right after its LMOVE, and
|
|
397
|
-
# file_reap below depends on it being here.
|
|
398
|
-
#
|
|
399
|
-
# Load-bearing: `mv` is a rename(2), which PRESERVES mtime. An item
|
|
400
|
-
# that sat in pending/ behind a backlog for longer than the
|
|
401
|
-
# visibility timeout would arrive in processing/ already looking
|
|
402
|
-
# stale, and the reaper would requeue it while a worker was actively
|
|
403
|
-
# building it -- the exact duplicate build the timeout exists to
|
|
404
|
-
# prevent, firing on precisely the busy queues that need a reaper.
|
|
405
|
-
# Verified on darwin: mv of a file stamped 2020 kept the 2020 mtime.
|
|
406
|
-
touch "$dest" 2>/dev/null || true
|
|
407
244
|
printf '%s' "$dest"
|
|
408
245
|
return 0
|
|
409
246
|
fi
|
|
@@ -435,110 +272,13 @@ file_consume_one() {
|
|
|
435
272
|
mv "$claimed" "$QUEUE_DIR/failed/$base" 2>/dev/null || log "WARN: could not move $base to failed/"
|
|
436
273
|
log "item $base TERMINAL-FAILED (exit $rc); moved to failed/, not acked"
|
|
437
274
|
else
|
|
438
|
-
# Transient crash: leave it in processing/ for re-drive. We do
|
|
439
|
-
# requeue
|
|
440
|
-
# could hot-loop a poison item). `--reap` picks it up once the
|
|
441
|
-
# visibility timeout has elapsed; see file_reap.
|
|
275
|
+
# Transient crash: leave it in processing/ for manual re-drive. We do NOT
|
|
276
|
+
# auto-requeue (no retry counter in a flat dir); honest at-least-once.
|
|
442
277
|
log "item $base crashed (exit $rc); left in processing/ for re-drive"
|
|
443
278
|
fi
|
|
444
279
|
return "$rc"
|
|
445
280
|
}
|
|
446
281
|
|
|
447
|
-
# Requeue in-flight items whose claim is older than the visibility timeout.
|
|
448
|
-
#
|
|
449
|
-
# THE GAP THIS CLOSES. The redis backend got a reaper; the file backend did not,
|
|
450
|
-
# so an item left in processing/ by a dead worker stayed there FOREVER and an
|
|
451
|
-
# unattended fleet on the file backend stalled on every crash. `--reap` answered
|
|
452
|
-
# "redis-only" and changed nothing.
|
|
453
|
-
#
|
|
454
|
-
# CLAIM TIME IS THE FILE MTIME. The file backend has no claim hash, so the mtime
|
|
455
|
-
# stamped by file_claim_oldest stands in for one.
|
|
456
|
-
#
|
|
457
|
-
# THE LIMIT, stated plainly: mtime is whatever last WROTE the file. A build that
|
|
458
|
-
# rewrites its own item file resets the clock and would look fresh forever, so
|
|
459
|
-
# it would never be reaped. Nothing here writes back to a claimed item, but a
|
|
460
|
-
# custom consumer that did would silently opt out of recovery. The redis claim
|
|
461
|
-
# hash does not have this weakness; the file backend accepts it rather than
|
|
462
|
-
# maintain a second sidecar file whose own staleness would need reaping too.
|
|
463
|
-
#
|
|
464
|
-
# There is also no retry counter (a flat dir carries none), so a poison item can
|
|
465
|
-
# be reaped repeatedly. That is the same shape the backend already documents for
|
|
466
|
-
# manual re-drive, not a new failure introduced here.
|
|
467
|
-
#
|
|
468
|
-
# SAFETY IS THE WHOLE DESIGN, mirroring redis_reap:
|
|
469
|
-
# - a FRESH item is NEVER requeued; a live build requeued is the user's work
|
|
470
|
-
# built twice, which is worse than the stranding this fixes
|
|
471
|
-
# - it NEVER deletes an item -- a name collision in pending/ leaves the item
|
|
472
|
-
# in processing/ and says so, rather than clobbering the pending copy
|
|
473
|
-
# - it reports counts on stdout; per-item detail goes to stderr via log()
|
|
474
|
-
file_reap() {
|
|
475
|
-
local timeout now requeued=0 kept=0 seen=0 f base mt age
|
|
476
|
-
timeout="${LOKI_QUEUE_VISIBILITY_SEC:-7200}"
|
|
477
|
-
now="$(date +%s)"
|
|
478
|
-
if [ ! -d "$QUEUE_DIR/processing" ]; then
|
|
479
|
-
echo "reap: nothing in flight"
|
|
480
|
-
return 0
|
|
481
|
-
fi
|
|
482
|
-
mkdir -p "$QUEUE_DIR/pending" 2>/dev/null || true
|
|
483
|
-
# Glob, not `ls`: a filename with a space or newline survives it.
|
|
484
|
-
for f in "$QUEUE_DIR"/processing/*; do
|
|
485
|
-
[ -e "$f" ] || continue # unmatched glob stays literal
|
|
486
|
-
[ -f "$f" ] || continue
|
|
487
|
-
seen=$((seen + 1))
|
|
488
|
-
base="$(basename "$f")"
|
|
489
|
-
# GNU FIRST, and the order is load-bearing. `stat -f` means
|
|
490
|
-
# --file-system on GNU coreutils and EXITS 0, printing a mount point
|
|
491
|
-
# rather than an mtime -- so a `-f`-first probe never falls through on
|
|
492
|
-
# Linux and hands back garbage. BSD `stat -c` has no such ambiguity: it
|
|
493
|
-
# is an illegal option and exits 1 (verified on darwin), so `-f %m` is
|
|
494
|
-
# reached only where it means what we want.
|
|
495
|
-
# `find -printf` and `-mmin` are GNU-only / minute-granular, and the
|
|
496
|
-
# fresh-vs-stale distinction needs seconds.
|
|
497
|
-
mt="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null)"
|
|
498
|
-
case "$mt" in
|
|
499
|
-
''|*[!0-9]*)
|
|
500
|
-
# Unreadable or non-numeric claim time. KEEP the item, and note
|
|
501
|
-
# the ASYMMETRY with redis_reap deliberately: a missing redis
|
|
502
|
-
# claim is DIAGNOSTIC (the worker died between LMOVE and HSET),
|
|
503
|
-
# but a file that exists ALWAYS has an mtime, so failing to read
|
|
504
|
-
# one means our stat invocation is wrong -- a portability bug,
|
|
505
|
-
# not a dead worker.
|
|
506
|
-
# MEASURED, not assumed: with a `stat` that prints a non-number
|
|
507
|
-
# and exits 0, the un-guarded arithmetic aborts the reaper under
|
|
508
|
-
# `set -uo pipefail` (rc=1, nothing requeued). So the observed
|
|
509
|
-
# failure is a DEAD REAPER on the affected platform, not a
|
|
510
|
-
# duplicate build. Keeping the item is correct either way, and
|
|
511
|
-
# the WARN names the reason instead of exiting silently.
|
|
512
|
-
kept=$((kept + 1))
|
|
513
|
-
log "WARN: cannot read mtime for $base (got '$mt'); left in processing/ rather than guess a claim time"
|
|
514
|
-
continue
|
|
515
|
-
;;
|
|
516
|
-
esac
|
|
517
|
-
age="$((now - mt))"
|
|
518
|
-
if [ "$age" -le "$timeout" ]; then
|
|
519
|
-
kept=$((kept + 1))
|
|
520
|
-
continue
|
|
521
|
-
fi
|
|
522
|
-
if [ -e "$QUEUE_DIR/pending/$base" ]; then
|
|
523
|
-
kept=$((kept + 1))
|
|
524
|
-
log "stale item $base NOT requeued: pending/$base already exists (never overwrite an item)"
|
|
525
|
-
continue
|
|
526
|
-
fi
|
|
527
|
-
if mv "$f" "$QUEUE_DIR/pending/$base" 2>/dev/null; then
|
|
528
|
-
requeued=$((requeued + 1))
|
|
529
|
-
log "reaped stale item $base (age ${age}s > ${timeout}s), requeued"
|
|
530
|
-
else
|
|
531
|
-
kept=$((kept + 1))
|
|
532
|
-
log "WARN: could not requeue $base; left in processing/"
|
|
533
|
-
fi
|
|
534
|
-
done
|
|
535
|
-
if [ "$seen" -eq 0 ]; then
|
|
536
|
-
echo "reap: nothing in flight"
|
|
537
|
-
return 0
|
|
538
|
-
fi
|
|
539
|
-
echo "reap: requeued $requeued, left $kept in flight (timeout ${timeout}s)"
|
|
540
|
-
}
|
|
541
|
-
|
|
542
282
|
# =============================================================================
|
|
543
283
|
# Driver
|
|
544
284
|
# =============================================================================
|
|
@@ -567,18 +307,6 @@ main() {
|
|
|
567
307
|
return 2
|
|
568
308
|
fi
|
|
569
309
|
|
|
570
|
-
# --reap requeues stale in-flight items and exits. Kept a SEPARATE mode
|
|
571
|
-
# rather than folded into the consume loop: a reaper wants its own cadence
|
|
572
|
-
# (a CronJob, or an operator hand-running it), and a consumer that reaped on
|
|
573
|
-
# every poll would race its own peers on a busy queue.
|
|
574
|
-
if [ "${1:-}" = "--reap" ] || [ "${LOKI_QUEUE_REAP:-0}" = "1" ]; then
|
|
575
|
-
case "$backend" in
|
|
576
|
-
redis) redis_reap ;;
|
|
577
|
-
file) file_init_dirs && file_reap ;;
|
|
578
|
-
esac
|
|
579
|
-
return $?
|
|
580
|
-
fi
|
|
581
|
-
|
|
582
310
|
if [ "$ONESHOT" = "1" ]; then
|
|
583
311
|
log "mode=oneshot backend=$backend (serverless: process one item then exit)"
|
|
584
312
|
consume_one "$backend"
|