@trycore/spec-build-harness 0.13.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 +34 -9
- 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/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/docs/commands.md +2 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/protocolo-cliente-runtime.md +64 -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/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 +456 -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
|
@@ -221,6 +221,28 @@ runtime_projection_path() {
|
|
|
221
221
|
echo "$(config_root)/.claude/state/runtime-projection.json"
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
# runtime_disconnected — 0 si este agente está en modo runtime y NO puede ver el
|
|
225
|
+
# estado del hub; 1 en cualquier otro caso.
|
|
226
|
+
#
|
|
227
|
+
# Existe para separar dos situaciones que hasta ahora colapsaban en el mismo
|
|
228
|
+
# `exit 0` de los guards y que significan lo contrario:
|
|
229
|
+
#
|
|
230
|
+
# auto-arme el proyecto todavía no usa el arnés (no hay credenciales) →
|
|
231
|
+
# no hay nada que vigilar y hay que dejar trabajar.
|
|
232
|
+
# desconectado el proyecto SÍ está registrado (hay credenciales) pero falta la
|
|
233
|
+
# proyección → el agente no sabe qué le manda el hub. Aquí callar
|
|
234
|
+
# es peor que estorbar: es exactamente el estado en el que dos
|
|
235
|
+
# workers reclaman el mismo trabajo o pisan los mismos ficheros.
|
|
236
|
+
#
|
|
237
|
+
# En legacy|dual devuelve 1 siempre: el fichero local es la fuente de verdad y no
|
|
238
|
+
# hay hub del que desconectarse.
|
|
239
|
+
runtime_disconnected() {
|
|
240
|
+
[ "$(runtime_mode)" = "runtime" ] || return 1
|
|
241
|
+
[ -f "$(runtime_credentials_path)" ] || return 1 # auto-arme legítimo
|
|
242
|
+
[ -f "$(runtime_projection_path)" ] && return 1
|
|
243
|
+
return 0
|
|
244
|
+
}
|
|
245
|
+
|
|
224
246
|
runtime_projection_read() {
|
|
225
247
|
local file; file="$(runtime_projection_path)"
|
|
226
248
|
[ -f "$file" ] || { echo "{}"; return; }
|
|
@@ -257,6 +279,76 @@ except Exception:
|
|
|
257
279
|
PY
|
|
258
280
|
}
|
|
259
281
|
|
|
282
|
+
# _runtime_graph_version — versión de grafo conocida por la proyección local ("" si el hub no
|
|
283
|
+
# versiona o no hay caché). Vive aquí, y no en projection.sh, porque el despacho de la outbox
|
|
284
|
+
# la necesita y projection.sh depende de este fichero (invertirlo sería un ciclo de sourcing).
|
|
285
|
+
_runtime_graph_version() {
|
|
286
|
+
local f; f="$(runtime_projection_path)"
|
|
287
|
+
[ -f "$f" ] || { echo ""; return; }
|
|
288
|
+
command -v python3 >/dev/null 2>&1 || { echo ""; return; }
|
|
289
|
+
python3 - "$f" <<'PY' 2>/dev/null || echo ""
|
|
290
|
+
import json,sys
|
|
291
|
+
try:
|
|
292
|
+
v=(json.load(open(sys.argv[1])).get("context") or {}).get("graph_version")
|
|
293
|
+
except Exception:
|
|
294
|
+
v=None
|
|
295
|
+
print("" if v is None else v)
|
|
296
|
+
PY
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
runtime_graph_status_path() { echo "$(config_root)/.claude/state/graph-status.json"; }
|
|
300
|
+
|
|
301
|
+
# runtime_graph_note_stale <local> <servidor> — deja constancia de que el hub rechazó una
|
|
302
|
+
# acción por grafo rancio [#63]. Es lo que hace VISIBLE el desfase en `status`: sin este
|
|
303
|
+
# rastro, el 409 se resolvería solo y el usuario nunca sabría que ocurrió.
|
|
304
|
+
# Una versión ausente (cadena vacía) se graba como `null`, NUNCA como 0: «no sé qué versión
|
|
305
|
+
# tenía» y «tenía la versión cero» son cosas distintas, y la segunda mentiría en `status`.
|
|
306
|
+
# [Ronda final] El fallo de escritura ya NO se traga: se avisa y se devuelve rc 1. Tragarlo
|
|
307
|
+
# borraba en silencio justo el rastro que esta función existe para dejar — el desfase habría
|
|
308
|
+
# ocurrido y nadie podría verlo.
|
|
309
|
+
runtime_graph_note_stale() {
|
|
310
|
+
local local_v="$1" server_v="$2" f
|
|
311
|
+
f="$(runtime_graph_status_path)"
|
|
312
|
+
mkdir -p "$(dirname "$f")"
|
|
313
|
+
command -v python3 >/dev/null 2>&1 || { echo "⚠ sin python3: no se pudo anotar el desfase de grafo (local v${local_v:-?}, hub v${server_v:-?})" >&2; return 1; }
|
|
314
|
+
python3 - "$f" "$local_v" "$server_v" <<'PY' 2>/dev/null || { echo "⚠ no se pudo anotar el desfase de grafo en $f (local v${local_v:-?}, hub v${server_v:-?}): no será visible en \`status\`" >&2; return 1; }
|
|
315
|
+
import json,sys,os,tempfile,datetime
|
|
316
|
+
path,lv,sv=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
317
|
+
def num(x):
|
|
318
|
+
try: return int(x)
|
|
319
|
+
except Exception: return None
|
|
320
|
+
d={"local":num(lv),"server":num(sv),
|
|
321
|
+
"seen_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
322
|
+
dirn=os.path.dirname(path) or "."
|
|
323
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".graph-status.",suffix=".tmp")
|
|
324
|
+
try:
|
|
325
|
+
with os.fdopen(fd,"w") as out:
|
|
326
|
+
json.dump(d,out,indent=2); out.flush(); os.fsync(out.fileno())
|
|
327
|
+
os.replace(tmp,path)
|
|
328
|
+
except Exception:
|
|
329
|
+
try: os.unlink(tmp)
|
|
330
|
+
except OSError: pass
|
|
331
|
+
raise
|
|
332
|
+
PY
|
|
333
|
+
return 0
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
runtime_graph_status_read() {
|
|
337
|
+
local f; f="$(runtime_graph_status_path)"
|
|
338
|
+
[ -f "$f" ] || { echo "{}"; return; }
|
|
339
|
+
command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
|
|
340
|
+
python3 - "$f" <<'PY' 2>/dev/null || echo "{}"
|
|
341
|
+
import json,sys
|
|
342
|
+
try:
|
|
343
|
+
print(json.dumps(json.load(open(sys.argv[1]))))
|
|
344
|
+
except Exception:
|
|
345
|
+
print("{}")
|
|
346
|
+
PY
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
# runtime_graph_clear_stale — el desfase dejó de existir (la proyección alcanzó al servidor).
|
|
350
|
+
runtime_graph_clear_stale() { rm -f "$(runtime_graph_status_path)" 2>/dev/null; return 0; }
|
|
351
|
+
|
|
260
352
|
RUNTIME_OUTBOX_MAX_BYTES=5242880
|
|
261
353
|
RUNTIME_OUTBOX_MAX_AGE_S=259200
|
|
262
354
|
# [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
|
|
@@ -275,7 +367,10 @@ RUNTIME_OUTBOX_MAX_AGE_S=259200
|
|
|
275
367
|
# [#52] `phase_advanced` es protegido: el orden de fases del hub es ESTRICTO — evictar un
|
|
276
368
|
# eslabón de la cadena haría ilegales todos los eventos posteriores del slice (incluido el
|
|
277
369
|
# slice_archived, que exige phase == pr).
|
|
278
|
-
|
|
370
|
+
# [#62] `epic_proposed` viaja por el CARRIL DIRECTO (su propio endpoint, no el lote de
|
|
371
|
+
# /events) pero comparte cola y cota: evictar una propuesta de épica perdería trabajo humano
|
|
372
|
+
# ya redactado y aprobado en la terminal, sin rastro en el hub.
|
|
373
|
+
RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated phase_advanced checkpoint_created verdict_reported escalation_raised epic_proposed"
|
|
279
374
|
|
|
280
375
|
runtime_outbox_dir() {
|
|
281
376
|
echo "$(config_root)/.claude/state/outbox"
|
|
@@ -313,6 +408,167 @@ PY
|
|
|
313
408
|
runtime_outbox_enforce_cap >/dev/null
|
|
314
409
|
}
|
|
315
410
|
|
|
411
|
+
# runtime_enqueue_direct <type> <method> <path> <payload_file> [stamp_graph_version] — encola
|
|
412
|
+
# una petición del CARRIL DIRECTO [#62]: un hecho que NO viaja en el lote de `POST /events`
|
|
413
|
+
# sino a su propio endpoint (hoy: la propuesta de épica, hub#113). Comparte con la cola de
|
|
414
|
+
# eventos el directorio, el formato en disco, la cota y el sentinela de flush; lo único
|
|
415
|
+
# distinto es a dónde se despacha. Imprime el `client_event_id` (referencia local mientras el
|
|
416
|
+
# hub no ha asignado el suyo). El payload va por FICHERO, nunca por argv.
|
|
417
|
+
# La RUTA llega ya resuelta: runtime-client.sh no conoce la tabla de endpoints — la escribe
|
|
418
|
+
# quien sí la conoce (lib/runtime-ops.sh), que es el único sitio del cliente con rutas.
|
|
419
|
+
# [#63] Con `1` en el 5º argumento, el fichero se marca para que el DESPACHO le estampe la
|
|
420
|
+
# versión de grafo vigente. Sellarla al encolar sería un error: una propuesta que espera dos
|
|
421
|
+
# días en la cola offline saldría con la versión de hace dos días y nacería condenada al 409.
|
|
422
|
+
runtime_enqueue_direct() {
|
|
423
|
+
local type="$1" method="$2" path="$3" pfile="$4" stamp="${5:-0}" dir
|
|
424
|
+
dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
|
|
425
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
426
|
+
python3 - "$dir" "$type" "$method" "$path" "$pfile" "$stamp" <<'PY' 2>/dev/null || return 1
|
|
427
|
+
import json,sys,os,tempfile,uuid,datetime
|
|
428
|
+
dir_,typ,method,path,pfile,stamp=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4],sys.argv[5],sys.argv[6]
|
|
429
|
+
try:
|
|
430
|
+
payload=json.load(open(pfile))
|
|
431
|
+
except Exception:
|
|
432
|
+
payload={}
|
|
433
|
+
cid=str(uuid.uuid4())
|
|
434
|
+
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
435
|
+
d={"client_event_id":cid,"type":typ,"channel":"direct","method":method,"path":path,
|
|
436
|
+
"payload":payload,"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
437
|
+
if stamp=="1":
|
|
438
|
+
d["stamp_graph_version"]=True
|
|
439
|
+
fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
|
|
440
|
+
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
441
|
+
try:
|
|
442
|
+
with os.fdopen(fd,"w") as out:
|
|
443
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
444
|
+
os.replace(tmp,fname)
|
|
445
|
+
except Exception:
|
|
446
|
+
try: os.unlink(tmp)
|
|
447
|
+
except OSError: pass
|
|
448
|
+
raise
|
|
449
|
+
print(cid)
|
|
450
|
+
PY
|
|
451
|
+
runtime_outbox_enforce_cap >/dev/null
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
# _runtime_direct_field <fichero> <clave> — escalar de primer nivel de una petición encolada
|
|
455
|
+
# ("" si falta o el fichero es ilegible).
|
|
456
|
+
_runtime_direct_field() {
|
|
457
|
+
command -v python3 >/dev/null 2>&1 || { echo ""; return; }
|
|
458
|
+
python3 - "$1" "$2" <<'PY' 2>/dev/null
|
|
459
|
+
import json,sys
|
|
460
|
+
try:
|
|
461
|
+
v=json.load(open(sys.argv[1])).get(sys.argv[2])
|
|
462
|
+
except Exception:
|
|
463
|
+
v=None
|
|
464
|
+
print("" if v is None else v)
|
|
465
|
+
PY
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# _runtime_direct_payload <fichero> — imprime el cuerpo que viaja al endpoint del carril
|
|
469
|
+
# directo: el payload guardado MÁS el `client_event_id` como CLAVE DE IDEMPOTENCIA ("{}" si
|
|
470
|
+
# falla). [#62 · ronda final] Sin esa clave el hub no podía deduplicar el carril directo: el
|
|
471
|
+
# lote de `/events` la lleva desde siempre (protocolo §5), pero aquí el `client_event_id`
|
|
472
|
+
# vivía solo como clave de primer nivel del FICHERO y no salía en la petición. Con dos
|
|
473
|
+
# despachadores posibles (el daemon y el proceso de la skill), un doble envío de la misma
|
|
474
|
+
# propuesta creaba DOS propuestas y, al aprobarlas, dos `EP-XXX` — la colisión que motiva #62,
|
|
475
|
+
# reintroducida por el otro extremo. Se estampa al armar el cuerpo, no al encolar: el formato
|
|
476
|
+
# en disco no cambia y los ficheros ya encolados drenan sin migración.
|
|
477
|
+
_runtime_direct_payload() {
|
|
478
|
+
command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
|
|
479
|
+
python3 - "$1" <<'PY' 2>/dev/null || echo "{}"
|
|
480
|
+
import json,sys
|
|
481
|
+
try:
|
|
482
|
+
d=json.load(open(sys.argv[1]))
|
|
483
|
+
p=d.get("payload")
|
|
484
|
+
if p is None:
|
|
485
|
+
p={}
|
|
486
|
+
if isinstance(p,dict):
|
|
487
|
+
cid=d.get("client_event_id")
|
|
488
|
+
# Un `client_event_id` ya presente en el payload manda: lo puso quien encoló y
|
|
489
|
+
# sobrescribirlo cambiaría la identidad de la petición a espaldas del productor.
|
|
490
|
+
if cid and "client_event_id" not in p:
|
|
491
|
+
p["client_event_id"]=cid
|
|
492
|
+
print(json.dumps(p,ensure_ascii=False))
|
|
493
|
+
except Exception:
|
|
494
|
+
print("{}")
|
|
495
|
+
PY
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
# _runtime_reject_file <fichero> <razón> — aparta una petición a rejected/ con su razón
|
|
499
|
+
# PERSISTIDA (la muestran `slice-ops.sh status` y `trycore-build status/doctor`: el stderr de
|
|
500
|
+
# un daemon no lo lee nadie). Nunca lanza.
|
|
501
|
+
_runtime_reject_file() {
|
|
502
|
+
local f="$1" reason="$2" rdir
|
|
503
|
+
rdir="$(runtime_outbox_dir)/rejected"
|
|
504
|
+
mkdir -p "$rdir"
|
|
505
|
+
echo "⚠ $reason" >&2
|
|
506
|
+
command -v python3 >/dev/null 2>&1 || { mv "$f" "$rdir/" 2>/dev/null; return 0; }
|
|
507
|
+
# [#62] Escritura ATÓMICA del apartado: `tempfile.mkstemp` en el mismo directorio destino
|
|
508
|
+
# + `os.replace` (restricción dura de la spec). Un `json.dump` directo a `dest` deja un
|
|
509
|
+
# fichero truncado legible a medio escribir si el proceso muere entre el open y el flush —
|
|
510
|
+
# inaceptable para algo que `slice-ops.sh status`/`trycore-build doctor` leen en caliente.
|
|
511
|
+
python3 - "$f" "$rdir" "$reason" <<'PY' 2>/dev/null || mv "$f" "$rdir/" 2>/dev/null
|
|
512
|
+
import json,sys,os,tempfile
|
|
513
|
+
f,rdir,reason=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
514
|
+
try:
|
|
515
|
+
d=json.load(open(f))
|
|
516
|
+
except Exception:
|
|
517
|
+
d=None
|
|
518
|
+
dest=os.path.join(rdir,os.path.basename(f))
|
|
519
|
+
if isinstance(d,dict):
|
|
520
|
+
d["rejected_reason"]=reason
|
|
521
|
+
fd,tmp=tempfile.mkstemp(dir=rdir,prefix=".rejected.",suffix=".tmp")
|
|
522
|
+
try:
|
|
523
|
+
with os.fdopen(fd,"w") as out:
|
|
524
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
525
|
+
os.replace(tmp,dest)
|
|
526
|
+
os.unlink(f)
|
|
527
|
+
except Exception:
|
|
528
|
+
try: os.unlink(tmp)
|
|
529
|
+
except OSError: pass
|
|
530
|
+
raise
|
|
531
|
+
else:
|
|
532
|
+
os.replace(f,dest)
|
|
533
|
+
PY
|
|
534
|
+
return 0
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
# _runtime_ack_direct <fichero> <respuesta-cruda> — deja el acuse en acks/<client_event_id>.json.
|
|
538
|
+
# El ledger de dominio (qué propuesta es, en qué estado está) NO vive aquí: runtime-client.sh
|
|
539
|
+
# es agnóstico al dominio. Quien correlaciona es lib/runtime-ops.sh.
|
|
540
|
+
_runtime_ack_direct() {
|
|
541
|
+
local f="$1" resp="$2" adir
|
|
542
|
+
adir="$(runtime_outbox_dir)/acks"
|
|
543
|
+
mkdir -p "$adir"
|
|
544
|
+
command -v python3 >/dev/null 2>&1 || return 0
|
|
545
|
+
python3 - "$f" "$adir" "$resp" <<'PY' 2>/dev/null
|
|
546
|
+
import json,sys,os,tempfile,datetime
|
|
547
|
+
f,adir,resp=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
548
|
+
try:
|
|
549
|
+
d=json.load(open(f))
|
|
550
|
+
except Exception:
|
|
551
|
+
d={}
|
|
552
|
+
try:
|
|
553
|
+
body=json.loads(resp)
|
|
554
|
+
except Exception:
|
|
555
|
+
body={}
|
|
556
|
+
cid=d.get("client_event_id") or "sin-id"
|
|
557
|
+
out={"client_event_id":cid,"type":d.get("type"),"status":"acked","response":body,
|
|
558
|
+
"acked_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
559
|
+
path=os.path.join(adir,cid+".json")
|
|
560
|
+
fd,tmp=tempfile.mkstemp(dir=adir,prefix=".ack.",suffix=".tmp")
|
|
561
|
+
try:
|
|
562
|
+
with os.fdopen(fd,"w") as fh:
|
|
563
|
+
json.dump(out,fh,indent=2,ensure_ascii=False); fh.flush(); os.fsync(fh.fileno())
|
|
564
|
+
os.replace(tmp,path)
|
|
565
|
+
except Exception:
|
|
566
|
+
try: os.unlink(tmp)
|
|
567
|
+
except OSError: pass
|
|
568
|
+
PY
|
|
569
|
+
return 0
|
|
570
|
+
}
|
|
571
|
+
|
|
316
572
|
# runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
|
|
317
573
|
# (más viejos primero); jamás descarta un tipo de RUNTIME_OUTBOX_PROTECTED. Si hubo
|
|
318
574
|
# descartes, encola un telemetry_gap con el payload EXACTO del catálogo v2 [#44]
|
|
@@ -421,10 +677,245 @@ PY
|
|
|
421
677
|
|
|
422
678
|
RUNTIME_OUTBOX_BACKOFF_SCHEDULE="1 5 30 300"
|
|
423
679
|
|
|
424
|
-
#
|
|
680
|
+
# _runtime_partition_outbox <fichero…> — imprime "direct <ruta>", "batch <ruta>" o
|
|
681
|
+
# "bad <ruta>" por línea. Una sola invocación de python3 para todos los ficheros: el despacho
|
|
682
|
+
# corre en el hilo del daemon cada pocos segundos y no puede pagar un proceso por fichero.
|
|
683
|
+
# [#62 · ronda final] La tercera clase existe porque un fichero ILEGIBLE (JSON corrupto, o un
|
|
684
|
+
# JSON que no es un objeto) no se puede afirmar que sea del lote: clasificarlo como `batch`
|
|
685
|
+
# lo condenaba a saltarse en silencio al armar el cuerpo y a quedarse atascado PARA SIEMPRE
|
|
686
|
+
# —una pérdida muda sobre un tipo que además está protegido de la evicción por cota—. El
|
|
687
|
+
# llamador lo aparta a rejected/ nombrando la causa.
|
|
688
|
+
_runtime_partition_outbox() {
|
|
689
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
690
|
+
python3 - "$@" <<'PY' 2>/dev/null
|
|
691
|
+
import json,sys
|
|
692
|
+
for p in sys.argv[1:]:
|
|
693
|
+
try:
|
|
694
|
+
d=json.load(open(p))
|
|
695
|
+
except Exception:
|
|
696
|
+
d=None
|
|
697
|
+
if not isinstance(d,dict):
|
|
698
|
+
print("bad "+p)
|
|
699
|
+
continue
|
|
700
|
+
print(("direct " if d.get("channel")=="direct" else "batch ")+p)
|
|
701
|
+
PY
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
# _runtime_bump_field <fichero> <clave> <valor-entero> — persiste un contador dentro de la
|
|
705
|
+
# petición encolada (escritura atómica, con `fsync` — mismo patrón que el resto del fichero).
|
|
706
|
+
# Sin esto, el conteo de 409 viviría en memoria y se perdería entre ciclos del daemon: el
|
|
707
|
+
# reintento sería infinito por construcción. [Ronda de arreglo 1, Important 2] El fallo YA NO
|
|
708
|
+
# se traga: se propaga (rc distinguible) para que el llamador aparte el fichero en vez de
|
|
709
|
+
# reintentar para siempre sin poder nunca completar el conteo.
|
|
710
|
+
_runtime_bump_field() {
|
|
711
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
712
|
+
python3 - "$1" "$2" "$3" <<'PY' 2>/dev/null
|
|
713
|
+
import json,sys,os,tempfile
|
|
714
|
+
path,key,val=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
715
|
+
try:
|
|
716
|
+
d=json.load(open(path))
|
|
717
|
+
except Exception:
|
|
718
|
+
raise SystemExit(1) # fichero ilegible: rc distinguible, el llamador decide (no reintento ciego)
|
|
719
|
+
d[key]=int(val)
|
|
720
|
+
dirn=os.path.dirname(path) or "."
|
|
721
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".outbox.",suffix=".tmp")
|
|
722
|
+
try:
|
|
723
|
+
with os.fdopen(fd,"w") as out:
|
|
724
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
725
|
+
os.replace(tmp,path)
|
|
726
|
+
except Exception:
|
|
727
|
+
try: os.unlink(tmp)
|
|
728
|
+
except OSError: pass
|
|
729
|
+
raise
|
|
730
|
+
PY
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
# _runtime_mark_graph_409 <fichero> <n> <version-local> — persiste el conteo de rechazos por
|
|
734
|
+
# grafo rancio Y la versión local con la que se produjeron. Los dos campos van juntos porque la
|
|
735
|
+
# racha es «tres rechazos contra la MISMA versión local», no «tres rechazos a secas» (ver
|
|
736
|
+
# _runtime_dispatch_direct). Escritura atómica con fsync y fallo propagado (rc distinguible),
|
|
737
|
+
# igual que _runtime_bump_field: si el conteo no se puede persistir, el llamador aparta el
|
|
738
|
+
# fichero en vez de reintentar sin fin.
|
|
739
|
+
_runtime_mark_graph_409() {
|
|
740
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
741
|
+
python3 - "$1" "$2" "$3" <<'PY' 2>/dev/null
|
|
742
|
+
import json,sys,os,tempfile
|
|
743
|
+
path,n,gv=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
744
|
+
try:
|
|
745
|
+
d=json.load(open(path))
|
|
746
|
+
except Exception:
|
|
747
|
+
raise SystemExit(1) # fichero ilegible: rc distinguible, el llamador decide
|
|
748
|
+
d["graph_409"]=int(n)
|
|
749
|
+
# Ausencia de versión = None, nunca 0: «salió sin estampar» y «salió con la versión 0» son
|
|
750
|
+
# cosas distintas, y confundirlas haría que la racha se reiniciara sola sin motivo.
|
|
751
|
+
d["graph_409_at_version"]=gv if gv else None
|
|
752
|
+
dirn=os.path.dirname(path) or "."
|
|
753
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".outbox.",suffix=".tmp")
|
|
754
|
+
try:
|
|
755
|
+
with os.fdopen(fd,"w") as out:
|
|
756
|
+
json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
757
|
+
os.replace(tmp,path)
|
|
758
|
+
except Exception:
|
|
759
|
+
try: os.unlink(tmp)
|
|
760
|
+
except OSError: pass
|
|
761
|
+
raise
|
|
762
|
+
PY
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
# _runtime_dispatch_direct <fichero…> — despacha el carril directo UNO a uno (cada petición
|
|
766
|
+
# tiene su endpoint; no hay lote que armar). Contrato de resultado por fichero:
|
|
767
|
+
# 2xx → ack en acks/, se borra el fichero y se limpia el desfase de grafo
|
|
768
|
+
# si había uno anotado (la petición llegó, luego ya no vamos rancios).
|
|
769
|
+
# 404 → el hub NO implementa esa ruta (instancia sin soporte, hub#113
|
|
770
|
+
# pendiente): se aparta diciéndolo con esas palabras.
|
|
771
|
+
# 409 reason=stale_graph → grafo rancio [#63]: se anota el desfase y se CONSERVA (no es un
|
|
772
|
+
# rechazo de contrato); a los 3 seguidos se aparta. Cualquier OTRO
|
|
773
|
+
# 409 (propuesta duplicada, conflicto de estado…) sigue el camino
|
|
774
|
+
# normal de rechazo de contrato — el `reason` es lo único que decide.
|
|
775
|
+
# 4xx (salvo 404/408/409-stale_graph/429) → rechazo de contrato: se aparta a rejected/.
|
|
776
|
+
# 000 · 5xx · 408 · 429 → transitorio: se CONSERVA y se reintenta en el próximo ciclo.
|
|
777
|
+
# rc 1 si algo quedó pendiente por causa transitoria, 0 si no queda nada del carril.
|
|
778
|
+
# No agenda backoff propio a propósito: el carril directo es de volumen bajísimo (una
|
|
779
|
+
# propuesta de épica) y compartir el backoff del lote dejaría los hechos de dominio del slice
|
|
780
|
+
# esperando detrás de una propuesta. El ritmo real de reintento lo marca el ciclo del daemon.
|
|
781
|
+
_runtime_dispatch_direct() {
|
|
782
|
+
local f rc=0 resp status type path method payload gv server_gv n409 prev_gv stamped reason
|
|
783
|
+
for f in "$@"; do
|
|
784
|
+
[ -f "$f" ] || continue
|
|
785
|
+
# [Ronda final] `gv` se reinicia POR ITERACIÓN: es `local` a la función y solo se asigna
|
|
786
|
+
# dentro del `if stamp_graph_version`, así que un fichero sin estampar que recibiera un 409
|
|
787
|
+
# heredaba la versión del fichero anterior y la usaba en el mensaje, en el marcador de
|
|
788
|
+
# desfase y en la racha — un dato inventado con apariencia de medida.
|
|
789
|
+
gv=""
|
|
790
|
+
type="$(_runtime_direct_field "$f" type)"
|
|
791
|
+
method="$(_runtime_direct_field "$f" method)"
|
|
792
|
+
path="$(_runtime_direct_field "$f" path)"
|
|
793
|
+
[ -n "$method" ] || method=POST
|
|
794
|
+
if [ -z "$path" ]; then
|
|
795
|
+
_runtime_reject_file "$f" "petición directa sin ruta de despacho (fichero corrupto): no se envió"
|
|
796
|
+
continue
|
|
797
|
+
fi
|
|
798
|
+
payload="$(_runtime_direct_payload "$f")"
|
|
799
|
+
# [#63] Estampado AL DESPACHAR, con la versión que la proyección tenga en ESTE instante.
|
|
800
|
+
if [ "$(_runtime_direct_field "$f" stamp_graph_version)" = "True" ]; then
|
|
801
|
+
gv="$(_runtime_graph_version)"
|
|
802
|
+
if [ -n "$gv" ]; then
|
|
803
|
+
# [Ronda de arreglo 1, Important 1] Si `$gv` no es un entero válido (hub#115 aún no
|
|
804
|
+
# existe: nada coacciona el tipo en `agent-context.sh`), `int()` lanza y python no
|
|
805
|
+
# imprime nada — NUNCA se sobrescribe `payload` con esa cadena vacía, o la petición
|
|
806
|
+
# saldría sin cuerpo y el 4xx del hub se reportaría como "rechazo del hub" en vez de
|
|
807
|
+
# como el fallo de estampado que de verdad es.
|
|
808
|
+
stamped="$(printf '%s' "$payload" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["graph_version"]=int(sys.argv[1]); print(json.dumps(d,ensure_ascii=False))' "$gv" 2>/dev/null)"
|
|
809
|
+
[ -n "$stamped" ] && payload="$stamped"
|
|
810
|
+
fi
|
|
811
|
+
fi
|
|
812
|
+
resp="$(_runtime_http "$method" "$path" "$payload")"
|
|
813
|
+
status="$(runtime_http_status)"
|
|
814
|
+
case "$status" in
|
|
815
|
+
200|201|202)
|
|
816
|
+
_runtime_ack_direct "$f" "$resp"
|
|
817
|
+
rm -f "$f"
|
|
818
|
+
runtime_graph_clear_stale ;;
|
|
819
|
+
404)
|
|
820
|
+
# [bash 3.2] un carácter multibyte pegado a `$var` sin llaves («$type») rompe el
|
|
821
|
+
# escaneo del nombre bajo `set -u` en esta bash — `${type}` lo aísla.
|
|
822
|
+
_runtime_reject_file "$f" "el hub no expone $path (HTTP 404): esta instancia está sin soporte para «${type}» — actualiza el hub y vuelve a proponer" ;;
|
|
823
|
+
409)
|
|
824
|
+
# [Ronda de arreglo 1, Important 4] El `reason` decide, igual que en `_claim_once`:
|
|
825
|
+
# un 409 por OTRA causa (propuesta duplicada, conflicto de estado…) no es un grafo
|
|
826
|
+
# rancio y tratarlo como tal apartaría la propuesta con un diagnóstico falso.
|
|
827
|
+
reason="$(printf '%s' "$resp" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("reason") or "")' 2>/dev/null)"
|
|
828
|
+
if [ "$reason" = "stale_graph" ]; then
|
|
829
|
+
# No es un error de contrato: el grafo del hub cambió. Se anota el desfase (lo
|
|
830
|
+
# muestra `status`) y la petición se CONSERVA — el refresco del heartbeat (#61)
|
|
831
|
+
# traerá la versión nueva y el próximo ciclo la re-estampa. Con 3 rechazos seguidos
|
|
832
|
+
# se deja de insistir: a esas alturas no es una carrera, y un bucle silencioso es
|
|
833
|
+
# peor que un rechazo visible.
|
|
834
|
+
server_gv="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("graph_version") or "")' 2>/dev/null)"
|
|
835
|
+
runtime_graph_note_stale "$gv" "$server_gv"
|
|
836
|
+
n409="$(_runtime_direct_field "$f" graph_409)"
|
|
837
|
+
case "$n409" in ''|*[!0-9]*) n409=0 ;; esac
|
|
838
|
+
# [Ronda final] La racha cuenta rechazos contra la MISMA versión local estampada. Las
|
|
839
|
+
# dos cadencias son independientes y nada las acopla: el despacho corre en cada tick
|
|
840
|
+
# que encuentre `outbox/.flush-request` —y `session-stop.sh` lo deja en CADA turno—,
|
|
841
|
+
# mientras el refresco de contexto va a `runtime.context_refresh_s` (45 s por defecto,
|
|
842
|
+
# y `0` lo DESACTIVA, valor soportado). Contando rechazos a secas, tres turnos en
|
|
843
|
+
# menos de 45 s gastaban las tres vidas de la propuesta contra la misma foto del
|
|
844
|
+
# grafo y la apartaban pidiendo un refresco que el cliente nunca se permitió hacer.
|
|
845
|
+
# Si la versión local cambió, hubo refresco de verdad: la racha empieza de cero.
|
|
846
|
+
prev_gv="$(_runtime_direct_field "$f" graph_409_at_version)"
|
|
847
|
+
[ "$prev_gv" = "$gv" ] || n409=0
|
|
848
|
+
n409=$((n409 + 1))
|
|
849
|
+
if [ "$n409" -ge 3 ]; then
|
|
850
|
+
_runtime_reject_file "$f" "el hub rechazó «${type}» tres veces por el MISMO grafo obsoleto (local v${gv:-?}, hub v${server_gv:-?}): refresca el proyecto y vuelve a proponer"
|
|
851
|
+
elif _runtime_mark_graph_409 "$f" "$n409" "$gv"; then
|
|
852
|
+
rc=1
|
|
853
|
+
else
|
|
854
|
+
# [Ronda de arreglo 1, Important 2] El conteo no se pudo persistir: reintentar a
|
|
855
|
+
# ciegas sería un bucle infinito (el contador jamás llegaría a 3). Se aparta.
|
|
856
|
+
_runtime_reject_file "$f" "no se pudo persistir el conteo de rechazos por grafo obsoleto de «${type}»: se aparta para evitar un reintento sin fin"
|
|
857
|
+
fi
|
|
858
|
+
else
|
|
859
|
+
_runtime_reject_file "$f" "el hub rechazó «${type}» (HTTP $status): $resp — no se reintentará"
|
|
860
|
+
fi ;;
|
|
861
|
+
408|429|5*|000)
|
|
862
|
+
# [Ronda de arreglo 1, Minor 6] «3 rechazos CONSECUTIVOS» debe ser literal: un
|
|
863
|
+
# transitorio intercalado (timeout, 5xx…) no es un stale_graph y no debe sumar a esa
|
|
864
|
+
# racha — se reinicia el contador si había uno. Best-effort: si la escritura falla,
|
|
865
|
+
# el `rc=1` de abajo igual reintenta el próximo ciclo (no hay riesgo de bucle, ya que
|
|
866
|
+
# solo la rama `409 stale_graph` puede volver a incrementarlo).
|
|
867
|
+
n409="$(_runtime_direct_field "$f" graph_409)"
|
|
868
|
+
case "$n409" in ''|*[!0-9]*) n409=0 ;; esac
|
|
869
|
+
[ "$n409" -gt 0 ] && _runtime_bump_field "$f" graph_409 0
|
|
870
|
+
rc=1 ;;
|
|
871
|
+
4*)
|
|
872
|
+
_runtime_reject_file "$f" "el hub rechazó «${type}» (HTTP $status): $resp — no se reintentará" ;;
|
|
873
|
+
*)
|
|
874
|
+
rc=1 ;;
|
|
875
|
+
esac
|
|
876
|
+
done
|
|
877
|
+
return $rc
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
RUNTIME_DISPATCH_LOCK_MAX_AGE_MIN=2
|
|
881
|
+
|
|
882
|
+
runtime_dispatch_lock_path() { echo "$(config_root)/.claude/state/.outbox-dispatch.lock"; }
|
|
883
|
+
|
|
884
|
+
# runtime_dispatch_outbox — envoltura con MUTEX de `_runtime_dispatch_outbox_once`.
|
|
885
|
+
#
|
|
886
|
+
# [#62 · ronda final] Hasta esta rama el único despachador era el daemon (proceso único) y no
|
|
887
|
+
# hacía falta serializar nada. `slice-ops.sh propose-epic` despacha ahora en PRIMER PLANO, así
|
|
888
|
+
# que el proceso de la skill y el daemon pueden recorrer la MISMA cola a la vez. Para el lote
|
|
889
|
+
# no sería grave (su seguridad viene de la idempotencia server-side por `client_event_id`),
|
|
890
|
+
# pero `runtime_http_status` es un FICHERO compartido: entre el `_runtime_http` de un proceso y
|
|
891
|
+
# su lectura del status puede colarse el curl del otro, y un `409` leído como `2xx` borraría la
|
|
892
|
+
# petición dándola por entregada sin que el hub la haya visto jamás.
|
|
893
|
+
#
|
|
894
|
+
# `mkdir` es atómico (mismo patrón que `hb_spawnlock` en heartbeat.sh); un lock huérfano
|
|
895
|
+
# —proceso muerto entre el mkdir y el rmdir— se libera por edad. El umbral (2 min) queda muy
|
|
896
|
+
# por encima de un ciclo real: el lote es una petición y el carril directo es de volumen
|
|
897
|
+
# bajísimo, ambos con `curl -m 10`.
|
|
898
|
+
runtime_dispatch_outbox() {
|
|
899
|
+
local lock rc
|
|
900
|
+
[ "$(runtime_mode)" = "legacy" ] && return 0
|
|
901
|
+
lock="$(runtime_dispatch_lock_path)"
|
|
902
|
+
mkdir -p "$(dirname "$lock")"
|
|
903
|
+
if [ -d "$lock" ] && [ -n "$(find "$lock" -maxdepth 0 -mmin "+$RUNTIME_DISPATCH_LOCK_MAX_AGE_MIN" 2>/dev/null)" ]; then
|
|
904
|
+
rmdir "$lock" 2>/dev/null
|
|
905
|
+
fi
|
|
906
|
+
# Otro proceso está despachando ESTA cola: no se toca nada y se reporta que quedó trabajo
|
|
907
|
+
# (rc 1). Devolver 0 sería afirmar que la cola quedó drenada sin haberlo comprobado.
|
|
908
|
+
mkdir "$lock" 2>/dev/null || return 1
|
|
909
|
+
_runtime_dispatch_outbox_once
|
|
910
|
+
rc=$?
|
|
911
|
+
rmdir "$lock" 2>/dev/null
|
|
912
|
+
return $rc
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
# _runtime_dispatch_outbox_once — despacho best-effort de la cola. No-op en modo legacy.
|
|
425
916
|
# Respeta un backoff persistido (nunca bloquea reintentando en el hilo del hook). FIFO
|
|
426
917
|
# global (el agrupado por-agregado nace con el catálogo de eventos del sub-slice C).
|
|
427
|
-
|
|
918
|
+
_runtime_dispatch_outbox_once() {
|
|
428
919
|
local mode; mode="$(runtime_mode)"
|
|
429
920
|
[ "$mode" = "legacy" ] && return 0
|
|
430
921
|
|
|
@@ -442,9 +933,42 @@ runtime_dispatch_outbox() {
|
|
|
442
933
|
fi
|
|
443
934
|
[ "$now" -lt "$next_ok" ] && return 0
|
|
444
935
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
936
|
+
# [#62] Dos carriles, un solo punto de despacho: los ficheros con `channel: "direct"` van a
|
|
937
|
+
# su propio endpoint; el resto arma el lote de POST /events como siempre.
|
|
938
|
+
local all=() files=() direct=() bad=() f line direct_rc=0 part prc
|
|
939
|
+
while IFS= read -r f; do all+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
|
|
940
|
+
[ ${#all[@]} -eq 0 ] && return 0
|
|
941
|
+
# [Ronda final] El rc de la partición se COMPRUEBA. Si falla (E2BIG con una cola muy grande,
|
|
942
|
+
# python3 caído a mitad), `direct` y `files` quedaban vacíos, se borraba el backoff agendado
|
|
943
|
+
# y la función devolvía 0 con la cola llena: un fallo reportado como drenaje limpio. Ahora se
|
|
944
|
+
# dice, no se toca el estado de backoff y se reintenta en el próximo ciclo.
|
|
945
|
+
part="$(_runtime_partition_outbox "${all[@]}")"
|
|
946
|
+
prc=$?
|
|
947
|
+
if [ "$prc" -ne 0 ]; then
|
|
948
|
+
echo "⚠ no se pudo clasificar la cola de la outbox (${#all[@]} fichero(s)): no se despacha en este ciclo, se reintentará" >&2
|
|
949
|
+
return 1
|
|
950
|
+
fi
|
|
951
|
+
while IFS= read -r line; do
|
|
952
|
+
case "$line" in
|
|
953
|
+
"direct "*) direct+=("${line#direct }") ;;
|
|
954
|
+
"batch "*) files+=("${line#batch }") ;;
|
|
955
|
+
"bad "*) bad+=("${line#bad }") ;;
|
|
956
|
+
esac
|
|
957
|
+
done <<EOF
|
|
958
|
+
$part
|
|
959
|
+
EOF
|
|
960
|
+
if [ ${#bad[@]} -gt 0 ]; then
|
|
961
|
+
for f in "${bad[@]}"; do
|
|
962
|
+
_runtime_reject_file "$f" "fichero de la outbox ilegible (no es un objeto JSON): no se puede saber por qué carril iba ni qué contiene — se aparta en vez de dejarlo atascado en la cola para siempre"
|
|
963
|
+
done
|
|
964
|
+
fi
|
|
965
|
+
if [ ${#direct[@]} -gt 0 ]; then
|
|
966
|
+
_runtime_dispatch_direct "${direct[@]}" || direct_rc=1
|
|
967
|
+
fi
|
|
968
|
+
if [ ${#files[@]} -eq 0 ]; then
|
|
969
|
+
[ "$direct_rc" -eq 0 ] && rm -f "$state_file"
|
|
970
|
+
return "$direct_rc"
|
|
971
|
+
fi
|
|
448
972
|
|
|
449
973
|
# [#37] El hub (`EventBatchIn`) exige el sobre {"events": [...]} — el array pelado era
|
|
450
974
|
# 422 en cada intento y la cola no drenaba jamás. Cada elemento va con `event_type`
|
|
@@ -544,9 +1068,13 @@ PY
|
|
|
544
1068
|
# Si todo lo pendiente eran tipos sin equivalente (ya apartados), no hay nada que enviar.
|
|
545
1069
|
local n_items
|
|
546
1070
|
n_items="$(printf '%s' "$batch" | python3 -c 'import json,sys;print(len(json.load(sys.stdin).get("events") or []))' 2>/dev/null || echo 0)"
|
|
1071
|
+
# [#62] El lote no tenía nada que enviar, pero el carril directo puede seguir con algo
|
|
1072
|
+
# pendiente por causa transitoria: el rc de la función tiene que reflejarlo (no solo el
|
|
1073
|
+
# rc del lote), porque quien lo consume (p. ej. slice-ops.sh) decide en base a ÉL si
|
|
1074
|
+
# queda trabajo por drenar.
|
|
547
1075
|
if [ "$n_items" = "0" ]; then
|
|
548
1076
|
rm -f "$state_file"
|
|
549
|
-
return
|
|
1077
|
+
return "$direct_rc"
|
|
550
1078
|
fi
|
|
551
1079
|
|
|
552
1080
|
local resp status
|
|
@@ -603,8 +1131,11 @@ for p in paths:
|
|
|
603
1131
|
except OSError:
|
|
604
1132
|
pass
|
|
605
1133
|
PY
|
|
1134
|
+
# [#62] Igual que arriba: el lote se resolvió (200), pero el rc global tiene que
|
|
1135
|
+
# reflejar también el carril directo si algo suyo quedó pendiente por causa
|
|
1136
|
+
# transitoria en este mismo ciclo.
|
|
606
1137
|
rm -f "$state_file"
|
|
607
|
-
return
|
|
1138
|
+
return "$direct_rc"
|
|
608
1139
|
fi
|
|
609
1140
|
|
|
610
1141
|
# [#37] Un 4xx del LOTE (salvo 408/429) es un rechazo del contrato, no red caída:
|
|
@@ -614,7 +1145,7 @@ PY
|
|
|
614
1145
|
case "$status" in
|
|
615
1146
|
408|429) : ;;
|
|
616
1147
|
4*)
|
|
617
|
-
local rejected_dir
|
|
1148
|
+
local rejected_dir
|
|
618
1149
|
rejected_dir="$dir/rejected"
|
|
619
1150
|
mkdir -p "$rejected_dir"
|
|
620
1151
|
echo "⛔ el hub rechazó el lote de eventos (HTTP $status): $resp" >&2
|