@trycore/spec-build-harness 0.10.0 → 0.11.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/VERSION +1 -1
- package/commands/build/escalate.md +1 -1
- package/commands/build/onboard.md +20 -4
- package/dist/cli.js +11 -1
- package/dist/commands/migrate.js +107 -2
- package/dist/lib/normalize.js +925 -78
- package/dist/lib/state-bundle.js +112 -8
- package/docs/runtime/guia-modo-dual-y-migracion.md +9 -2
- package/docs/runtime/protocolo-cliente-runtime.md +29 -19
- package/hooks/build/event-emitter.sh +9 -52
- package/hooks/build/lib/runtime-client.sh +183 -23
- package/hooks/build/lib/runtime-ops.sh +14 -8
- package/hooks/build/lib/state-io.sh +0 -0
- package/hooks/build/release-ops.sh +16 -9
- package/hooks/build/slice-ops.sh +152 -81
- package/package.json +1 -1
- package/scripts/check-runtime-purity.sh +0 -0
- package/scripts/denylist.txt +4 -0
- package/scripts/lib/graph-bundle.py +68 -20
- package/scripts/tests/test-baseline-verdict.sh +0 -0
- package/scripts/tests/test-hooks-runtime.sh +13 -38
- package/scripts/tests/test-install.sh +46 -0
- package/scripts/tests/test-skill-ops.sh +578 -58
- package/skills/building-a-slice/references/runtime-protocol.md +1 -1
- package/skills/releasing-a-version/SKILL.md +2 -1
package/dist/lib/state-bundle.js
CHANGED
|
@@ -5,10 +5,74 @@
|
|
|
5
5
|
// import histórico (docs/superpowers/specs/2026-08-20-cliente-migrate-normalizacion-eventos-design.md):
|
|
6
6
|
// convierte el build-state.json legacy del piloto en la forma ImportBundleIn
|
|
7
7
|
// que ya entiende el import del hub — eventos del catálogo v2, no snapshots crudos.
|
|
8
|
-
import { normalizeHistory, normalizeReleases, normalizeFront, normalizeFacts, validateNormalizedBundle, } from './normalize.js';
|
|
8
|
+
import { normalizeHistory, normalizeReleases, normalizeFront, normalizeFacts, normalizePreHarnessEpic, validateNormalizedBundle, verifyNormalizedBundle, } from './normalize.js';
|
|
9
9
|
export const STATE_BUNDLE_VERSION = 2;
|
|
10
10
|
const EPOCH_ISO = new Date(0).toISOString();
|
|
11
|
-
|
|
11
|
+
// Contrato del hub (graph_service.VALID_LAYERS): un layer fuera de este set
|
|
12
|
+
// rechaza el grafo ENTERO (GraphIntegrityError) — mejor pararlo en local.
|
|
13
|
+
const VALID_LAYERS = new Set(['FOUNDATIONAL', 'BUSINESS']);
|
|
14
|
+
/**
|
|
15
|
+
* Extrae la sección `graph` (formato GraphImportIn del hub) desde el bundle de
|
|
16
|
+
* `scripts/lib/graph-bundle.py` o desde un `{epics: [...]}` crudo, validando lo
|
|
17
|
+
* que el hub validará: `layer` ∈ FOUNDATIONAL|BUSINESS, `code` y `title` por
|
|
18
|
+
* épica, `code` y `title` por historia (`story_in["code"]`). Errores ⇒ graph
|
|
19
|
+
* null: embeber un grafo que el hub rechazará entero no ayuda a nadie.
|
|
20
|
+
*/
|
|
21
|
+
export function extractGraphSection(raw) {
|
|
22
|
+
const errors = [];
|
|
23
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
24
|
+
return { graph: null, errors: ['el bundle de grafo no es un objeto JSON'] };
|
|
25
|
+
}
|
|
26
|
+
const epicsRaw = raw.epics;
|
|
27
|
+
if (!Array.isArray(epicsRaw)) {
|
|
28
|
+
return { graph: null, errors: ['el bundle de grafo no tiene `epics` (¿lo generó scripts/lib/graph-bundle.py?)'] };
|
|
29
|
+
}
|
|
30
|
+
const epics = [];
|
|
31
|
+
epicsRaw.forEach((e, i) => {
|
|
32
|
+
if (typeof e !== 'object' || e === null || Array.isArray(e)) {
|
|
33
|
+
errors.push(`epics[${i}] no es un objeto`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const epic = e;
|
|
37
|
+
const code = typeof epic.code === 'string' && epic.code ? epic.code : null;
|
|
38
|
+
if (!code)
|
|
39
|
+
errors.push(`epics[${i}] sin \`code\``);
|
|
40
|
+
const ref = code ?? `epics[${i}]`;
|
|
41
|
+
if (typeof epic.title !== 'string' || !epic.title) {
|
|
42
|
+
errors.push(`${ref} sin \`title\` (EpicIn del hub lo exige)`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof epic.layer !== 'string' || !VALID_LAYERS.has(epic.layer)) {
|
|
45
|
+
errors.push(`${ref} con layer ${JSON.stringify(epic.layer ?? null)} fuera de FOUNDATIONAL|BUSINESS: el hub rechazaría el grafo ENTERO`);
|
|
46
|
+
}
|
|
47
|
+
for (const [j, s] of (Array.isArray(epic.stories) ? epic.stories : []).entries()) {
|
|
48
|
+
const story = typeof s === 'object' && s !== null ? s : {};
|
|
49
|
+
if (typeof story.code !== 'string' || !story.code) {
|
|
50
|
+
errors.push(`${ref}: historia [${j}] sin \`code\` (el hub hace story_in["code"] — ¿bundle en formato viejo con \`id\`?)`);
|
|
51
|
+
}
|
|
52
|
+
else if (typeof story.title !== 'string' || !story.title) {
|
|
53
|
+
errors.push(`${ref}: historia ${story.code} sin \`title\``);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
epics.push(epic);
|
|
57
|
+
});
|
|
58
|
+
if (errors.length > 0)
|
|
59
|
+
return { graph: null, errors };
|
|
60
|
+
return { graph: { epics }, errors };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Épicas del grafo embebido SIN representación en el bundle: ni slice en
|
|
64
|
+
* history[] ni declaración pre-arnés. Es el síntoma exacto del incidente de
|
|
65
|
+
* el primer piloto real: quedan «sin empezar» en el hub y
|
|
66
|
+
* `claim_next` las reparte como trabajo nuevo. Solo detecta — la lista
|
|
67
|
+
* pre-arnés la confirma el humano, nunca se auto-añade nada.
|
|
68
|
+
*/
|
|
69
|
+
export function graphEpicsWithoutRepresentation(graph, representedCodes) {
|
|
70
|
+
const represented = new Set(representedCodes);
|
|
71
|
+
return graph.epics
|
|
72
|
+
.map((e) => (typeof e.code === 'string' ? e.code : ''))
|
|
73
|
+
.filter((code) => code !== '' && !represented.has(code));
|
|
74
|
+
}
|
|
75
|
+
export function buildStateBundle(raw, projectRef, preHarness) {
|
|
12
76
|
const warnings = [];
|
|
13
77
|
const st = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {};
|
|
14
78
|
if (raw !== null && (typeof raw !== 'object' || Array.isArray(raw))) {
|
|
@@ -22,13 +86,40 @@ export function buildStateBundle(raw, projectRef) {
|
|
|
22
86
|
if (st.releases !== undefined && !Array.isArray(st.releases)) {
|
|
23
87
|
warnings.push('releases no es un array: se descarta (0 releases migradas)');
|
|
24
88
|
}
|
|
25
|
-
const { entries: history, unmapped: historyUnmapped } = normalizeHistory(historyRaw);
|
|
26
|
-
const { entries: releases, unmapped: releasesUnmapped } = normalizeReleases(releasesRaw);
|
|
27
|
-
|
|
89
|
+
const { entries: history, unmapped: historyUnmapped, warnings: historyWarnings } = normalizeHistory(historyRaw);
|
|
90
|
+
const { entries: releases, unmapped: releasesUnmapped, warnings: releaseWarnings } = normalizeReleases(releasesRaw);
|
|
91
|
+
warnings.push(...historyWarnings, ...releaseWarnings);
|
|
92
|
+
const frontUnmapped = [];
|
|
93
|
+
const front = normalizeFront(st.parallel_front ?? null, warnings, frontUnmapped);
|
|
28
94
|
const fronts = front ? [front] : [];
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
95
|
+
const factsUnmapped = [];
|
|
96
|
+
const facts = normalizeFacts(st, EPOCH_ISO, warnings, factsUnmapped);
|
|
97
|
+
const unmapped = [...historyUnmapped, ...releasesUnmapped, ...frontUnmapped, ...factsUnmapped];
|
|
98
|
+
// Épicas pre-arnés (issue #42): lista CONFIRMADA por el humano — entrada
|
|
99
|
+
// sintética archivada sin gates por épica (patrón del workaround del piloto).
|
|
100
|
+
// Conflicto obvio ⇒ error, no fusión: una épica declarada pre-arnés que YA
|
|
101
|
+
// tiene slice (en history[] o como active_slice) es una contradicción que
|
|
102
|
+
// debe resolver el humano, no el normalizador.
|
|
103
|
+
const conflictErrors = [];
|
|
104
|
+
if (preHarness) {
|
|
105
|
+
const historyCodes = new Set(history.map((h) => h.epic_code));
|
|
106
|
+
const active = st.active_slice;
|
|
107
|
+
const activeCode = typeof active === 'object' && active !== null && typeof active.epica === 'string'
|
|
108
|
+
? String(active.epica)
|
|
109
|
+
: null;
|
|
110
|
+
for (const epic of preHarness.epics) {
|
|
111
|
+
if (historyCodes.has(epic.code)) {
|
|
112
|
+
conflictErrors.push(`pre-arnés: la épica "${epic.code}" ya tiene slice en build-state.json (history[]) — una épica no puede declararse pre-arnés y tener historia de slice a la vez; quítala de la lista --pre-harness`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (activeCode === epic.code) {
|
|
116
|
+
conflictErrors.push(`pre-arnés: la épica "${epic.code}" es el active_slice de build-state.json — un slice en construcción no es pre-arnés; ciérralo o quítala de la lista --pre-harness`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
history.push(normalizePreHarnessEpic(epic, preHarness.asOf));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const validationErrors = [...conflictErrors, ...validateNormalizedBundle({ history, releases, fronts })];
|
|
32
123
|
return {
|
|
33
124
|
bundle_version: STATE_BUNDLE_VERSION,
|
|
34
125
|
kind: 'state',
|
|
@@ -44,3 +135,16 @@ export function buildStateBundle(raw, projectRef) {
|
|
|
44
135
|
validation_errors: validationErrors,
|
|
45
136
|
};
|
|
46
137
|
}
|
|
138
|
+
/** Verificación offline del bundle contra los invariantes del hub (migrate
|
|
139
|
+
* --verify): delega en `verifyNormalizedBundle`, que replica en seco lo que
|
|
140
|
+
* `validar_secuencia` del hub aplicaría en el import (import_domain.py:126-172).
|
|
141
|
+
* Vacío ⇒ el bundle puede entregarse al ADMIN sin motivo de rechazo conocido. */
|
|
142
|
+
export function verifyStateBundle(bundle) {
|
|
143
|
+
return verifyNormalizedBundle({
|
|
144
|
+
history: bundle.history,
|
|
145
|
+
releases: bundle.releases,
|
|
146
|
+
fronts: bundle.fronts,
|
|
147
|
+
facts: bundle.facts,
|
|
148
|
+
unmapped: bundle.unmapped,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -98,10 +98,17 @@ los conozca (proyecciones consultables, no solo lo que ocurra de aquí en adelan
|
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
100
|
trycore-build migrate --project-ref "<nombre-del-proyecto-en-el-hub>"
|
|
101
|
-
# escribe .claude/state/migration-bundle.json
|
|
101
|
+
# escribe .claude/state/migration-bundle.json (UN solo fichero, con la sección graph embebida
|
|
102
|
+
# si existe .claude/state/graph-bundle.json o se pasa --graph <fichero> — issue #41)
|
|
103
|
+
|
|
104
|
+
trycore-build migrate --verify # solo verifica offline contra los invariantes del hub; no escribe nada
|
|
102
105
|
```
|
|
103
106
|
|
|
104
|
-
Esto **normaliza y valida en local** — nunca sube nada.
|
|
107
|
+
Esto **normaliza, verifica contra los invariantes del hub y valida en local** — nunca sube nada.
|
|
108
|
+
Si el bundle viola algún invariante (evidencia obligatoria en veredictos passing, límites de
|
|
109
|
+
longitud, catálogo de gates/eventos, orden de fases…), `migrate` **aborta sin escribir el
|
|
110
|
+
fichero**: un bundle que el hub rechazaría no se entrega al ADMIN (lección del primer piloto real,
|
|
111
|
+
issue #40 — el primer import rechazó 36/41 entradas por defectos detectables offline). Entrega el fichero resultante a un ADMIN
|
|
105
112
|
con la instrucción: *«súbelo en la consola del hub, pantalla de import histórico del proyecto»*. El
|
|
106
113
|
import es idempotente y reanuda si falló a medias; las entradas no mapeables se importan igual como
|
|
107
114
|
`legacy_imported` con el payload original — nada se pierde, nada bloquea.
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|---|---|---|---|
|
|
17
17
|
| SessionStart (`startup\|clear\|compact`) | `session-start.sh` | `POST /agents/register` (idempotente, con `asset_types`) + `GET /agent/context` + `context-sync.sh` (§4) + aseguramiento del daemon | Escribe la **caché de proyección normalizada** (`.claude/state/runtime-projection.json`). No inyecta contexto: el render lo hace `load-build-state.sh`, que corre después en el mismo evento. |
|
|
18
18
|
| SessionStart (mismo matcher) | `load-build-state.sh` | **ninguna** | Renderiza `additionalContext` desde la caché en modo `runtime`; en `legacy\|dual` sigue leyendo `build-state.json`. |
|
|
19
|
-
| PostToolUse (`Bash\|Edit\|Write\|MultiEdit\|Task`) | `event-emitter.sh` | **ninguna
|
|
19
|
+
| PostToolUse (`Bash\|Edit\|Write\|MultiEdit\|Task`) | `event-emitter.sh` | **ninguna** | Solo hace la comprobación barata del daemon (stat del pidfile). Desde el issue #44 **no encola nada**: `tool_use_recorded` no existe en el catálogo v2 del hub y era ruido garantizado en `rejected/`; la telemetría de uso queda suspendida hasta que el equipo del hub decida añadir el tipo. |
|
|
20
20
|
| — (daemon, no hook) | `heartbeat.sh --daemon` | `PUT /leases/renew` + `POST /events` | Singleton por repo con registro de ppids de sesión; late cada `lease_ttl_s/3` (valor del servidor) y despacha la cola mientras viva. Ver §9. |
|
|
21
21
|
| — (invocado, no registrado) | `context-sync.sh` | `GET …/context/manifest`, `GET …/context/files?path=…&sha256=…`, `POST /context/synced` | Lo llama `session-start.sh` y, desde el sub-slice C, las skills antes de reclamar. |
|
|
22
22
|
| PreCompact / PostToolUse / Stop | `context-monitor.sh` | encola `handoff_recorded {note, resume_hint, stopped_at}` | En `runtime` el handoff **solo** es evento (no se escribe el fichero); en `dual` se escribe el fichero **y** se emite; en `legacy`, solo el fichero. `handoff_recorded` es de ámbito **slice**: solo se encola con `slice_id` conocido (misma guardia que `event-emitter.sh`); en `dual`, como no hay claim vía runtime, `active_slice` normalmente no llega por `/agent/context` y el evento no se emite (solo queda el fichero). |
|
|
@@ -29,7 +29,8 @@ Eventos emitidos por skills (no por hooks) — sub-slice C: `POST /checkpoints`,
|
|
|
29
29
|
## 3. Claim (el corazón del pull)
|
|
30
30
|
|
|
31
31
|
```
|
|
32
|
-
POST /tasks/next {context_hashes:
|
|
32
|
+
POST /tasks/next {context_hashes: {"<path>": "<sha256>", …}} // dict[str,str]; {} = reporta vacío;
|
|
33
|
+
// valor "" = fichero ilegible (nunca null)
|
|
33
34
|
200 → {slice_id, epic_code, epic_title, stories[], files_scope, docs_ref, phase, gates,
|
|
34
35
|
branch_base, openspec_change, manifest_hash,
|
|
35
36
|
lease: {ttl_s, expires_at}, checkpoint: {branch, commit_sha} | null}
|
|
@@ -53,12 +54,19 @@ Reglas del cliente (las implementa `slice-ops.sh claim`, no la prosa de la skill
|
|
|
53
54
|
4. Un agente mantiene **un solo** slice activo; reclamar con lease vigente devuelve el mismo slice.
|
|
54
55
|
5. Modo `dual`: **no se reclama** (el fichero decide el slice, spec §6.1) — el subcomando devuelve
|
|
55
56
|
rc 3. Modo `legacy`: no hay red, rc 3.
|
|
57
|
+
6. **No existe claim dirigido**: `TasksNextIn` no acepta `epic_code` (pydantic ignoraría el campo y
|
|
58
|
+
la cola repartiría otra épica — incidente del primer piloto, issue #39). `claim --epic` falla explícito
|
|
59
|
+
(rc 2, sin abrir socket) explicando el protocolo real: terminar el slice en dual → cutover
|
|
60
|
+
admin → reclamar del hub lo que la cola reparta.
|
|
56
61
|
|
|
57
62
|
Actos de dominio posteriores (todos por `slice-ops.sh`, ninguno a mano):
|
|
58
63
|
`POST /slices/{id}/verdicts` · `POST /checkpoints` · `POST /slices/{id}/submit` · eventos
|
|
59
|
-
`wiring_*`, `
|
|
60
|
-
proyecto (`
|
|
61
|
-
`
|
|
64
|
+
`wiring_*`, `progress_noted`, `slice_archived`, `slice_escalated` y los hechos de
|
|
65
|
+
proyecto **máquina** (`project_kind`, `harness_phase`, `foundation`) como
|
|
66
|
+
`project_fact_updated {fact, value, source: "AGENT"}` — el único tipo del catálogo v2 para hechos
|
|
67
|
+
(issue #38). Los hechos **humanos** (`scaffold_confirmed`, `design_source_*`) NO viajan por la
|
|
68
|
+
superficie de agente: se fijan tras el PDP por `PATCH /orchestrator/projects/{project_id}`
|
|
69
|
+
(consola admin); `slice-ops fact` los rechaza en runtime/dual con rc 6. Outer loop, por
|
|
62
70
|
`release-ops.sh`: `POST /releases/{line}/verdicts` y
|
|
63
71
|
`POST /fronts/{front_id}/members/{agent_key}/integration`.
|
|
64
72
|
|
|
@@ -86,9 +94,11 @@ Offline: sin runtime se trabaja con el último lock; la statusline marca `⚠ st
|
|
|
86
94
|
|
|
87
95
|
## 5. Cola offline de eventos (`.claude/state/outbox/`)
|
|
88
96
|
|
|
89
|
-
- Un archivo JSON por evento (no por lote — `runtime_enqueue_event`), con `client_event_id` (UUID) ⇒ **idempotencia server-side** (reintentos seguros). El despacho (`runtime_dispatch_outbox`) sí agrupa en un único `POST /events` por invocación.
|
|
97
|
+
- Un archivo JSON por evento (no por lote — `runtime_enqueue_event`), con `client_event_id` (UUID) ⇒ **idempotencia server-side** (reintentos seguros). El despacho (`runtime_dispatch_outbox`) sí agrupa en un único `POST /events` por invocación, con el sobre que el hub exige: `{"events": [{client_event_id, event_type, payload, slice_id?}]}` — la clave interna `type` de los ficheros de la outbox se traduce a `event_type` **al armar el cuerpo** (el formato en disco no cambia; ficheros encolados por 0.10.x drenan sin migración). El ack real del hub es `{accepted[], duplicates[], rejected[{client_event_id, reason}]}` (issue #37).
|
|
98
|
+
- **Capa de compat de nombres/payloads en el mismo punto de salida** (issue #44): los productores encolan los nombres del **catálogo v2** del hub (`gate_verdict`, `checkpoint_recorded`, `slice_escalated`, `progress_noted`), pero los ficheros 0.10.x con los nombres viejos (`verdict_reported`, `checkpoint_created`, `escalation_raised`, `progress_note_recorded`) drenan traducidos — tipo **y** claves de payload (`status`→`verdict` en mayúscula, `note`→`summary`, `reason/gate/phase`→`cause`) — sin migración. Los tipos **sin equivalente** en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se apartan localmente a `outbox/rejected/` sin gastar red.
|
|
99
|
+
- **Un 4xx no es red caída**: un 4xx del lote (salvo 408/429) o un elemento en `rejected[]` del ack **no se reintenta** — el fichero se mueve a `outbox/rejected/` con la razón loggeada y se avisa. Solo 408/429/5xx/corte de red conservan la cola con backoff (fail-open intacto).
|
|
90
100
|
- Despacho en background con backoff persistido (`1 s → 5 s → 30 s → 5 min`, tope; el estado vive en `outbox/.dispatch-state.json` y sobrevive entre invocaciones de hooks distintos). Orden FIFO global — el agrupado por-agregado nace con el catálogo de eventos (sub-slice C).
|
|
91
|
-
- Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `
|
|
101
|
+
- Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `checkpoint_recorded`, `gate_verdict`, `slice_escalated`, `slice_submitted`, `slice_archived`, `wiring_*`, `project_fact_updated`, `handoff_recorded` ni el propio `telemetry_gap` (nombres del catálogo v2; los alias 0.10.x de los cuatro renombrados siguen protegidos porque la capa de compat los entrega). El descarte se reporta como evento `telemetry_gap` con el payload del catálogo `{dropped, window_h, reason}` (issue #44), coalescido en un único gap mientras la cola siga sobre la cota.
|
|
92
102
|
- Flush forzado en `Stop`: `session-stop.sh` deja el sentinela `outbox/.flush-request`; el daemon `heartbeat.sh` lo consume de forma asíncrona (nunca en el hilo del hook). `trycore-build doctor` **reporta** el tamaño/edad de la cola pero **no** dispara un flush síncrono — sigue sin implementar.
|
|
93
103
|
|
|
94
104
|
## 6. Declaración de tipos de asset (`asset-types.json` del paquete)
|
|
@@ -161,24 +171,24 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
|
|
|
161
171
|
| Comando | Superficie |
|
|
162
172
|
|---|---|
|
|
163
173
|
| `slice-ops.sh mode` | ninguna (lee `build-config.json`) |
|
|
164
|
-
| `slice-ops.sh claim
|
|
174
|
+
| `slice-ops.sh claim` | `POST /tasks/next` (§3); `--epic` falla explícito, rc 2 (no hay claim dirigido, §3.6) |
|
|
165
175
|
| `slice-ops.sh next-step` | ninguna: lo **deriva el cliente** desde la caché de proyección |
|
|
166
|
-
| `slice-ops.sh gate <g> <pass\|fail\|na>` | `POST /slices/{id}/verdicts` |
|
|
167
|
-
| `slice-ops.sh wiring seed\|update` | eventos `wiring_checklist_seeded` / `wiring_item_updated` |
|
|
168
|
-
| `slice-ops.sh progress` | evento `
|
|
169
|
-
| `slice-ops.sh checkpoint` | `POST /checkpoints` |
|
|
170
|
-
| `slice-ops.sh submit` | `POST /slices/{id}/submit` |
|
|
171
|
-
| `slice-ops.sh archive` | evento `slice_archived` (siempre por cola, idempotente) |
|
|
172
|
-
| `slice-ops.sh fact …` |
|
|
176
|
+
| `slice-ops.sh gate <g> <pass\|fail\|na>` | `POST /slices/{id}/verdicts` (`{gate, verdict: PASS\|FAIL, evidence}`); offline → evento `gate_verdict`. `na` **no viaja**: el catálogo no lo admite (aviso local, rc 0, issue #44) |
|
|
177
|
+
| `slice-ops.sh wiring seed\|update` | eventos `wiring_checklist_seeded` (items `{item_id, kind, ref}`, sin `status`) / `wiring_item_updated` |
|
|
178
|
+
| `slice-ops.sh progress` | evento `progress_noted` (evictable) |
|
|
179
|
+
| `slice-ops.sh checkpoint` | `POST /checkpoints` (`summary`, no `note`); offline → evento `checkpoint_recorded {branch, commit_sha, summary}` |
|
|
180
|
+
| `slice-ops.sh submit` | `POST /slices/{id}/submit`; offline → evento `slice_submitted` con payload `{}` (el catálogo no admite campos) |
|
|
181
|
+
| `slice-ops.sh archive` | evento `slice_archived` con payload `{}` y `slice_id` (siempre por cola, idempotente) |
|
|
182
|
+
| `slice-ops.sh fact …` | hechos máquina → evento `project_fact_updated` (sin `slice_id`); hechos humanos → rc 6 con remisión al PATCH admin (§3) |
|
|
173
183
|
| `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
|
|
174
184
|
| `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales |
|
|
175
|
-
| `slice-ops.sh escalate` | evento `
|
|
176
|
-
| `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts` |
|
|
185
|
+
| `slice-ops.sh escalate` | evento `slice_escalated {cause}` (gate y fase dentro del texto de la causa; exige slice activo en runtime) |
|
|
186
|
+
| `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts`; **sin fallback offline** (rc 5 y reintento al reconectar: el agregado `release` no entra por `POST /events`, issue #44) |
|
|
177
187
|
| `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
|
|
178
|
-
| `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration` |
|
|
188
|
+
| `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration`; **sin fallback offline** (rc 5: `front_integration_reported` no existe en el catálogo v2, issue #44) |
|
|
179
189
|
|
|
180
190
|
**Códigos de salida** (contrato con la prosa): `0` ok · `2` uso · `3` modo legacy (o claim en dual)
|
|
181
|
-
· `4` sin slice activo · `5` offline
|
|
191
|
+
· `4` sin slice activo · `5` offline (encolado si el tipo tiene camino por la cola; si no, reintento manual al reconectar) · `6` rechazado por el servidor (no reintentar) ·
|
|
182
192
|
`7` sin trabajo.
|
|
183
193
|
|
|
184
194
|
**Modo `dual`:** el fichero es primario y estos comandos **espejan** cada transición a
|
|
@@ -14,59 +14,16 @@ source "$HERE/lib/projection.sh"
|
|
|
14
14
|
MODE="$(runtime_mode)"
|
|
15
15
|
[ "$MODE" = "legacy" ] && exit 0
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# El stdin del hook se consume y se descarta (higiene del pipe; no se usa).
|
|
18
|
+
cat >/dev/null 2>&1 || true
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
p=json.load(open(sys.argv[1]))
|
|
28
|
-
except Exception:
|
|
29
|
-
raise SystemExit(0)
|
|
30
|
-
if not isinstance(p,dict):
|
|
31
|
-
raise SystemExit(0)
|
|
32
|
-
tool=p.get("tool_name") or ""
|
|
33
|
-
if not tool:
|
|
34
|
-
raise SystemExit(0)
|
|
35
|
-
ti=p.get("tool_input")
|
|
36
|
-
if not isinstance(ti,dict):
|
|
37
|
-
ti={}
|
|
38
|
-
out={"tool":tool}
|
|
39
|
-
if tool=="Bash":
|
|
40
|
-
cmd=str(ti.get("command") or "").strip()
|
|
41
|
-
# shlex.split (posix=True) respeta comillas: una asignación de entorno inline con
|
|
42
|
-
# valor citado y espacios (p.ej. `KEY="a b" cmd`) se tokeniza como UN solo token, no
|
|
43
|
-
# se trocea. Si el comando trae comillas sin cerrar u otro problema de tokenización,
|
|
44
|
-
# shlex lanza ValueError: no arriesgamos un fragmento de secreto, degradamos a "".
|
|
45
|
-
try:
|
|
46
|
-
toks=shlex.split(cmd) if cmd else []
|
|
47
|
-
except ValueError:
|
|
48
|
-
toks=[]
|
|
49
|
-
# Salta asignaciones de variable de entorno inline al frente (p.ej. `VAR=secreto cmd`,
|
|
50
|
-
# `A=1 B=2 cmd`) para que argv0 sea siempre el binario, nunca un valor que pueda
|
|
51
|
-
# llevar credenciales (protocolo §8).
|
|
52
|
-
i=0
|
|
53
|
-
while i < len(toks) and re.match(r'^[A-Za-z_][A-Za-z0-9_]*=', toks[i]):
|
|
54
|
-
i += 1
|
|
55
|
-
out["argv0"]=toks[i] if i < len(toks) else ""
|
|
56
|
-
elif tool=="Task":
|
|
57
|
-
out["subagent_type"]=str(ti.get("subagent_type") or "")
|
|
58
|
-
else:
|
|
59
|
-
fp=str(ti.get("file_path") or ti.get("path") or "")
|
|
60
|
-
root=os.environ.get("PROJECT_ROOT") or ""
|
|
61
|
-
if root and fp.startswith(root+os.sep):
|
|
62
|
-
fp=fp[len(root)+1:]
|
|
63
|
-
out["path"]=fp
|
|
64
|
-
print(json.dumps(out,ensure_ascii=False))
|
|
65
|
-
PY
|
|
66
|
-
)"
|
|
67
|
-
rm -f "$TMPP"
|
|
68
|
-
[ -n "$EV" ] && runtime_enqueue_event "tool_use_recorded" "$EV" "$SLICE_ID"
|
|
69
|
-
fi
|
|
20
|
+
# [#44] `tool_use_recorded` NO existe en el catálogo v2 del hub (event_catalog.SCHEMAS):
|
|
21
|
+
# encolarlo era ruido garantizado en outbox/rejected/ al drenar, y este hook era justo su
|
|
22
|
+
# único productor. NO se emite telemetría de uso de herramienta hasta que el equipo del
|
|
23
|
+
# hub decida si añade el tipo al catálogo (decisión pendiente, issue #44); si eso ocurre,
|
|
24
|
+
# se reactiva aquí el armado de metadatos que vivía en este bloque hasta la v0.10.x
|
|
25
|
+
# (git history: solo herramienta, ruta relativa y argv0 — jamás el comando completo ni el
|
|
26
|
+
# fuente, protocolo §8). En legacy este hook ya salía arriba sin encolar: nada cambia.
|
|
70
27
|
|
|
71
28
|
# Comprobación barata del daemon (stat del pidfile + kill -0, SIN red): un daemon caído a
|
|
72
29
|
# mitad de sesión no debe quedar muerto durante horas hasta el próximo SessionStart.
|
|
@@ -261,8 +261,18 @@ RUNTIME_OUTBOX_MAX_BYTES=5242880
|
|
|
261
261
|
RUNTIME_OUTBOX_MAX_AGE_S=259200
|
|
262
262
|
# [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
|
|
263
263
|
# perder un `slice_archived` o un `wiring_item_updated` dejaría la proyección del servidor
|
|
264
|
-
# mintiendo. El volumen evictable
|
|
265
|
-
|
|
264
|
+
# mintiendo. El volumen evictable es `progress_noted` (y su alias 0.10.x).
|
|
265
|
+
# [#38] Los hechos máquina de proyecto viajan como UN solo tipo del catálogo v2
|
|
266
|
+
# (`project_fact_updated`); los cinco alias legados (`scaffold_confirmed`,
|
|
267
|
+
# `design_source_declared`, `project_kind_declared`, `foundation_declared`,
|
|
268
|
+
# `harness_phase_changed`) ya no se encolan y salieron de la lista.
|
|
269
|
+
# [#44] La lista lleva los NOMBRES DEL CATÁLOGO v2 del hub (event_catalog.SCHEMAS):
|
|
270
|
+
# proteger de la evicción un tipo que el hub va a rechazar era absurdo. Salen
|
|
271
|
+
# `branch_drift` y `front_integration_reported` (no existen en el catálogo; el despacho
|
|
272
|
+
# los aparta a rejected/). Los tres alias 0.10.x del final (`checkpoint_created`,
|
|
273
|
+
# `verdict_reported`, `escalation_raised`) siguen protegidos SOLO porque la capa de
|
|
274
|
+
# compat del despacho los traduce y entrega — evictarlos perdería hechos entregables.
|
|
275
|
+
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 checkpoint_created verdict_reported escalation_raised"
|
|
266
276
|
|
|
267
277
|
runtime_outbox_dir() {
|
|
268
278
|
echo "$(config_root)/.claude/state/outbox"
|
|
@@ -301,9 +311,12 @@ PY
|
|
|
301
311
|
}
|
|
302
312
|
|
|
303
313
|
# runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
|
|
304
|
-
# (más viejos primero); jamás descarta
|
|
305
|
-
#
|
|
306
|
-
#
|
|
314
|
+
# (más viejos primero); jamás descarta un tipo de RUNTIME_OUTBOX_PROTECTED. Si hubo
|
|
315
|
+
# descartes, encola un telemetry_gap con el payload EXACTO del catálogo v2 [#44]
|
|
316
|
+
# (TelemetryGap: {dropped>=1, window_h>0, reason<=2000}; extra="forbid" — el
|
|
317
|
+
# {dropped_count, dropped_types} de 0.10.x era rechazo garantizado): `dropped` es el
|
|
318
|
+
# conteo, `window_h` la ventana de la cota en horas y `reason` el motivo con los tipos
|
|
319
|
+
# descartados. Imprime en stdout el conteo de descartados (última línea).
|
|
307
320
|
runtime_outbox_enforce_cap() {
|
|
308
321
|
local dir; dir="$(runtime_outbox_dir)"
|
|
309
322
|
[ -d "$dir" ] || { echo 0; return 0; }
|
|
@@ -360,19 +373,33 @@ if dropped:
|
|
|
360
373
|
existing_gap_path=p
|
|
361
374
|
existing_gap=d
|
|
362
375
|
break
|
|
376
|
+
window_h=max_age/3600.0
|
|
377
|
+
def resumen(tipos):
|
|
378
|
+
counts={}
|
|
379
|
+
for t in tipos:
|
|
380
|
+
k=t or "desconocido"
|
|
381
|
+
counts[k]=counts.get(k,0)+1
|
|
382
|
+
return ", ".join("%s x%d" % kv for kv in sorted(counts.items()))
|
|
383
|
+
encabezado="evicción por cota de la outbox (%d bytes / %.0f h)" % (max_bytes, window_h)
|
|
363
384
|
if existing_gap_path:
|
|
364
385
|
payload=existing_gap.get("payload") or {}
|
|
365
|
-
|
|
366
|
-
payload
|
|
386
|
+
# Compat 0.10.x: un gap ya encolado puede traer {dropped_count, dropped_types};
|
|
387
|
+
# se normaliza aquí al payload del catálogo antes de acumular.
|
|
388
|
+
prev_n=payload.get("dropped", payload.get("dropped_count", 0)) or 0
|
|
389
|
+
prev_types=payload.get("dropped_types") or []
|
|
390
|
+
prev_reason=payload.get("reason") if isinstance(payload.get("reason"),str) else ""
|
|
391
|
+
base=prev_reason or (("%s: %s" % (encabezado, resumen(prev_types))) if prev_types else "")
|
|
392
|
+
texto=("%s; %s" % (base, resumen(dropped))) if base else ("%s: %s" % (encabezado, resumen(dropped)))
|
|
367
393
|
gap=existing_gap
|
|
368
|
-
gap["payload"]=
|
|
394
|
+
gap["payload"]={"dropped": prev_n+len(dropped), "window_h": window_h, "reason": texto[:2000]}
|
|
369
395
|
fname=existing_gap_path
|
|
370
396
|
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
371
397
|
else:
|
|
372
398
|
cid=str(uuid.uuid4())
|
|
373
399
|
ts=datetime.datetime.now(datetime.timezone.utc)
|
|
374
400
|
gap={"client_event_id":cid,"type":"telemetry_gap",
|
|
375
|
-
"payload":{"
|
|
401
|
+
"payload":{"dropped":len(dropped),"window_h":window_h,
|
|
402
|
+
"reason":("%s: %s" % (encabezado, resumen(dropped)))[:2000]},
|
|
376
403
|
"enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
|
|
377
404
|
fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
|
|
378
405
|
fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
|
|
@@ -416,44 +443,177 @@ runtime_dispatch_outbox() {
|
|
|
416
443
|
while IFS= read -r f; do files+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
|
|
417
444
|
[ ${#files[@]} -eq 0 ] && return 0
|
|
418
445
|
|
|
446
|
+
# [#37] El hub (`EventBatchIn`) exige el sobre {"events": [...]} — el array pelado era
|
|
447
|
+
# 422 en cada intento y la cola no drenaba jamás. Cada elemento va con `event_type`
|
|
448
|
+
# (`EventIn`); la clave interna de la outbox sigue siendo `type` y se traduce AQUÍ, al
|
|
449
|
+
# armar el cuerpo: los ficheros ya encolados en consumidores (0.10.x, clave `type`)
|
|
450
|
+
# drenan sin migración y el formato en disco no cambia (lo leen otros clientes).
|
|
451
|
+
# [#44] En este MISMO punto de salida vive la capa de compat de nombres/payloads: los
|
|
452
|
+
# productores ya encolan los nombres del catálogo v2, pero un consumidor 0.10.x dejó
|
|
453
|
+
# ficheros con los nombres viejos (`verdict_reported`, `checkpoint_created`,
|
|
454
|
+
# `escalation_raised`, `progress_note_recorded`) y payloads que `extra="forbid"`
|
|
455
|
+
# rechazaría — se traducen tipo Y claves al armar el cuerpo. Los tipos SIN equivalente
|
|
456
|
+
# en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se
|
|
457
|
+
# apartan localmente a rejected/ sin gastar red: mandarlos era rechazo garantizado.
|
|
419
458
|
local batch
|
|
420
|
-
batch="$(python3 - "${files[@]}" <<'PY'
|
|
421
|
-
import json,sys
|
|
459
|
+
batch="$(python3 - "$dir/rejected" "${files[@]}" <<'PY'
|
|
460
|
+
import json,sys,os
|
|
461
|
+
rejected_dir=sys.argv[1]
|
|
462
|
+
SIN_EQUIVALENTE={"tool_use_recorded","branch_drift","front_integration_reported"}
|
|
463
|
+
def traducir(t,p,slice_id):
|
|
464
|
+
p=dict(p)
|
|
465
|
+
if t=="verdict_reported":
|
|
466
|
+
# Un veredicto de RELEASE 0.10.x (payload con release_line, sin slice) no tiene
|
|
467
|
+
# camino por /events: se deja pasar tal cual y el hub lo rechaza con razón — la
|
|
468
|
+
# forma de slice se traduce a gate_verdict {gate, verdict, evidence…}.
|
|
469
|
+
t="gate_verdict"
|
|
470
|
+
if "status" in p and "verdict" not in p:
|
|
471
|
+
p["verdict"]=p.pop("status")
|
|
472
|
+
v=p.get("verdict")
|
|
473
|
+
if isinstance(v,str) and v.lower() in ("pass","fail"):
|
|
474
|
+
p["verdict"]=v.upper()
|
|
475
|
+
p={k:v for k,v in p.items() if k in ("gate","verdict","evidence","cause","details_ref")}
|
|
476
|
+
elif t=="checkpoint_created":
|
|
477
|
+
t="checkpoint_recorded"
|
|
478
|
+
if "note" in p and "summary" not in p:
|
|
479
|
+
p["summary"]=p.pop("note")
|
|
480
|
+
sid=p.pop("slice_id",None)
|
|
481
|
+
if sid and not slice_id:
|
|
482
|
+
slice_id=sid
|
|
483
|
+
p={k:v for k,v in p.items() if k in ("branch","commit_sha","summary")}
|
|
484
|
+
elif t=="escalation_raised":
|
|
485
|
+
t="slice_escalated"
|
|
486
|
+
pref=""
|
|
487
|
+
if p.get("gate"): pref+="[gate: %s] " % p["gate"]
|
|
488
|
+
if p.get("phase"): pref+="[fase: %s] " % p["phase"]
|
|
489
|
+
cause=p.get("cause") or (pref+str(p.get("reason") or ""))
|
|
490
|
+
p={"cause": (cause.strip() or "escalada sin causa registrada")[:2000]}
|
|
491
|
+
elif t=="progress_note_recorded":
|
|
492
|
+
t="progress_noted"
|
|
493
|
+
p={k:v for k,v in p.items() if k=="note"}
|
|
494
|
+
elif t in ("slice_submitted","slice_archived"):
|
|
495
|
+
p={} # el catálogo no admite ningún campo (extra="forbid")
|
|
496
|
+
elif t=="wiring_checklist_seeded":
|
|
497
|
+
items=[]
|
|
498
|
+
for it in p.get("items") or []:
|
|
499
|
+
if isinstance(it,dict) and it.get("item_id"):
|
|
500
|
+
items.append({"item_id":it["item_id"],"kind":it.get("kind") or "","ref":it.get("ref") or ""})
|
|
501
|
+
p={"items":items}
|
|
502
|
+
elif t=="telemetry_gap" and "dropped" not in p and "dropped_count" in p:
|
|
503
|
+
tipos=p.get("dropped_types") or []
|
|
504
|
+
p={"dropped":p.get("dropped_count") or 0,"window_h":72.0,
|
|
505
|
+
"reason":("evicción por cota de la outbox del cliente; tipos descartados: "
|
|
506
|
+
+", ".join(str(x) for x in tipos))[:2000]}
|
|
507
|
+
return t,p,slice_id
|
|
422
508
|
items=[]
|
|
423
|
-
for
|
|
424
|
-
try:
|
|
425
|
-
|
|
426
|
-
|
|
509
|
+
for path in sys.argv[2:]:
|
|
510
|
+
try:
|
|
511
|
+
d=json.load(open(path))
|
|
512
|
+
except Exception:
|
|
513
|
+
continue
|
|
514
|
+
t=d.get("event_type") or d.get("type") or ""
|
|
515
|
+
if t in SIN_EQUIVALENTE:
|
|
516
|
+
try:
|
|
517
|
+
os.makedirs(rejected_dir,exist_ok=True)
|
|
518
|
+
os.replace(path,os.path.join(rejected_dir,os.path.basename(path)))
|
|
519
|
+
print("⚠ %r no existe en el catálogo v2 del hub: apartado a rejected/, no se enviará (issue #44)"
|
|
520
|
+
% t, file=sys.stderr)
|
|
521
|
+
except OSError:
|
|
522
|
+
pass
|
|
523
|
+
continue
|
|
524
|
+
t,p,slice_id=traducir(t,d.get("payload") or {},d.get("slice_id"))
|
|
525
|
+
ev={"client_event_id": d.get("client_event_id") or "",
|
|
526
|
+
"event_type": t,
|
|
527
|
+
"payload": p}
|
|
528
|
+
if slice_id:
|
|
529
|
+
ev["slice_id"]=slice_id
|
|
530
|
+
items.append(ev)
|
|
531
|
+
print(json.dumps({"events": items}, ensure_ascii=False))
|
|
427
532
|
PY
|
|
428
533
|
)"
|
|
429
534
|
|
|
535
|
+
# Si todo lo pendiente eran tipos sin equivalente (ya apartados), no hay nada que enviar.
|
|
536
|
+
local n_items
|
|
537
|
+
n_items="$(printf '%s' "$batch" | python3 -c 'import json,sys;print(len(json.load(sys.stdin).get("events") or []))' 2>/dev/null || echo 0)"
|
|
538
|
+
if [ "$n_items" = "0" ]; then
|
|
539
|
+
rm -f "$state_file"
|
|
540
|
+
return 0
|
|
541
|
+
fi
|
|
542
|
+
|
|
430
543
|
local resp status
|
|
431
544
|
resp="$(runtime_post "/events" "$batch")"
|
|
432
545
|
status="$(runtime_http_status)"
|
|
433
546
|
|
|
434
547
|
if [ "$status" = "200" ]; then
|
|
435
|
-
|
|
548
|
+
# [#37] Ack REAL del hub: {accepted:[cid], duplicates:[cid], rejected:[{client_event_id,
|
|
549
|
+
# reason}]}. Se conserva la lectura de la forma antigua {"results":[…]} (stubs/beta).
|
|
550
|
+
# Aceptado o duplicado => entregado, se borra. Rechazado POR ELEMENTO (fuera de catálogo
|
|
551
|
+
# o payload inválido) => reenviarlo es inútil: se aparta a rejected/ con su razón en
|
|
552
|
+
# stderr. Sin mención => se conserva para el próximo despacho (nunca se asume aceptado
|
|
553
|
+
# en silencio: perder un checkpoint/verdict/submit sin confirmación real viola la cota).
|
|
554
|
+
python3 - "$resp" "$dir/rejected" "${files[@]}" <<'PY'
|
|
436
555
|
import json,sys,os
|
|
437
556
|
resp=json.loads(sys.argv[1])
|
|
438
|
-
|
|
439
|
-
|
|
557
|
+
rejected_dir=sys.argv[2]
|
|
558
|
+
paths=sys.argv[3:]
|
|
559
|
+
status={}
|
|
560
|
+
reasons={}
|
|
561
|
+
if isinstance(resp,dict) and "results" in resp:
|
|
562
|
+
for r in resp.get("results") or []:
|
|
563
|
+
status[r.get("client_event_id")]=r.get("status")
|
|
564
|
+
elif isinstance(resp,dict):
|
|
565
|
+
for cid in resp.get("accepted") or []:
|
|
566
|
+
status[cid]="accepted"
|
|
567
|
+
for cid in resp.get("duplicates") or []:
|
|
568
|
+
status[cid]="accepted"
|
|
569
|
+
for r in resp.get("rejected") or []:
|
|
570
|
+
cid=r.get("client_event_id")
|
|
571
|
+
status[cid]="rejected"
|
|
572
|
+
reasons[cid]=r.get("reason") or "sin razón"
|
|
440
573
|
for p in paths:
|
|
441
574
|
try:
|
|
442
575
|
cid=json.load(open(p)).get("client_event_id")
|
|
443
576
|
except Exception:
|
|
444
577
|
continue
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
# viola el contrato de la cota, Task 8).
|
|
448
|
-
st=results.get(cid)
|
|
449
|
-
if st in ("accepted","rejected"):
|
|
578
|
+
st=status.get(cid)
|
|
579
|
+
if st=="accepted":
|
|
450
580
|
try: os.unlink(p)
|
|
451
581
|
except OSError: pass
|
|
582
|
+
elif st=="rejected":
|
|
583
|
+
print("⚠ el hub rechazó el evento %s: %s — apartado a rejected/, no se reenviará"
|
|
584
|
+
% (cid, reasons.get(cid,"sin razón")), file=sys.stderr)
|
|
585
|
+
try:
|
|
586
|
+
os.makedirs(rejected_dir,exist_ok=True)
|
|
587
|
+
os.replace(p, os.path.join(rejected_dir, os.path.basename(p)))
|
|
588
|
+
except OSError:
|
|
589
|
+
pass
|
|
452
590
|
PY
|
|
453
591
|
rm -f "$state_file"
|
|
454
592
|
return 0
|
|
455
593
|
fi
|
|
456
594
|
|
|
595
|
+
# [#37] Un 4xx del LOTE (salvo 408/429) es un rechazo del contrato, no red caída:
|
|
596
|
+
# reintentarlo para siempre solo repite el mismo error y el usuario cree que reportó.
|
|
597
|
+
# Se loggea el cuerpo, el lote entero se aparta a rejected/ y NO se agenda backoff.
|
|
598
|
+
# 408 (timeout) y 429 (rate-limit) siguen siendo transitorios, igual que 5xx y 000.
|
|
599
|
+
case "$status" in
|
|
600
|
+
408|429) : ;;
|
|
601
|
+
4*)
|
|
602
|
+
local rejected_dir f
|
|
603
|
+
rejected_dir="$dir/rejected"
|
|
604
|
+
mkdir -p "$rejected_dir"
|
|
605
|
+
echo "⛔ el hub rechazó el lote de eventos (HTTP $status): $resp" >&2
|
|
606
|
+
echo " ${#files[@]} evento(s) movidos a $rejected_dir/ — NO se reintentarán." >&2
|
|
607
|
+
echo " Revisa el contrato del cliente (docs/runtime/protocolo-cliente-runtime.md) o" >&2
|
|
608
|
+
echo " actualiza el arnés; el trabajo local sigue (fail-open)." >&2
|
|
609
|
+
for f in "${files[@]}"; do
|
|
610
|
+
mv "$f" "$rejected_dir/" 2>/dev/null
|
|
611
|
+
done
|
|
612
|
+
rm -f "$state_file"
|
|
613
|
+
return 1
|
|
614
|
+
;;
|
|
615
|
+
esac
|
|
616
|
+
|
|
457
617
|
local schedule idx max_idx delay
|
|
458
618
|
read -ra schedule <<< "$RUNTIME_OUTBOX_BACKOFF_SCHEDULE"
|
|
459
619
|
idx=$attempt
|