@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.
@@ -18,10 +18,19 @@ set -uo pipefail
18
18
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
19
  source "$HERE/lib/runtime-client.sh"
20
20
  source "$HERE/lib/projection.sh"
21
+ source "$HERE/lib/agent-context.sh"
21
22
 
22
23
  HB_TICK_S="${TRYCORE_HEARTBEAT_TICK_S:-5}"
23
24
  case "$HB_TICK_S" in ''|*[!0-9]*) HB_TICK_S=5 ;; esac
24
25
 
26
+ # Cadencia del refresco de contexto [#61]. NO es el tick: renovar un lease es barato, pero
27
+ # `GET /agent/context` es una proyección entera del proyecto — a 5 s castigaría al hub sin
28
+ # ganar nada. 45 s es del orden de la latencia con la que un humano aprueba una épica.
29
+ # `0` lo desactiva (vuelta al comportamiento previo a #61).
30
+ HB_CONTEXT_S="${TRYCORE_CONTEXT_REFRESH_S:-}"
31
+ [ -n "$HB_CONTEXT_S" ] || HB_CONTEXT_S="$(config_get runtime.context_refresh_s 45)"
32
+ case "$HB_CONTEXT_S" in ''|*[!0-9]*) HB_CONTEXT_S=45 ;; esac
33
+
25
34
  hb_pidfile() { echo "$(config_root)/.claude/state/heartbeat.pid"; }
26
35
  hb_sessions() { echo "$(config_root)/.claude/state/heartbeat-sessions.json"; }
27
36
  hb_statusfile() { echo "$(config_root)/.claude/state/heartbeat-status.json"; }
@@ -115,7 +124,11 @@ PY
115
124
  echo "$n"
116
125
  }
117
126
 
118
- # hb_write_status <json> — estado observable del daemon (lo lee statusline-bridge.sh).
127
+ # hb_write_status <fragmento-json> — UPSERT del fragmento sobre el estado observable del
128
+ # daemon (lo lee statusline-bridge.sh y `slice-ops.sh status`). Es merge y no reemplazo
129
+ # desde #61: el fichero lo escriben DOS deberes distintos del tick — la renovación del lease
130
+ # y el refresco de contexto — y con reemplazo el último en escribir borraba el diagnóstico
131
+ # del otro (un refresco correcto tapaba un `lease_lost: true` recién detectado).
119
132
  hb_write_status() {
120
133
  local body f
121
134
  body="$1"
@@ -126,14 +139,22 @@ hb_write_status() {
126
139
  import json,sys,os,tempfile
127
140
  path,body=sys.argv[1],sys.argv[2]
128
141
  try:
129
- d=json.loads(body)
142
+ d=json.load(open(path))
130
143
  except Exception:
131
144
  d={}
145
+ if not isinstance(d,dict):
146
+ d={}
147
+ try:
148
+ frag=json.loads(body)
149
+ except Exception:
150
+ frag={}
151
+ if isinstance(frag,dict):
152
+ d.update(frag)
132
153
  dirn=os.path.dirname(path) or "."
133
154
  fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".heartbeat-status.",suffix=".tmp")
134
155
  try:
135
156
  with os.fdopen(fd,"w") as out:
136
- json.dump(d,out,indent=2)
157
+ json.dump(d,out,indent=2); out.flush(); os.fsync(out.fileno())
137
158
  os.replace(tmp,path)
138
159
  except Exception:
139
160
  try: os.unlink(tmp)
@@ -168,8 +189,40 @@ hb_renew() {
168
189
  esac
169
190
  }
170
191
 
192
+ # hb_refresh_context — refresca la caché de proyección desde GET /agent/context. Es el ÚNICO
193
+ # camino por el que una terminal ABIERTA se entera de algo nuevo: `session-start.sh` hidrata
194
+ # una vez al arrancar y `slice-ops.sh` solo en claim/status — y el claim ocurre una vez por
195
+ # slice, así que sin esto una sesión larga trabaja horas contra una foto vieja (#61).
196
+ # Solo en modo `runtime`: en legacy|dual el fichero local es primario y el comportamiento no
197
+ # cambia. `agent_context_fetch_and_cache` NUNCA pisa la caché anterior si falla (fail-open);
198
+ # aquí solo se anota el diagnóstico, para que `status` distinga «vieja» de «no se pudo».
199
+ #
200
+ # `last_context_status` es SIEMPRE una cadena. El diagnóstico no es solo un código HTTP
201
+ # (`"write_error"` no lo es) y `"000"` —el valor de «sin red» de `runtime_http_status`— no es
202
+ # un número JSON válido: emitirlo sin comillas rompía el parseo del fragmento entero y
203
+ # `hb_write_status` acababa no escribiendo NADA, ni siquiera el `context_stale: true`.
204
+ hb_refresh_context() {
205
+ local status rc
206
+ [ "$(runtime_mode)" = "runtime" ] || return 0
207
+ agent_context_fetch_and_cache >/dev/null 2>&1
208
+ rc=$?
209
+ if [ "$rc" -eq 0 ]; then
210
+ hb_write_status "{\"context_stale\": false, \"last_context_status\": \"200\", \"last_context_refresh_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
211
+ return 0
212
+ fi
213
+ # [#61 · ronda final] Un 200 que no se pudo normalizar o escribir NO es un fallo de red:
214
+ # anotarlo como «último intento: 200» mandaba a mirar el sitio equivocado.
215
+ if [ "$rc" -eq 2 ]; then
216
+ status="write_error"
217
+ else
218
+ status="$(runtime_http_status)"
219
+ fi
220
+ hb_write_status "{\"context_stale\": true, \"last_context_status\": \"$status\"}"
221
+ return 1
222
+ }
223
+
171
224
  hb_daemon() {
172
- local pidf interval ttl last now n
225
+ local pidf interval ttl last last_ctx now n
173
226
  pidf="$(hb_pidfile)"
174
227
  mkdir -p "$(dirname "$pidf")"
175
228
  printf '%s' "$$" > "$pidf"
@@ -184,6 +237,9 @@ hb_daemon() {
184
237
  ;;
185
238
  esac
186
239
  last=0
240
+ # `session-start.sh` acaba de hidratar la caché: arrancar el contador AHORA evita un
241
+ # refresco redundante en el primer tick del daemon.
242
+ last_ctx="$(date +%s)"
187
243
  while :; do
188
244
  sleep "$HB_TICK_S"
189
245
  n="$(hb_prune_sessions)"
@@ -194,6 +250,10 @@ hb_daemon() {
194
250
  runtime_dispatch_outbox >/dev/null 2>&1
195
251
  fi
196
252
  now="$(date +%s)"
253
+ if [ "$HB_CONTEXT_S" -gt 0 ] && [ $(( now - last_ctx )) -ge "$HB_CONTEXT_S" ]; then
254
+ hb_refresh_context
255
+ last_ctx="$now"
256
+ fi
197
257
  if [ $(( now - last )) -ge "$interval" ]; then
198
258
  hb_renew
199
259
  runtime_dispatch_outbox >/dev/null 2>&1
@@ -102,8 +102,12 @@ out = {
102
102
  "checkpoint": checkpoint,
103
103
  },
104
104
  "nudges": nudges,
105
+ # [#63] `graph_version` viaja junto al manifest_hash: es la foto del BACKLOG (épicas y sus
106
+ # estados), no la del contexto gobernado. Un hub que no versiona la deja en None y el
107
+ # cliente sigue funcionando exactamente igual que antes (compatibilidad hacia atrás).
105
108
  "context": {"manifest_hash": first(ctx.get("manifest_hash"), raw.get("manifest_hash")),
106
- "version": first(ctx.get("version"), raw.get("context_version"))},
109
+ "version": first(ctx.get("version"), raw.get("context_version")),
110
+ "graph_version": first(ctx.get("graph_version"), raw.get("graph_version"))},
107
111
  "lease": {"expires_at": first(lease.get("expires_at"), raw.get("lease_expires_at")),
108
112
  "ttl_s": first(lease.get("ttl_s"), raw.get("lease_ttl_s"))},
109
113
  }
@@ -120,20 +124,25 @@ PY
120
124
  }
121
125
 
122
126
  # agent_context_fetch_and_cache — GET /agent/context, normaliza y escribe la caché.
123
- # rc 0 = caché actualizada · 1 = no se pudo (red, status != 200, respuesta ilegible):
124
- # en ese caso la caché anterior NO se toca, y los guards siguen operando con ella.
127
+ # rc 0 = caché actualizada · 1 = no se pudo TRAER (red caída, status != 200) · 2 = el hub
128
+ # respondió 200 pero la respuesta no se pudo normalizar o la proyección no se pudo escribir.
129
+ # En ambos fallos la caché anterior NO se toca y los guards siguen operando con ella.
130
+ # [#61 · ronda final] El 2 no es cosmético: con un solo rc de fallo, quien diagnostica después
131
+ # leía `runtime_http_status` == 200 y anunciaba «no se pudo refrescar (último intento: 200)»,
132
+ # que manda a mirar la red cuando el problema está en el disco o en el cuerpo de la respuesta.
133
+ # Todos los llamadores ramifican sobre «rc != 0», así que el código nuevo no cambia su flujo.
125
134
  agent_context_fetch_and_cache() {
126
135
  local body status tmp norm rc
127
136
  body="$(runtime_get "$AGENT_CONTEXT_PATH")"
128
137
  status="$(runtime_http_status)"
129
138
  [ "$status" = "200" ] || return 1
130
- tmp="$(mktemp)" || return 1
139
+ tmp="$(mktemp)" || return 2
131
140
  printf '%s' "$body" > "$tmp"
132
141
  norm="$(agent_context_normalize "$tmp")"
133
142
  rc=$?
134
143
  rm -f "$tmp"
135
- [ $rc -eq 0 ] || return 1
136
- [ -n "$norm" ] && [ "$norm" != "{}" ] || return 1
137
- runtime_projection_write "$norm" || return 1
144
+ [ $rc -eq 0 ] || return 2
145
+ [ -n "$norm" ] && [ "$norm" != "{}" ] || return 2
146
+ runtime_projection_write "$norm" || return 2
138
147
  return 0
139
148
  }
@@ -5,8 +5,32 @@
5
5
  # config_get. Diseño: fail-open, igual que state-io.sh — nunca lanza.
6
6
 
7
7
  # config_root — raíz del proyecto consumidor (misma señal que state_path en state-io.sh).
8
+ #
9
+ # Worktrees: el estado del arnés (runtime.credentials, runtime-projection.json,
10
+ # outbox/, build-state.json) está en .gitignore — es secreto y working state, no
11
+ # se versiona — así que NO viaja a un `git worktree add`. Resolviendo la raíz solo
12
+ # por el toplevel, todo agente que arranque en un worktree se queda sin
13
+ # credenciales y opera HUÉRFANO: sin identidad de proyecto, sin lease, sin
14
+ # reportar al hub, y con los guards inhibidos porque su fail-open de auto-arme no
15
+ # distingue "este proyecto no usa el arnés" de "no veo el estado desde aquí".
16
+ # Por eso, cuando el árbol actual no tiene estado, se cae al clon principal
17
+ # (--git-common-dir), que es donde vive de verdad. Idempotente para un clon
18
+ # normal: si el candidato ya tiene estado, se devuelve tal cual.
8
19
  config_root() {
9
- echo "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
20
+ local candidate
21
+ candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
22
+ [ -f "$candidate/.claude/state/runtime.credentials" ] && { echo "$candidate"; return; }
23
+
24
+ local common main
25
+ common="$(git -C "$candidate" rev-parse --git-common-dir 2>/dev/null)" || common=""
26
+ if [ -n "$common" ]; then
27
+ case "$common" in /*) ;; *) common="$candidate/$common" ;; esac
28
+ main="$(cd "$common/.." 2>/dev/null && pwd)" || main=""
29
+ if [ -n "$main" ] && [ -f "$main/.claude/state/runtime.credentials" ]; then
30
+ echo "$main"; return
31
+ fi
32
+ fi
33
+ echo "$candidate"
10
34
  }
11
35
 
12
36
  # config_get <clave.punteada> <default>