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.
- package/README.md +16 -16
- package/dist/core/agents/cli.js +48 -3
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +36 -19
- package/dist/core/agents/state/heartbeat-writer.d.ts +27 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +43 -0
- package/dist/core/hooks/events/schema.d.ts +7 -6
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +38 -0
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +24 -1
- package/dist/lib/browser/client.d.ts +6 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +1 -0
- package/dist/lib/instructions/splice.d.ts +1 -1
- package/dist/lib/instructions/splice.js +2 -2
- package/dist/lib/instructions/templates.js +18 -18
- package/package.json +1 -1
- package/src/core/agents/cli.ts +51 -4
- package/src/core/agents/state/heartbeat-projector.ts +36 -19
- package/src/core/agents/state/heartbeat-writer.ts +55 -0
- package/src/core/hooks/events/schema.ts +7 -6
- package/src/core/workflow/engine.ts +38 -0
- package/src/core/workflow/proof.ts +23 -1
- package/src/lib/browser/client.ts +7 -0
- package/src/lib/instructions/splice.ts +2 -2
- package/src/lib/instructions/templates.ts +18 -18
|
@@ -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
|
|
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)}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>\`)
|
|
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
|
|
232
|
-
router likely meant a different agent's session.
|
|
233
|
-
2. **Already contributed.** If you're in \`current_round_contributors\`, refuse
|
|
234
|
-
|
|
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
|
|
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>\`)
|
|
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
|
|
258
|
-
thing is NOT in this document at all
|
|
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
|
|
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
|
|
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
|
|
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
package/src/core/agents/cli.ts
CHANGED
|
@@ -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
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
-
// `
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
423
|
-
* `health.heartbeat_heal` so the full lifecycle (created → healed →
|
|
424
|
-
* auditable from the event stream alone. Sweeps were silent before,
|
|
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)
|
|
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
|
-
|
|
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
|
|
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)}
|
|
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`;
|