@trycore/spec-build-harness 0.12.0 → 0.14.0
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/.claude-plugin/plugin.json +1 -1
- package/METODOLOGIA.md +6 -1
- package/README.md +1 -0
- package/VERSION +1 -1
- package/agents/build/dor-dod-gatekeeper.md +10 -3
- package/commands/build/epic.md +109 -0
- package/commands/build/onboard.md +59 -17
- package/commands/build/slice.md +4 -2
- package/commands/build/work.md +6 -2
- package/config/build-config.template.json +2 -1
- package/dist/commands/init.js +13 -0
- package/dist/commands/status.js +13 -1
- package/dist/lib/normalize.js +11 -0
- package/dist/lib/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/dist/lib/state-bundle.js +15 -1
- package/docs/commands.md +2 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/protocolo-cliente-runtime.md +90 -0
- package/hooks/build/design-source-guard.sh +12 -1
- package/hooks/build/heartbeat.sh +64 -4
- package/hooks/build/lib/agent-context.sh +16 -7
- package/hooks/build/lib/config.sh +25 -1
- package/hooks/build/lib/runtime-client.sh +540 -9
- package/hooks/build/lib/runtime-ops.sh +108 -0
- package/hooks/build/scaffold-guard.sh +14 -1
- package/hooks/build/slice-ops.sh +498 -16
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +165 -1
- package/scripts/tests/test-config.sh +41 -0
- package/scripts/tests/test-hooks-runtime.sh +198 -3
- package/scripts/tests/test-install.sh +23 -0
- package/scripts/tests/test-runtime-client.sh +500 -0
- package/scripts/tests/test-skill-ops.sh +574 -0
- package/skills/building-a-slice/references/dor.md +21 -9
- package/skills/building-a-slice/references/foundation-contract.md +4 -0
- package/state/README.md +12 -0
|
@@ -295,4 +295,504 @@ print('slice_id' in json.load(open(files[0])))
|
|
|
295
295
|
kill "$STUB_PID4" 2>/dev/null; wait "$STUB_PID4" 2>/dev/null
|
|
296
296
|
unset CLAUDE_PROJECT_DIR
|
|
297
297
|
|
|
298
|
+
# ── [#62] Carril directo de la outbox ────────────────────────────────────────────────
|
|
299
|
+
PORT_D="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
|
|
300
|
+
cat > "$TMP/routes-d.json" <<JSON
|
|
301
|
+
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-77", "status": "PROPOSED"}},
|
|
302
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
303
|
+
JSON
|
|
304
|
+
: > "$TMP/calls-d.jsonl"
|
|
305
|
+
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_D" "$TMP/routes-d.json" "$TMP/calls-d.jsonl" &
|
|
306
|
+
STUB_D=$!
|
|
307
|
+
trap 'kill $STUB_D 2>/dev/null; rm -rf "$TMP"' EXIT
|
|
308
|
+
sleep 0.3
|
|
309
|
+
|
|
310
|
+
export CLAUDE_PROJECT_DIR="$TMP/projD"
|
|
311
|
+
mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
|
|
312
|
+
BUILD_CONFIG_FILE="$TMP/projD-cfg.json"
|
|
313
|
+
echo '{"runtime":{"mode":"dual"}}' > "$BUILD_CONFIG_FILE"
|
|
314
|
+
export BUILD_CONFIG_FILE
|
|
315
|
+
|
|
316
|
+
runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_D\",\"project_token\":\"tok-d\",\"agent_key\":\"ak-1\",\"project_id\":\"p-1\"}"
|
|
317
|
+
rm -rf "$CLAUDE_PROJECT_DIR/.claude/state/outbox"
|
|
318
|
+
|
|
319
|
+
echo '{"title":"Notificaciones","objective":"avisar al usuario"}' > "$TMP/prop.json"
|
|
320
|
+
CID="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
321
|
+
[ -n "$CID" ] && echo "OK runtime_enqueue_direct devuelve el client_event_id" \
|
|
322
|
+
|| { echo "FAIL runtime_enqueue_direct sin id"; fail=1; }
|
|
323
|
+
f="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | head -1)"
|
|
324
|
+
python3 -c "
|
|
325
|
+
import json
|
|
326
|
+
d=json.load(open('$f'))
|
|
327
|
+
ok = d['channel']=='direct' and d['method']=='POST' and d['path']=='/projects/p-1/epic-proposals' \
|
|
328
|
+
and d['type']=='epic_proposed' and d['payload']['title']=='Notificaciones'
|
|
329
|
+
print('SI' if ok else 'NO')" | grep -q SI \
|
|
330
|
+
&& echo "OK el fichero del carril directo lleva canal, metodo, ruta y payload" \
|
|
331
|
+
|| { echo "FAIL formato del carril directo"; fail=1; }
|
|
332
|
+
|
|
333
|
+
# El lote de /events NO debe llevarse el fichero directo por delante.
|
|
334
|
+
# [Corrección mecánica: el stub relee routes-d.json en cada request (no solo al arrancar),
|
|
335
|
+
# así que se le inyecta el client_event_id del evento de lote recién encolado en la lista
|
|
336
|
+
# de aceptados — igual que hace el bloque de backoff más arriba (STUB_PID3) — para que el
|
|
337
|
+
# lote quede confirmado y la comprobación de "n=0" verifique lo que dice verificar: que
|
|
338
|
+
# AMBOS carriles quedan drenados, no que el carril directo sobrevive solo porque el lote
|
|
339
|
+
# nunca se confirma.
|
|
340
|
+
runtime_enqueue_event progress_noted '{"note":"hola"}' "sl-1"
|
|
341
|
+
evt_cid="$(python3 -c "
|
|
342
|
+
import json,glob
|
|
343
|
+
for p in glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json'):
|
|
344
|
+
d=json.load(open(p))
|
|
345
|
+
if d.get('type')=='progress_noted':
|
|
346
|
+
print(d['client_event_id']); break
|
|
347
|
+
")"
|
|
348
|
+
cat > "$TMP/routes-d.json" <<JSON
|
|
349
|
+
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-77", "status": "PROPOSED"}},
|
|
350
|
+
"POST /events": {"status": 200, "body": {"accepted": ["$evt_cid"], "duplicates": [], "rejected": []}}}
|
|
351
|
+
JSON
|
|
352
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
353
|
+
sent="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
354
|
+
import json,sys
|
|
355
|
+
paths=[json.loads(l)["path"] for l in open(sys.argv[1]) if l.strip()]
|
|
356
|
+
print("SI" if "/projects/p-1/epic-proposals" in paths and "/events" in paths else "NO")
|
|
357
|
+
PY
|
|
358
|
+
)"
|
|
359
|
+
[ "$sent" = SI ] && echo "OK el despacho atiende los dos carriles en el mismo ciclo" \
|
|
360
|
+
|| { echo "FAIL un carril se comio al otro ($sent)"; fail=1; }
|
|
361
|
+
body="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
362
|
+
import json,sys
|
|
363
|
+
for l in open(sys.argv[1]):
|
|
364
|
+
d=json.loads(l)
|
|
365
|
+
if d["path"]=="/projects/p-1/epic-proposals":
|
|
366
|
+
print("SI" if json.loads(d["body"]).get("title")=="Notificaciones" else "NO")
|
|
367
|
+
break
|
|
368
|
+
PY
|
|
369
|
+
)"
|
|
370
|
+
[ "$body" = SI ] && echo "OK el carril directo manda el payload tal cual" \
|
|
371
|
+
|| { echo "FAIL payload del carril directo ($body)"; fail=1; }
|
|
372
|
+
[ -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/acks/$CID.json" ] && echo "OK el ack del carril directo queda en acks/" \
|
|
373
|
+
|| { echo "FAIL sin ack en acks/"; fail=1; }
|
|
374
|
+
pid="$(python3 -c "
|
|
375
|
+
import json
|
|
376
|
+
print(json.load(open('$CLAUDE_PROJECT_DIR/.claude/state/outbox/acks/$CID.json'))['response'].get('proposal_id'))" 2>/dev/null)"
|
|
377
|
+
[ "$pid" = "prop-77" ] && echo "OK el ack conserva la respuesta del hub" \
|
|
378
|
+
|| { echo "FAIL el ack perdio la respuesta ($pid)"; fail=1; }
|
|
379
|
+
n="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
|
|
380
|
+
[ "$n" = 0 ] && echo "OK el carril directo borra el fichero entregado" \
|
|
381
|
+
|| { echo "FAIL fichero directo sin borrar ($n)"; fail=1; }
|
|
382
|
+
|
|
383
|
+
# 404 = instancia del hub sin soporte: se aparta con una razón que lo NOMBRA, no se reintenta.
|
|
384
|
+
cat > "$TMP/routes-d.json" <<JSON
|
|
385
|
+
{"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
386
|
+
JSON
|
|
387
|
+
CID2="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
388
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
389
|
+
# [Ruling R8] la variante glob del brief expande a varios argumentos dentro de `-f` (bash
|
|
390
|
+
# inválido); solo la variante `find` es correcta.
|
|
391
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected" -name "*$CID2.json" 2>/dev/null | head -1)"
|
|
392
|
+
[ -n "$found" ] && echo "OK un 404 aparta la peticion directa" || { echo "FAIL 404 sin apartar"; fail=1; }
|
|
393
|
+
python3 -c "
|
|
394
|
+
import json
|
|
395
|
+
print(json.load(open('$found')).get('rejected_reason',''))" 2>/dev/null | grep -qi 'sin soporte' \
|
|
396
|
+
&& echo "OK la razon del 404 nombra la falta de soporte del hub" \
|
|
397
|
+
|| { echo "FAIL razon del 404 poco accionable"; fail=1; }
|
|
398
|
+
|
|
399
|
+
# Combo [Important 1, revisión ronda 1]: el carril directo falla TRANSITORIO (5xx) en el
|
|
400
|
+
# MISMO ciclo en que el lote de /events se despacha con éxito. El rc de la función no puede
|
|
401
|
+
# ocultar que algo del carril directo quedó pendiente solo porque el lote fue bien — es lo
|
|
402
|
+
# que consumen `scripts/tests/test-skill-ops.sh` (`|| rc=$?`).
|
|
403
|
+
runtime_enqueue_event progress_noted '{"note":"combo"}' "sl-3"
|
|
404
|
+
evt_cid2="$(python3 -c "
|
|
405
|
+
import json,glob
|
|
406
|
+
for p in glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json'):
|
|
407
|
+
d=json.load(open(p))
|
|
408
|
+
if d.get('type')=='progress_noted':
|
|
409
|
+
print(d['client_event_id']); break
|
|
410
|
+
")"
|
|
411
|
+
CID5="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
412
|
+
cat > "$TMP/routes-d.json" <<JSON
|
|
413
|
+
{"POST /projects/p-1/epic-proposals": {"status": 500, "body": {"error": "boom"}},
|
|
414
|
+
"POST /events": {"status": 200, "body": {"accepted": ["$evt_cid2"], "duplicates": [], "rejected": []}}}
|
|
415
|
+
JSON
|
|
416
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
417
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
418
|
+
rc=$?
|
|
419
|
+
[ "$rc" -ne 0 ] && echo "OK rc distinto de 0 si el directo falla transitorio aunque el lote se despache bien" \
|
|
420
|
+
|| { echo "FAIL rc 0 pese a directo pendiente ($rc)"; fail=1; }
|
|
421
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID5.json" | head -1)"
|
|
422
|
+
[ -n "$found" ] && echo "OK la peticion directa transitoria sigue en cola pese al exito del lote" \
|
|
423
|
+
|| { echo "FAIL la peticion directa transitoria se perdio"; fail=1; }
|
|
424
|
+
gone="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$evt_cid2.json" | head -1)"
|
|
425
|
+
[ -z "$gone" ] && echo "OK el lote se entrego pese a que el carril directo fallo en el mismo ciclo" \
|
|
426
|
+
|| { echo "FAIL el lote no se entrego"; fail=1; }
|
|
427
|
+
|
|
428
|
+
# Offline: la petición directa SOBREVIVE en la cola (no se aparta, no se pierde).
|
|
429
|
+
kill "$STUB_D" 2>/dev/null; wait "$STUB_D" 2>/dev/null
|
|
430
|
+
CID3="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
431
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
432
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
433
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID3.json" | head -1)"
|
|
434
|
+
[ -n "$found" ] && echo "OK offline la peticion directa sigue encolada" \
|
|
435
|
+
|| { echo "FAIL la peticion directa se perdio offline"; fail=1; }
|
|
436
|
+
|
|
437
|
+
# La propuesta es protegida: la cota jamás la evicta.
|
|
438
|
+
case " $RUNTIME_OUTBOX_PROTECTED " in
|
|
439
|
+
*" epic_proposed "*) echo "OK epic_proposed es un tipo protegido de la cota" ;;
|
|
440
|
+
*) echo "FAIL epic_proposed evictable"; fail=1 ;;
|
|
441
|
+
esac
|
|
442
|
+
|
|
443
|
+
# [#63] La versión de grafo se estampa AL DESPACHAR: una propuesta encolada ayer no puede
|
|
444
|
+
# salir con la versión de ayer. El stub anterior está muerto (test offline lo mató); se
|
|
445
|
+
# levanta uno nuevo en el mismo puerto, reutilizando $CLAUDE_PROJECT_DIR=$TMP/projD (Ruling R2).
|
|
446
|
+
python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_D" "$TMP/routes-d.json" "$TMP/calls-d.jsonl" &
|
|
447
|
+
STUB_D=$!
|
|
448
|
+
sleep 0.3
|
|
449
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
450
|
+
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-88"}},
|
|
451
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
452
|
+
JSON
|
|
453
|
+
# El test offline anterior dejó "$CID3" pendiente en la cola sin despachar (el stub estaba
|
|
454
|
+
# muerto): se drena aquí para que no se cuele delante de "$CID4" y contamine el "primer
|
|
455
|
+
# match" de /projects/p-1/epic-proposals que verifican las aserciones de abajo.
|
|
456
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
457
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
458
|
+
: > "$TMP/calls-d.jsonl"
|
|
459
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":42}}'
|
|
460
|
+
CID4="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
461
|
+
# La versión cambia ENTRE el encolado y el despacho: gana la del despacho.
|
|
462
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":47}}'
|
|
463
|
+
: > "$TMP/calls-d.jsonl"
|
|
464
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
465
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
466
|
+
sent="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
467
|
+
import json,sys
|
|
468
|
+
for l in open(sys.argv[1]):
|
|
469
|
+
d=json.loads(l)
|
|
470
|
+
if d["path"]=="/projects/p-1/epic-proposals":
|
|
471
|
+
print(json.loads(d["body"]).get("graph_version"))
|
|
472
|
+
break
|
|
473
|
+
PY
|
|
474
|
+
)"
|
|
475
|
+
[ "$sent" = 47 ] && echo "OK la version se estampa al despachar, no al encolar" \
|
|
476
|
+
|| { echo "FAIL version estampada al encolar ($sent)"; fail=1; }
|
|
477
|
+
|
|
478
|
+
# Sin el 5º argumento, el cuerpo NO lleva versión (retrocompatible).
|
|
479
|
+
CID5="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
480
|
+
: > "$TMP/calls-d.jsonl"
|
|
481
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
482
|
+
has="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
483
|
+
import json,sys
|
|
484
|
+
for l in open(sys.argv[1]):
|
|
485
|
+
d=json.loads(l)
|
|
486
|
+
if d["path"]=="/projects/p-1/epic-proposals":
|
|
487
|
+
print("SI" if "graph_version" in json.loads(d["body"]) else "NO")
|
|
488
|
+
break
|
|
489
|
+
PY
|
|
490
|
+
)"
|
|
491
|
+
[ "$has" = NO ] && echo "OK sin estampado pedido, el cuerpo no cambia" || { echo "FAIL estampado no pedido"; fail=1; }
|
|
492
|
+
|
|
493
|
+
# 409 stale_graph en el carril directo: se anota el desfase y la petición SIGUE en la cola
|
|
494
|
+
# (la refresca el heartbeat, #61), no se pierde ni se aparta a la primera.
|
|
495
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
496
|
+
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 60}},
|
|
497
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
498
|
+
JSON
|
|
499
|
+
runtime_graph_clear_stale
|
|
500
|
+
CID6="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
501
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
502
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
503
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID6.json" | head -1)"
|
|
504
|
+
[ -n "$found" ] && echo "OK un 409 stale_graph conserva la peticion en la cola" \
|
|
505
|
+
|| { echo "FAIL la peticion se perdio con el 409"; fail=1; }
|
|
506
|
+
runtime_graph_status_read | python3 -c "
|
|
507
|
+
import json,sys; print(json.load(sys.stdin).get('server'))" | grep -qx 60 \
|
|
508
|
+
&& echo "OK el 409 del carril directo anota el desfase" || { echo "FAIL desfase sin anotar"; fail=1; }
|
|
509
|
+
|
|
510
|
+
# Tres 409 seguidos: se aparta con un mensaje que nombra el desfase (no bucle infinito).
|
|
511
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
|
|
512
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
|
|
513
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID6.json" | head -1)"
|
|
514
|
+
[ -z "$found" ] && echo "OK tras 3 rechazos la peticion deja de reintentarse" \
|
|
515
|
+
|| { echo "FAIL reintento infinito del 409"; fail=1; }
|
|
516
|
+
rej="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected" -name "*$CID6.json" | head -1)"
|
|
517
|
+
python3 -c "
|
|
518
|
+
import json
|
|
519
|
+
print(json.load(open('$rej')).get('rejected_reason',''))" 2>/dev/null | grep -q '60' \
|
|
520
|
+
&& echo "OK la razon del apartado nombra la version del hub" \
|
|
521
|
+
|| { echo "FAIL razon del apartado sin desfase"; fail=1; }
|
|
522
|
+
|
|
523
|
+
# ── Ronda de arreglo 1: Important 1/3/4 y Minor 6 ─────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
# [Important 1] Una versión de grafo NO ENTERA (hub#115 aún no existe; nada coacciona el
|
|
526
|
+
# tipo en agent-context.sh) no debe vaciar el payload y disfrazar el fallo de "rechazo del
|
|
527
|
+
# hub": la propuesta original tiene que seguir viajando intacta.
|
|
528
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":"42.5"}}'
|
|
529
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
530
|
+
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-100"}},
|
|
531
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
532
|
+
JSON
|
|
533
|
+
CID9="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
534
|
+
: > "$TMP/calls-d.jsonl"
|
|
535
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
536
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
537
|
+
body_ok="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
538
|
+
import json,sys
|
|
539
|
+
for l in open(sys.argv[1]):
|
|
540
|
+
d=json.loads(l)
|
|
541
|
+
if d["path"]=="/projects/p-1/epic-proposals":
|
|
542
|
+
b=d["body"]
|
|
543
|
+
print("SI" if b and json.loads(b).get("title")=="Notificaciones" else "NO")
|
|
544
|
+
break
|
|
545
|
+
PY
|
|
546
|
+
)"
|
|
547
|
+
[ "$body_ok" = SI ] && echo "OK una version de grafo no entera no vacia el payload" \
|
|
548
|
+
|| { echo "FAIL el payload se vacio con una version de grafo invalida"; fail=1; }
|
|
549
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":42}}'
|
|
550
|
+
|
|
551
|
+
# [Important 3] Un 2xx del carril directo limpia el marcador de desfase: la petición llegó,
|
|
552
|
+
# así que ya no vamos rancios.
|
|
553
|
+
runtime_graph_note_stale 42 99
|
|
554
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
555
|
+
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-101"}},
|
|
556
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
557
|
+
JSON
|
|
558
|
+
CID10="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
559
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
560
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
561
|
+
[ "$(runtime_graph_status_read)" = "{}" ] && echo "OK un 2xx del carril directo limpia el marcador de desfase" \
|
|
562
|
+
|| { echo "FAIL el marcador de desfase sobrevive a un 2xx"; fail=1; }
|
|
563
|
+
|
|
564
|
+
# [Important 4] Un 409 que NO trae reason=stale_graph no se confunde con un grafo rancio: es
|
|
565
|
+
# un rechazo de contrato normal, apartado de inmediato, sin inventar un desfase.
|
|
566
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
567
|
+
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "duplicate_proposal"}},
|
|
568
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
569
|
+
JSON
|
|
570
|
+
CID12="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
571
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
572
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
573
|
+
rej12="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected" -name "*$CID12.json" | head -1)"
|
|
574
|
+
[ -n "$rej12" ] && echo "OK un 409 sin reason=stale_graph se rechaza de inmediato (rechazo de contrato)" \
|
|
575
|
+
|| { echo "FAIL un 409 no-stale_graph se trato como grafo rancio"; fail=1; }
|
|
576
|
+
python3 -c "
|
|
577
|
+
import json
|
|
578
|
+
print(json.load(open('$rej12')).get('rejected_reason',''))" 2>/dev/null | grep -qi 'grafo' \
|
|
579
|
+
&& { echo "FAIL la razon del rechazo inventa un desfase de grafo que no existe"; fail=1; } \
|
|
580
|
+
|| echo "OK la razon del rechazo no inventa un desfase de grafo"
|
|
581
|
+
|
|
582
|
+
# [Minor 6] "3 rechazos CONSECUTIVOS" es literal: un transitorio intercalado reinicia la racha.
|
|
583
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
584
|
+
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 70}},
|
|
585
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
586
|
+
JSON
|
|
587
|
+
CID11="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
588
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
|
|
589
|
+
f11="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID11.json" | head -1)"
|
|
590
|
+
n1="$(python3 -c "import json; print(json.load(open('$f11')).get('graph_409'))" 2>/dev/null)"
|
|
591
|
+
[ "$n1" = 1 ] && echo "OK el primer 409 stale_graph cuenta 1" || { echo "FAIL primer conteo ($n1)"; fail=1; }
|
|
592
|
+
|
|
593
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
594
|
+
{"POST /projects/p-1/epic-proposals": {"status": 500, "body": {}},
|
|
595
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
596
|
+
JSON
|
|
597
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
|
|
598
|
+
n2="$(python3 -c "import json; print(json.load(open('$f11')).get('graph_409'))" 2>/dev/null)"
|
|
599
|
+
[ "$n2" = 0 ] && echo "OK un transitorio intercalado reinicia la racha de rechazos por grafo" \
|
|
600
|
+
|| { echo "FAIL la racha no se reinicio ($n2)"; fail=1; }
|
|
601
|
+
|
|
602
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
603
|
+
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 70}},
|
|
604
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
605
|
+
JSON
|
|
606
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
|
|
607
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
|
|
608
|
+
found11="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID11.json" | head -1)"
|
|
609
|
+
[ -n "$found11" ] && echo "OK tras el reinicio hacen falta 3 rechazos NUEVOS, no 2" \
|
|
610
|
+
|| { echo "FAIL se aparto con solo 2 rechazos tras el reinicio"; fail=1; }
|
|
611
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"; runtime_dispatch_outbox >/dev/null 2>&1
|
|
612
|
+
found11="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID11.json" | head -1)"
|
|
613
|
+
[ -z "$found11" ] && echo "OK el 3er rechazo consecutivo (tras el reinicio) aparta la peticion" \
|
|
614
|
+
|| { echo "FAIL no se aparto tras 3 consecutivos reales"; fail=1; }
|
|
615
|
+
|
|
616
|
+
# ── Ronda final de la rama: A1 (idempotencia + mutex), A2 (racha por versión), B1, C4 ──
|
|
617
|
+
|
|
618
|
+
# [A1a] El carril directo viaja con `client_event_id` en el CUERPO: es la clave de
|
|
619
|
+
# idempotencia con la que el hub deduplica. Sin ella, dos despachos de la misma propuesta
|
|
620
|
+
# (daemon + proceso de la skill) crean DOS propuestas y, al aprobarlas, dos EP-XXX.
|
|
621
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
622
|
+
{"POST /projects/p-1/epic-proposals": {"status": 201, "body": {"proposal_id": "prop-200"}},
|
|
623
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
624
|
+
JSON
|
|
625
|
+
CID13="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
626
|
+
: > "$TMP/calls-d.jsonl"
|
|
627
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
628
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
629
|
+
sent_cid="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
630
|
+
import json,sys
|
|
631
|
+
for l in open(sys.argv[1]):
|
|
632
|
+
d=json.loads(l)
|
|
633
|
+
if d["path"]=="/projects/p-1/epic-proposals":
|
|
634
|
+
print(json.loads(d["body"]).get("client_event_id") or "")
|
|
635
|
+
break
|
|
636
|
+
PY
|
|
637
|
+
)"
|
|
638
|
+
[ "$sent_cid" = "$CID13" ] && echo "OK el carril directo manda client_event_id como clave de idempotencia" \
|
|
639
|
+
|| { echo "FAIL el cuerpo del carril directo viaja sin client_event_id ($sent_cid vs $CID13)"; fail=1; }
|
|
640
|
+
sent_title="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
641
|
+
import json,sys
|
|
642
|
+
for l in open(sys.argv[1]):
|
|
643
|
+
d=json.loads(l)
|
|
644
|
+
if d["path"]=="/projects/p-1/epic-proposals":
|
|
645
|
+
print(json.loads(d["body"]).get("title") or "")
|
|
646
|
+
break
|
|
647
|
+
PY
|
|
648
|
+
)"
|
|
649
|
+
[ "$sent_title" = "Notificaciones" ] && echo "OK la clave de idempotencia no pisa el payload original" \
|
|
650
|
+
|| { echo "FAIL el payload se perdio al estampar la clave ($sent_title)"; fail=1; }
|
|
651
|
+
|
|
652
|
+
# [A1b] Mutex del despacho: con el lock tomado por otro proceso, la cola NO se toca, no se
|
|
653
|
+
# abre socket y el rc dice que quedó trabajo (nunca 0 = "drenada" sin haberlo comprobado).
|
|
654
|
+
LOCK="$(runtime_dispatch_lock_path)"
|
|
655
|
+
rmdir "$LOCK" 2>/dev/null
|
|
656
|
+
CID14="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
657
|
+
: > "$TMP/calls-d.jsonl"
|
|
658
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
659
|
+
mkdir -p "$LOCK"
|
|
660
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
661
|
+
rc=$?
|
|
662
|
+
[ "$rc" -eq 1 ] && echo "OK con el mutex tomado el despacho no reporta la cola como drenada" \
|
|
663
|
+
|| { echo "FAIL rc del despacho con el mutex tomado ($rc)"; fail=1; }
|
|
664
|
+
[ ! -s "$TMP/calls-d.jsonl" ] && echo "OK con el mutex tomado no se abre ningun socket" \
|
|
665
|
+
|| { echo "FAIL el despacho ignoro el mutex y llamo al hub"; fail=1; }
|
|
666
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID14.json" | head -1)"
|
|
667
|
+
[ -n "$found" ] && echo "OK con el mutex tomado la cola queda intacta" \
|
|
668
|
+
|| { echo "FAIL la cola se toco con el mutex tomado"; fail=1; }
|
|
669
|
+
rmdir "$LOCK" 2>/dev/null
|
|
670
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
671
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID14.json" | head -1)"
|
|
672
|
+
[ -z "$found" ] && echo "OK liberado el mutex, el despacho drena la cola" \
|
|
673
|
+
|| { echo "FAIL la cola no drena tras liberar el mutex"; fail=1; }
|
|
674
|
+
|
|
675
|
+
# Un lock HUÉRFANO (proceso muerto entre el mkdir y el rmdir) se libera por edad: si no, la
|
|
676
|
+
# cola de ese repo no volvería a despacharse nunca.
|
|
677
|
+
CID15="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
678
|
+
mkdir -p "$LOCK"
|
|
679
|
+
touch -t 200001010000 "$LOCK"
|
|
680
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
681
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
682
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID15.json" | head -1)"
|
|
683
|
+
[ -z "$found" ] && echo "OK un mutex huerfano se libera por edad y la cola vuelve a drenar" \
|
|
684
|
+
|| { echo "FAIL el mutex huerfano bloquea la cola para siempre"; fail=1; }
|
|
685
|
+
[ ! -d "$LOCK" ] && echo "OK el despacho suelta el mutex al terminar" \
|
|
686
|
+
|| { echo "FAIL el mutex queda tomado tras el despacho"; fail=1; }
|
|
687
|
+
|
|
688
|
+
# [A2] La racha de 3 rechazos por grafo rancio cuenta rechazos contra la MISMA versión local.
|
|
689
|
+
# Si entre rechazo y rechazo hubo un refresco de verdad (cambió la versión estampada), la
|
|
690
|
+
# propuesta NO puede agotar sus tres vidas: es el caso real con `context_refresh_s: 0` o con
|
|
691
|
+
# tres turnos en menos de 45 s.
|
|
692
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
693
|
+
{"POST /projects/p-1/epic-proposals": {"status": 409, "body": {"reason": "stale_graph", "graph_version": 90}},
|
|
694
|
+
"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
695
|
+
JSON
|
|
696
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":80}}'
|
|
697
|
+
CID16="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
698
|
+
for v in 81 82 83; do
|
|
699
|
+
# Cada ciclo despacha con una versión local NUEVA: es lo que haría el refresco del
|
|
700
|
+
# heartbeat entre turno y turno.
|
|
701
|
+
runtime_projection_write "{\"fetched_at\":\"2099-01-01T00:00:00Z\",\"context\":{\"graph_version\":$v}}"
|
|
702
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
703
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
704
|
+
done
|
|
705
|
+
f16="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID16.json" | head -1)"
|
|
706
|
+
[ -n "$f16" ] && echo "OK con la version local cambiando, la propuesta no agota la racha de 409" \
|
|
707
|
+
|| { echo "FAIL se aparto la propuesta pese a que cada 409 fue contra otra version"; fail=1; }
|
|
708
|
+
n16="$(python3 -c "import json; print(json.load(open('$f16')).get('graph_409'))" 2>/dev/null)"
|
|
709
|
+
[ "$n16" = 1 ] && echo "OK la racha se reinicia en cada version local nueva" \
|
|
710
|
+
|| { echo "FAIL la racha no se reinicio con la version nueva ($n16)"; fail=1; }
|
|
711
|
+
v16="$(python3 -c "import json; print(json.load(open('$f16')).get('graph_409_at_version'))" 2>/dev/null)"
|
|
712
|
+
[ "$v16" = 83 ] && echo "OK el marcador recuerda con que version local se rechazo" \
|
|
713
|
+
|| { echo "FAIL el marcador de version de la racha ($v16)"; fail=1; }
|
|
714
|
+
# La misma versión tres veces SÍ agota la racha (el corte sigue existiendo).
|
|
715
|
+
for _ in 1 2 3; do
|
|
716
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
717
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
718
|
+
done
|
|
719
|
+
f16="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID16.json" | head -1)"
|
|
720
|
+
[ -z "$f16" ] && echo "OK tres 409 contra la MISMA version local siguen apartando la propuesta" \
|
|
721
|
+
|| { echo "FAIL el corte de la racha desaparecio"; fail=1; }
|
|
722
|
+
|
|
723
|
+
# [B2] Un fichero SIN estampar que recibe un 409 no puede heredar la versión del fichero
|
|
724
|
+
# ANTERIOR del mismo lote: el marcador se graba con `local: null`, no con una versión inventada
|
|
725
|
+
# (ni con el 0, que tampoco es una versión real). El fixture necesita DOS ficheros directos en
|
|
726
|
+
# el MISMO despacho —uno estampado y otro no, en ese orden— porque `gv` es `local` a la función
|
|
727
|
+
# y con un solo fichero nace vacío igualmente: la fuga solo existe ENTRE iteraciones del bucle.
|
|
728
|
+
runtime_graph_clear_stale
|
|
729
|
+
runtime_projection_write '{"fetched_at":"2099-01-01T00:00:00Z","context":{"graph_version":77}}'
|
|
730
|
+
CID17A="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json" 1)"
|
|
731
|
+
CID17="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
732
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
733
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
734
|
+
# El orden FIFO (prefijo de timestamp) garantiza que el estampado se procesa primero; el
|
|
735
|
+
# marcador refleja el ÚLTIMO 409, o sea el del fichero sin estampar.
|
|
736
|
+
sent_gv="$(python3 - "$TMP/calls-d.jsonl" <<'PY'
|
|
737
|
+
import json,sys
|
|
738
|
+
last=None
|
|
739
|
+
for l in open(sys.argv[1]):
|
|
740
|
+
d=json.loads(l)
|
|
741
|
+
if d["path"]=="/projects/p-1/epic-proposals":
|
|
742
|
+
last=json.loads(d["body"]).get("graph_version")
|
|
743
|
+
print("" if last is None else last)
|
|
744
|
+
PY
|
|
745
|
+
)"
|
|
746
|
+
[ -z "$sent_gv" ] && echo "OK el fichero sin estampar viaja sin graph_version" \
|
|
747
|
+
|| { echo "FAIL el fichero sin estampar heredo una graph_version ($sent_gv)"; fail=1; }
|
|
748
|
+
lv="$(runtime_graph_status_read | python3 -c 'import json,sys; print(json.load(sys.stdin).get("local"))' 2>/dev/null)"
|
|
749
|
+
[ "$lv" = None ] && echo "OK sin version estampada, el desfase se anota como ausente (no 0 ni heredada)" \
|
|
750
|
+
|| { echo "FAIL el desfase invento una version local ($lv)"; fail=1; }
|
|
751
|
+
v17="$(python3 -c "import json;print(json.load(open('$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID17.json" | head -1)')).get('graph_409_at_version'))" 2>/dev/null)"
|
|
752
|
+
[ "$v17" = None ] && echo "OK la racha del fichero sin estampar no hereda la version del anterior" \
|
|
753
|
+
|| { echo "FAIL la racha heredo la version del fichero anterior ($v17)"; fail=1; }
|
|
754
|
+
find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 \( -name "*$CID17.json" -o -name "*$CID17A.json" \) -delete 2>/dev/null
|
|
755
|
+
|
|
756
|
+
# [B1] Un fichero ILEGIBLE de la cola se aparta con su razón en vez de quedarse atascado para
|
|
757
|
+
# siempre: como `batch` se saltaba en silencio al armar el lote y la cota nunca lo evictaba.
|
|
758
|
+
cat > "$TMP/routes-d.json" <<'JSON'
|
|
759
|
+
{"POST /events": {"status": 200, "body": {"accepted": [], "duplicates": [], "rejected": []}}}
|
|
760
|
+
JSON
|
|
761
|
+
BADF="$CLAUDE_PROJECT_DIR/.claude/state/outbox/20990101000000000000-corrupto.json"
|
|
762
|
+
printf 'esto no es json' > "$BADF"
|
|
763
|
+
rm -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json"
|
|
764
|
+
err_bad="$(runtime_dispatch_outbox 2>&1 >/dev/null)"
|
|
765
|
+
[ ! -f "$BADF" ] && echo "OK un fichero ilegible sale de la cola" \
|
|
766
|
+
|| { echo "FAIL el fichero ilegible sigue atascado en la cola"; fail=1; }
|
|
767
|
+
[ -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/rejected/$(basename "$BADF")" ] \
|
|
768
|
+
&& echo "OK el fichero ilegible se aparta a rejected/" \
|
|
769
|
+
|| { echo "FAIL el fichero ilegible no llego a rejected/"; fail=1; }
|
|
770
|
+
echo "$err_bad" | grep -qi 'ilegible' && echo "OK el apartado del fichero ilegible nombra la causa" \
|
|
771
|
+
|| { echo "FAIL el apartado del ilegible no dice por que ($err_bad)"; fail=1; }
|
|
772
|
+
|
|
773
|
+
# [C4] Si la partición de la cola falla (E2BIG con una cola enorme, python caído), el despacho
|
|
774
|
+
# NO puede devolver 0 con la cola llena ni borrar el backoff agendado: eso es un fallo
|
|
775
|
+
# reportado como drenaje limpio.
|
|
776
|
+
CID18="$(runtime_enqueue_direct epic_proposed POST /projects/p-1/epic-proposals "$TMP/prop.json")"
|
|
777
|
+
python3 -c "import json;json.dump({'next_attempt_epoch':0,'attempt':2}, open('$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json','w'))"
|
|
778
|
+
_runtime_partition_outbox() { return 1; }
|
|
779
|
+
err_part="$(runtime_dispatch_outbox 2>&1 >/dev/null)"
|
|
780
|
+
rc=$?
|
|
781
|
+
[ "$rc" -ne 0 ] && echo "OK una particion fallida no se reporta como cola drenada" \
|
|
782
|
+
|| { echo "FAIL rc 0 con la particion fallida"; fail=1; }
|
|
783
|
+
echo "$err_part" | grep -qi 'clasificar' && echo "OK la particion fallida se dice en stderr" \
|
|
784
|
+
|| { echo "FAIL la particion fallida es muda ($err_part)"; fail=1; }
|
|
785
|
+
[ -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json" ] \
|
|
786
|
+
&& echo "OK la particion fallida conserva el backoff agendado" \
|
|
787
|
+
|| { echo "FAIL el backoff se borro con la particion fallida"; fail=1; }
|
|
788
|
+
found="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name "*$CID18.json" | head -1)"
|
|
789
|
+
[ -n "$found" ] && echo "OK la particion fallida conserva la cola" \
|
|
790
|
+
|| { echo "FAIL la cola se perdio con la particion fallida"; fail=1; }
|
|
791
|
+
unset -f _runtime_partition_outbox
|
|
792
|
+
source "$ROOT/hooks/build/lib/runtime-client.sh"
|
|
793
|
+
|
|
794
|
+
kill "$STUB_D" 2>/dev/null; wait "$STUB_D" 2>/dev/null
|
|
795
|
+
|
|
796
|
+
unset CLAUDE_PROJECT_DIR BUILD_CONFIG_FILE
|
|
797
|
+
|
|
298
798
|
exit $fail
|