harnery 0.31.2 → 0.31.4

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.
@@ -14,7 +14,7 @@
14
14
  * 2. A **fully-owned file** harnery creates whole (a shipped skill's
15
15
  * `SKILL.md`), carrying an ownership header comment so `deinit` deletes
16
16
  * only files harnery generated and `--check` flags a hand-edit:
17
- * <!-- harnery:generated <name> v=<hash> — machine-owned … -->
17
+ * <!-- harnery:generated <name> v=<hash>; machine-owned … -->
18
18
  *
19
19
  * Modeled on the first host's HTML-theme splicer (regenerate + byte-compare,
20
20
  * sha256-8 hash, content outside the region untouchable). Pure (no fs) so it's
@@ -103,7 +103,7 @@ export function buildOwnedSkill(opts) {
103
103
  "---",
104
104
  ].join("\n");
105
105
  const body = opts.body.trim();
106
- const marker = `<!-- harnery:generated ${opts.name} v=${shortHash(body)} — machine-owned; ` +
106
+ const marker = `<!-- harnery:generated ${opts.name} v=${shortHash(body)}; machine-owned; ` +
107
107
  `regenerated by \`${opts.binName} init\`, removed by \`${opts.binName} deinit\`. ` +
108
108
  `Edit the harnery template, not this file. -->`;
109
109
  return `${fm}\n${marker}\n\n${body}\n`;
@@ -115,7 +115,7 @@ surfaces prior decisions, so check for precedent before re-deciding. ${decidePoi
115
115
  function decideBody(b) {
116
116
  return `The decision docket is a persistent queue for decisions you would otherwise
117
117
  route to a human. It's built on the \`${b} decision\` engine. This skill is the
118
- mechanics — file, find precedent, claim, and resolve with evidence. *When* a
118
+ mechanics: file, find precedent, claim, and resolve with evidence. *When* a
119
119
  decision needs a human at all (versus one you settle yourself) is host policy;
120
120
  if this project defines that rubric, follow it.
121
121
 
@@ -128,7 +128,7 @@ if this project defines that rubric, follow it.
128
128
  ## Capture (default)
129
129
 
130
130
  1. **Check precedent first.** \`${b} decision search "<key terms>"\`. If a resolved
131
- decision already answers this, cite it — don't re-litigate.
131
+ decision already answers this, cite it; don't re-litigate.
132
132
  2. **File it** when the choice has a second consumer (a human will want to see it,
133
133
  or a future agent will face it again) or reversal is expensive. Skip the
134
134
  docket for pure local mechanics (a variable name, one of two equivalent idioms).
@@ -141,7 +141,7 @@ if this project defines that rubric, follow it.
141
141
 
142
142
  For a decision with real substance, write a brief to a file and pass
143
143
  \`--brief <path>\` so the reviewer sees options + evidence, not a cold prompt.
144
- 3. **Proceed on your default.** Filing does not mean blocking — note the id in your
144
+ 3. **Proceed on your default.** Filing does not mean blocking. Note the id in your
145
145
  reply and keep working.
146
146
 
147
147
  ## Resolve (\`resolve <id>\`)
@@ -151,7 +151,7 @@ ${b} decision show <id> # read the question + context + any brief
151
151
  ${b} decision claim <id> # mark it deliberating (claimed by you)
152
152
  \`\`\`
153
153
 
154
- Research it for real — run the queries, read the files, compute the costs. Then
154
+ Research it for real: run the queries, read the files, compute the costs. Then
155
155
  resolve with **cited evidence** (the engine rejects an evidence-free resolution):
156
156
 
157
157
  \`\`\`bash
@@ -210,7 +210,7 @@ ${b} council list --mine --json
210
210
 
211
211
  One section per council you're a member of: id + objective, round N (open /
212
212
  collected) with N/M contributors, and your status (awaiting prompt / prompt ready
213
- / already contributed) with the next command to run. Stop after listing — don't
213
+ / already contributed) with the next command to run. Stop after listing; don't
214
214
  auto-route into contribute.
215
215
 
216
216
  ## Create (\`create <objective>\`)
@@ -224,18 +224,18 @@ http://localhost:9000/councils/new?objective=<encoded>
224
224
 
225
225
  If the dev server isn't up, start it with \`${b} web up\`.
226
226
 
227
- ## Contribute (\`contribute <id>\`) — the guarded flow
227
+ ## Contribute (\`contribute <id>\`): the guarded flow
228
228
 
229
229
  Run these checks in order; refuse with a specific reason if any fails.
230
230
 
231
- 1. **Membership.** If your \`whoami\` name isn't in \`manifest.members\`, refuse — the
232
- router likely meant a different agent's session.
233
- 2. **Already contributed.** If you're in \`current_round_contributors\`, refuse —
234
- wait for the steward to advance the round.
231
+ 1. **Membership.** If your \`whoami\` name isn't in \`manifest.members\`, refuse.
232
+ The router likely meant a different agent's session.
233
+ 2. **Already contributed.** If you're in \`current_round_contributors\`, refuse.
234
+ Wait for the steward to advance the round.
235
235
  3. **Prompt routing.** Find your entry in \`current_round_prompts\`. If none is
236
236
  drafted for you, refuse (the steward must write one first). If the routed body
237
237
  carries a \`<!-- council-route … member: <name> -->\` header naming a *different*
238
- agent, refuse — the wrong prompt was pasted into your session.
238
+ agent, refuse: the wrong prompt was pasted into your session.
239
239
  4. **Compose** per your prompt (read \`manifest.target_doc\` in full if set; strip
240
240
  the route header before treating the body as instructions).
241
241
  5. **Submit:**
@@ -250,20 +250,20 @@ Run these checks in order; refuse with a specific reason if any fails.
250
250
  \`<trivial>\` angle-bracket tag on the final line (the exit-criterion parser keys
251
251
  on it). When in doubt, lean \`<trivial>\`.
252
252
 
253
- ## Prompts (\`prompts <id>\`) — steward
253
+ ## Prompts (\`prompts <id>\`): steward
254
254
 
255
255
  1. **Authority.** If your \`whoami\` name ≠ the council's \`steward\`, stop.
256
256
  2. **Plan the round** from the target doc + prior rounds. Round 1 must include a
257
- completeness critic — assign one member the explicit charge: "What important
258
- thing is NOT in this document at all — a missing dimension, not a flaw in what's
259
- written?" Lens-scoped reviewers reliably miss whole absent dimensions.
257
+ completeness critic. Assign one member the explicit charge: "What important
258
+ thing is NOT in this document at all (a missing dimension, not a flaw in what's
259
+ written)?" Lens-scoped reviewers reliably miss whole absent dimensions.
260
260
  3. **Draft + write** one prompt per member missing from \`current_round_prompts\`:
261
261
 
262
262
  \`\`\`bash
263
263
  ${b} council prompt <id> agent-<Name> --message "..." # or --file <path>
264
264
  \`\`\`
265
265
 
266
- The CLI auto-prepends the \`<!-- council-route … -->\` header — never write it by
266
+ The CLI auto-prepends the \`<!-- council-route … -->\` header; never write it by
267
267
  hand. Every prompt must instruct the member to end with the literal
268
268
  \`<substantive>\` / \`<trivial>\` tag.
269
269
 
@@ -280,7 +280,7 @@ export const SKILLS = [
280
280
  relPath: "harn-decide/SKILL.md",
281
281
  render: (binName) => buildOwnedSkill({
282
282
  name: "harn-decide",
283
- description: "File a decision into the docket instead of blocking on a human — search precedent, file it, and proceed on a reversible default; or pick up and resolve an open decision with cited evidence. Use whenever you're about to ask a human a decision-shaped question you could resolve yourself.",
283
+ description: "File a decision into the docket instead of blocking on a human: search precedent, file it, and proceed on a reversible default; or pick up and resolve an open decision with cited evidence. Use whenever you're about to ask a human a decision-shaped question you could resolve yourself.",
284
284
  argumentHint: "[<the decision / question you're facing> | resolve <id> | review]",
285
285
  binName,
286
286
  body: decideBody(binName),
@@ -291,7 +291,7 @@ export const SKILLS = [
291
291
  relPath: "harn-council/SKILL.md",
292
292
  render: (binName) => buildOwnedSkill({
293
293
  name: "harn-council",
294
- description: "Interact with the multi-agent council system: list / create / show / prompts (steward) / contribute (member). Guards against misrouting — refuses to contribute when you aren't a member, have already contributed, or weren't routed a prompt.",
294
+ description: "Interact with the multi-agent council system: list / create / show / prompts (steward) / contribute (member). Guards against misrouting: refuses to contribute when you aren't a member, have already contributed, or weren't routed a prompt.",
295
295
  argumentHint: "[<id-or-fragment> | create <objective> | contribute <id> | prompts <id> | show <id>]",
296
296
  binName,
297
297
  body: councilBody(binName),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.31.2",
3
+ "version": "0.31.4",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -164,6 +164,48 @@ async function handleProject(root: string, rest: string[]): Promise<number> {
164
164
  return 0;
165
165
  }
166
166
 
167
+ function adapterFromPlatform(platform: unknown): "claude-code" | "cursor" | "codex" {
168
+ if (platform === "cursor") return "cursor";
169
+ if (platform === "codex") return "codex";
170
+ return "claude-code";
171
+ }
172
+
173
+ /**
174
+ * Append a canonical `health.heartbeat_swept` event after an operator kill.
175
+ * Same envelope stale-sweep emits, so the projector's terminal / ended_at
176
+ * guards treat kill and sweep identically. Soft-fails: the unlink already
177
+ * happened.
178
+ */
179
+ async function emitHeartbeatSwept(
180
+ root: string,
181
+ owner: string,
182
+ hb: { session_id?: string; platform?: string; last_heartbeat?: string },
183
+ ): Promise<void> {
184
+ try {
185
+ const { emit } = await import("./events/emit.ts");
186
+ let ageSecs: number | undefined;
187
+ if (hb.last_heartbeat) {
188
+ const ts = Date.parse(hb.last_heartbeat);
189
+ if (Number.isFinite(ts)) {
190
+ ageSecs = Math.max(0, Math.floor((Date.now() - ts) / 1000));
191
+ }
192
+ }
193
+ emit(root, {
194
+ event_type: "health.heartbeat_swept",
195
+ instance_id: owner,
196
+ session_id: hb.session_id ?? owner,
197
+ adapter: adapterFromPlatform(hb.platform),
198
+ source: "agent-coord",
199
+ data: {
200
+ reason: "killed",
201
+ ...(ageSecs !== undefined ? { age_secs: ageSecs } : {}),
202
+ },
203
+ });
204
+ } catch {
205
+ /* soft-fail: never break the caller */
206
+ }
207
+ }
208
+
167
209
  /**
168
210
  * Append a canonical `claim.release` event for a path dropped from an owner's
169
211
  * files_touched. The path is canonicalized to repo-relative (matching the
@@ -181,14 +223,11 @@ async function emitClaimRelease(
181
223
  try {
182
224
  const { emit } = await import("./events/emit.ts");
183
225
  const canonical = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
184
- const platform = hb.platform;
185
- const adapter =
186
- platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code";
187
226
  emit(root, {
188
227
  event_type: "claim.release",
189
228
  instance_id: owner,
190
229
  session_id: hb.session_id ?? owner,
191
- adapter,
230
+ adapter: adapterFromPlatform(hb.platform),
192
231
  source: "agent-coord",
193
232
  data: { path: canonical, reason },
194
233
  });
@@ -269,9 +308,17 @@ async function handleStateAction(root: string, action: string, rest: string[]):
269
308
  // killing only the file leaves the claims resurrectable from the
270
309
  // permanent Edit/Write events on the next full replay (observed: a
271
310
  // 6-day-dead agent's claims returning after its heartbeat was killed).
311
+ //
312
+ // Also emit health.heartbeat_swept so the stream records a terminal
313
+ // marker. Without it, a later drain that replays [session.start …
314
+ // tools … claim.release] treats the owner as still in-flight and
315
+ // re-creates the file (kill→resurrect loop observed 2026-08-03: 21
316
+ // killed zombies back within a minute via claim.release seeding +
317
+ // historical replay).
272
318
  const before = writer.readHeartbeat(root, owner);
273
319
  const ok = writer.killHeartbeat(root, owner);
274
320
  if (ok && before) {
321
+ await emitHeartbeatSwept(root, owner, before);
275
322
  for (const held of before.files_touched ?? []) {
276
323
  await emitClaimRelease(root, owner, before, held, "heal");
277
324
  }
@@ -65,30 +65,30 @@ export function projectHeartbeats(
65
65
  ): { written: string[]; perOwner: Record<string, V2Heartbeat> } {
66
66
  const perOwner: Record<string, V2Heartbeat> = {};
67
67
 
68
- // Terminal events for an owner we've never seen must NOT seed a new heartbeat:
69
- // that resurrects a dead agent as a nameless, started_at-less zombie (the
70
- // `agent-unknown (20608d ago)` ghost). It happens when a subagent.stop /
71
- // session.end drains without (or after) its matching start: seed() then
72
- // apply(stop) writes a bare tombstone the sweep+readers then choke on. If
73
- // there's no existing heartbeat and the first event we see for an owner is
74
- // terminal, skip it entirely.
68
+ // Events that must NOT seed a heartbeat for an owner with no live file.
69
+ // Terminal lifecycle events (session.end / subagent.stop /
70
+ // health.heartbeat_swept) used to be the whole set — a stop without a
71
+ // matching start resurrected a nameless `agent-unknown` tombstone, and a
72
+ // lone health.heartbeat_swept re-created the file stale-sweep had just
73
+ // deleted (self-perpetuating zombie loop, same instance swept 18×).
75
74
  //
76
- // `health.heartbeat_swept` is terminal for the same reason, and was the
77
- // sharper bug: stale-sweep deletes a dead heartbeat then emits this event,
78
- // which the projector replayed to RE-CREATE the very file the sweep just
79
- // removed (minus files_touched, since no start event ever ran for it). The
80
- // reader then flagged it "missing required fields", and the resurrected file,
81
- // carrying a fresh last_heartbeat = the swept-event ts, survived one
82
- // freshness window before the next sweep deleted-and-resurrected it again. A
83
- // self-perpetuating zombie loop (same instance swept 18×). A swept event must
84
- // never seed a heartbeat.
85
- const TERMINAL = new Set(["session.end", "subagent.stop", "health.heartbeat_swept"]);
75
+ // `claim.release` joins them: kill-heartbeat unlinks the file THEN emits
76
+ // claim.release for each held path so replay honors the drop. Projecting
77
+ // those releases alone used to seed a fresh heartbeat (name recovered from
78
+ // .name-history, last_heartbeat = release ts), undoing the kill. Side-effect
79
+ // events must never birth an owner.
80
+ const NEVER_SEED = new Set([
81
+ "session.end",
82
+ "subagent.stop",
83
+ "health.heartbeat_swept",
84
+ "claim.release",
85
+ ]);
86
86
 
87
87
  // Seed from any existing v2 files so a partial replay doesn't reset state.
88
88
  for (const ev of events) {
89
89
  if (!perOwner[ev.instance_id]) {
90
90
  const existing = readExisting(coordRoot, ev.instance_id);
91
- if (!existing && TERMINAL.has(ev.event_type)) continue;
91
+ if (!existing && NEVER_SEED.has(ev.event_type)) continue;
92
92
  perOwner[ev.instance_id] = existing ?? seed(ev, coordRoot);
93
93
  }
94
94
  apply(perOwner[ev.instance_id]!, ev, coordRoot);
@@ -158,7 +158,20 @@ function seed(ev: CanonicalEvent, coordRoot: string): V2Heartbeat {
158
158
  }
159
159
 
160
160
  function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
161
- hb.last_heartbeat = ev.ts;
161
+ // Sweep telemetry must not refresh liveness. apply() used to stamp
162
+ // last_heartbeat = ev.ts for every event, so a drain that replayed
163
+ // [session.start … tools … health.heartbeat_swept] wrote a heartbeat whose
164
+ // last_heartbeat was the swept ts — looking freshly alive for another
165
+ // freshness window, which the next sweep deleted-and-re-emitted. Record the
166
+ // event for audit (last_event_id / events_applied) but keep the prior
167
+ // liveness stamp; also set ended_at so the mid-batch write guard skips
168
+ // re-creating a file the sweep/kill already removed.
169
+ const isSweepTelemetry = ev.event_type === "health.heartbeat_swept";
170
+ if (!isSweepTelemetry) {
171
+ hb.last_heartbeat = ev.ts;
172
+ } else if (!hb.last_heartbeat) {
173
+ hb.last_heartbeat = ev.ts;
174
+ }
162
175
  hb.last_event_id = ev.event_id;
163
176
  hb.events_applied += 1;
164
177
  hb.v2_meta.last_projected = new Date().toISOString();
@@ -201,6 +214,10 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
201
214
  hb.clean_exit = pickBool(d, "clean_exit");
202
215
  break;
203
216
 
217
+ case "health.heartbeat_swept":
218
+ hb.ended_at = ev.ts;
219
+ break;
220
+
204
221
  case "subagent.start": {
205
222
  const name = pickStr(d, "name");
206
223
  if (name) hb.name = name;
@@ -347,6 +347,61 @@ export function healHeartbeat(
347
347
  return hb;
348
348
  }
349
349
 
350
+ /**
351
+ * Register a workflow child in the coordination layer from the ENGINE side.
352
+ *
353
+ * Previously a spawned child was visible only if its adapter fired Harnery's
354
+ * hooks, which made coordination visibility a property of the vendor CLI
355
+ * rather than of the engine. Headless `codex exec` fires no hooks, so codex
356
+ * children never appeared in `harn agents list` and rendered as "no live
357
+ * session" on the run page while actively working — for the entire length of
358
+ * a multi-minute stage.
359
+ *
360
+ * The engine already knows every fact a heartbeat needs at spawn time, so it
361
+ * writes one itself. Hook-firing adapters keep enriching the same file
362
+ * (last_tool, files_touched); non-hook adapters at least become visible and
363
+ * attributable. Pair with `killHeartbeat` when the child ends.
364
+ *
365
+ * Idempotent: re-registering refreshes in place and preserves `started_at`
366
+ * and any claims a hook already recorded.
367
+ */
368
+ export function registerWorkflowChild(
369
+ coordRoot: string,
370
+ opts: {
371
+ instanceId: string;
372
+ runId: string;
373
+ agentId: string;
374
+ sessionId?: string;
375
+ adapter?: string;
376
+ label?: string;
377
+ model?: string;
378
+ },
379
+ ): Heartbeat {
380
+ const now = nowIsoSeconds();
381
+ const prior = readHeartbeat(coordRoot, opts.instanceId);
382
+ const hb: Heartbeat = {
383
+ ...(prior ?? {}),
384
+ schema_version: 1,
385
+ instance_id: opts.instanceId,
386
+ // The reader keys children by session_id and skips any heartbeat missing
387
+ // one, so it must always be set even before the adapter mints a real id.
388
+ session_id: prior?.session_id ?? opts.sessionId ?? opts.instanceId,
389
+ name: opts.label || opts.agentId,
390
+ kind: "workflow-child",
391
+ agent_id: opts.agentId,
392
+ model: opts.model ?? prior?.model ?? "",
393
+ platform: adapterToPlatform(opts.adapter),
394
+ started_at: prior?.started_at ?? now,
395
+ last_heartbeat: now,
396
+ files_touched: prior?.files_touched ?? [],
397
+ task: opts.label ?? "",
398
+ workflow_run_id: opts.runId,
399
+ workflow_agent_id: opts.agentId,
400
+ };
401
+ atomicWrite(heartbeatPath(coordRoot, opts.instanceId), JSON.stringify(hb, null, 2));
402
+ return hb;
403
+ }
404
+
350
405
  /**
351
406
  * Stamp the heartbeat with the most-recent tool name + target. Written from
352
407
  * the post-tool-use hook.
@@ -419,18 +419,19 @@ export type HealthPidmapHeal = EventEnvelope<
419
419
  >;
420
420
 
421
421
  /**
422
- * A heartbeat file was removed by stale-sweep. Symmetric with
423
- * `health.heartbeat_heal` so the full lifecycle (created → healed → swept) is
424
- * auditable from the event stream alone. Sweeps were silent before, which
425
- * made "why did this agent vanish?" un-answerable without guesswork.
422
+ * A heartbeat file was removed by stale-sweep or an operator kill. Symmetric
423
+ * with `health.heartbeat_heal` so the full lifecycle (created → healed →
424
+ * swept) is auditable from the event stream alone. Sweeps were silent before,
425
+ * which made "why did this agent vanish?" un-answerable without guesswork.
426
426
  * `reason`: "stale" (last_heartbeat past the freshness cutoff) | "unparseable"
427
427
  * (JSON.parse failed AND mtime was old) | "missing_ts" (no last_heartbeat AND
428
- * mtime was old). Fresh-mtime files are never swept regardless of content.
428
+ * mtime was old) | "killed" (operator `kill-heartbeat` / `agents heal --kind
429
+ * kill`). Fresh-mtime files are never auto-swept regardless of content.
429
430
  */
430
431
  export type HealthHeartbeatSwept = EventEnvelope<
431
432
  "health.heartbeat_swept",
432
433
  {
433
- reason: "stale" | "unparseable" | "missing_ts";
434
+ reason: "stale" | "unparseable" | "missing_ts" | "killed";
434
435
  age_secs?: number;
435
436
  }
436
437
  >;
@@ -21,6 +21,7 @@ import { createHash, randomBytes } from "node:crypto";
21
21
  import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
22
22
  import { isAbsolute, join, resolve } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
+ import { killHeartbeat, registerWorkflowChild } from "../agents/state/heartbeat-writer.ts";
24
25
  import { snapshotRepo } from "../context/index.ts";
25
26
  import type {
26
27
  ExternalMutationRequest,
@@ -109,6 +110,17 @@ const DEFAULT_MAX_AGENTS = 50;
109
110
  const DEFAULT_CONCURRENCY = 4;
110
111
  const DEFAULT_MAX_ATTEMPTS = 2;
111
112
  const DEFAULT_TIMEOUT_MS = 300_000;
113
+
114
+ /**
115
+ * Deterministic coordination identity for a spawned child.
116
+ *
117
+ * Stable per (run, agent) so a re-register refreshes one heartbeat instead of
118
+ * accumulating duplicates, and so an orphan left by a killed engine is
119
+ * greppable straight back to the run that owned it.
120
+ */
121
+ function childInstanceId(runId: string, agentId: string): string {
122
+ return `${runId}-${agentId}`;
123
+ }
112
124
  const DEFAULT_MAX_TURNS = 25;
113
125
  const DEFAULT_POLICY_ASK_TIMEOUT_MS = 60_000;
114
126
  const MAX_POLICY_DECISIONS = 50;
@@ -935,6 +947,24 @@ async function executeWorkflow(
935
947
  });
936
948
  log(`[${name}] ${currentStage || "(no stage)"} → ${id} [${adapter}] ${label}`);
937
949
 
950
+ // Register the child ourselves rather than trusting its adapter to fire
951
+ // Harnery's hooks. Headless `codex exec` fires none, so codex children
952
+ // were invisible to `harn agents list` and the run page for the whole
953
+ // duration of a stage. The engine knows everything the heartbeat needs.
954
+ // Best-effort: coordination visibility must never fail a run.
955
+ try {
956
+ registerWorkflowChild(opts.coordRoot, {
957
+ instanceId: childInstanceId(runId, id),
958
+ runId,
959
+ agentId: id,
960
+ adapter,
961
+ label,
962
+ model: agentOpts.model,
963
+ });
964
+ } catch {
965
+ /* visibility is not worth failing a spawn over */
966
+ }
967
+
938
968
  let attemptPrompt = dispatchPrompt;
939
969
  let last: SpawnResult | null = null;
940
970
  let agentCostUsd = 0;
@@ -1047,6 +1077,14 @@ async function executeWorkflow(
1047
1077
  throw error;
1048
1078
  } finally {
1049
1079
  reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
1080
+ // Deregister on every exit path, including throw. A child heartbeat that
1081
+ // outlives its process reads as a live agent forever and blocks peers on
1082
+ // its stale claims. The transcript remains the durable record.
1083
+ try {
1084
+ killHeartbeat(opts.coordRoot, childInstanceId(runId, id));
1085
+ } catch {
1086
+ /* already gone, or swept */
1087
+ }
1050
1088
  release();
1051
1089
  }
1052
1090
  };
@@ -173,7 +173,12 @@ export function createEvidenceRecord(input: {
173
173
  recorded_at: input.recordedAt ?? new Date().toISOString(),
174
174
  kind: enumValue(value.kind, EVIDENCE_KINDS, "evidence kind"),
175
175
  status: enumValue(value.status, ["passed", "failed", "observed", "unknown"], "evidence status"),
176
- label: boundedRequired(value.label, "evidence label", MAX_LABEL_CHARS),
176
+ // Truncate rather than throw. A label is a display string; the substance
177
+ // lives in `summary` and `ref`. Throwing here discards the whole run at
178
+ // the point evidence is recorded, which is the END of the work — a real
179
+ // three-agent review was lost because its label ran 31 characters long.
180
+ // Same posture the transcript writer already takes: shrink and say so.
181
+ label: truncatedRequired(value.label, "evidence label", MAX_LABEL_CHARS),
177
182
  summary: boundedOptional(value.summary, "evidence summary", MAX_SUMMARY_CHARS),
178
183
  ref: boundedOptional(value.ref, "evidence ref", MAX_REF_CHARS),
179
184
  stage: boundedOptional(input.stage, "evidence stage", MAX_LABEL_CHARS),
@@ -584,6 +589,23 @@ function boundedRequired(value: unknown, field: string, max: number): string {
584
589
  return normalized;
585
590
  }
586
591
 
592
+ /**
593
+ * Like `boundedRequired`, but shortens an over-long value instead of throwing.
594
+ *
595
+ * For fields where the string is a display label rather than load-bearing
596
+ * content: losing the tail of a label is trivial, losing the run that produced
597
+ * it is not. The marker keeps the truncation visible so a reader never mistakes
598
+ * a shortened label for the whole thing. Still throws when the value is missing
599
+ * or blank — that is a caller bug, not an overflow.
600
+ */
601
+ function truncatedRequired(value: unknown, field: string, max: number): string {
602
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${field} is required`);
603
+ const normalized = value.trim();
604
+ if (normalized.length <= max) return normalized;
605
+ const marker = "…[truncated]";
606
+ return `${normalized.slice(0, Math.max(0, max - marker.length))}${marker}`;
607
+ }
608
+
587
609
  function boundedOptional(value: unknown, field: string, max: number): string | undefined {
588
610
  if (value === undefined) return undefined;
589
611
  if (typeof value !== "string") throw new Error(`${field} must be a string`);
@@ -81,6 +81,12 @@ export interface BrowserOptions {
81
81
  viewport?: { width: number; height: number };
82
82
  /** Default navigation timeout in ms. Default 30000. */
83
83
  navigationTimeout?: number;
84
+ /**
85
+ * Max ms to wait for Chromium to start (`launchPersistentContext` timeout).
86
+ * When unset, Playwright's default (30s) applies. Tests that need a faster
87
+ * fail→retry loop should set this explicitly (e.g. 10_000).
88
+ */
89
+ launchTimeout?: number;
84
90
  /**
85
91
  * `wait_until` strategy for `navigate`. Default `"load"`.
86
92
  * Use `"domcontentloaded"` for sites with long-running analytics scripts
@@ -213,6 +219,7 @@ export class Browser {
213
219
  this.context = await chromium.launchPersistentContext(this.profileDir, {
214
220
  headless: !this.opts.headed,
215
221
  viewport: this.opts.viewport ?? { width: 1280, height: 800 },
222
+ ...(this.opts.launchTimeout !== undefined ? { timeout: this.opts.launchTimeout } : {}),
216
223
  ...(this.opts.launchArgs && this.opts.launchArgs.length > 0
217
224
  ? { args: this.opts.launchArgs }
218
225
  : {}),
@@ -14,7 +14,7 @@
14
14
  * 2. A **fully-owned file** harnery creates whole (a shipped skill's
15
15
  * `SKILL.md`), carrying an ownership header comment so `deinit` deletes
16
16
  * only files harnery generated and `--check` flags a hand-edit:
17
- * <!-- harnery:generated <name> v=<hash> — machine-owned … -->
17
+ * <!-- harnery:generated <name> v=<hash>; machine-owned … -->
18
18
  *
19
19
  * Modeled on the first host's HTML-theme splicer (regenerate + byte-compare,
20
20
  * sha256-8 hash, content outside the region untouchable). Pure (no fs) so it's
@@ -133,7 +133,7 @@ export function buildOwnedSkill(opts: {
133
133
  ].join("\n");
134
134
  const body = opts.body.trim();
135
135
  const marker =
136
- `<!-- harnery:generated ${opts.name} v=${shortHash(body)} — machine-owned; ` +
136
+ `<!-- harnery:generated ${opts.name} v=${shortHash(body)}; machine-owned; ` +
137
137
  `regenerated by \`${opts.binName} init\`, removed by \`${opts.binName} deinit\`. ` +
138
138
  `Edit the harnery template, not this file. -->`;
139
139
  return `${fm}\n${marker}\n\n${body}\n`;