harnery 0.7.0 → 0.7.1

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.
@@ -150,6 +150,32 @@ async function handleProject(root, rest) {
150
150
  }
151
151
  return 0;
152
152
  }
153
+ /**
154
+ * Append a canonical `claim.release` event for a path dropped from an owner's
155
+ * files_touched. The path is canonicalized to repo-relative (matching the
156
+ * projector's normalization) so the subtraction matches on replay regardless
157
+ * of the form the caller passed. Soft-fails: a failed emit must never break
158
+ * the release/kill flow — the file mutation already happened.
159
+ */
160
+ async function emitClaimRelease(root, owner, hb, path, reason) {
161
+ try {
162
+ const { emit } = await import("./events/emit.js");
163
+ const canonical = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
164
+ const platform = hb.platform;
165
+ const harness = platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code";
166
+ emit(root, {
167
+ event_type: "claim.release",
168
+ instance_id: owner,
169
+ session_id: hb.session_id ?? owner,
170
+ harness,
171
+ source: "agent-coord",
172
+ data: { path: canonical, reason },
173
+ });
174
+ }
175
+ catch {
176
+ /* soft-fail: never break the caller */
177
+ }
178
+ }
153
179
  async function handleStateAction(root, action, rest) {
154
180
  const writer = await import("./state/heartbeat-writer.js");
155
181
  const [owner, ...args] = rest;
@@ -189,14 +215,36 @@ async function handleStateAction(root, action, rest) {
189
215
  process.stderr.write("agent-coord release-claim: missing <path>\n");
190
216
  return 2;
191
217
  }
218
+ const before = writer.readHeartbeat(root, owner);
192
219
  const hb = writer.releaseClaim(root, owner, path);
193
220
  if (!hb)
194
221
  return 1;
222
+ // Durability: the projector rebuilds files_touched by replaying the
223
+ // permanent Edit/Write events, so a file-only release is silently
224
+ // reverted by the next full replay. Emitting claim.release puts the
225
+ // subtraction into the stream so every future replay honors it. Only
226
+ // emit when the release actually removed a held path (idempotent
227
+ // re-releases stay quiet).
228
+ const heldBefore = before?.files_touched?.length ?? 0;
229
+ const heldAfter = hb.files_touched?.length ?? 0;
230
+ if (heldBefore > heldAfter) {
231
+ await emitClaimRelease(root, owner, before ?? hb, path, "explicit");
232
+ }
195
233
  process.stdout.write(`${JSON.stringify({ instance_id: owner, files_touched: hb.files_touched })}\n`);
196
234
  return 0;
197
235
  }
198
236
  case "kill-heartbeat": {
237
+ // Read held claims BEFORE the unlink so they can be released durably —
238
+ // killing only the file leaves the claims resurrectable from the
239
+ // permanent Edit/Write events on the next full replay (observed: a
240
+ // 6-day-dead agent's claims returning after its heartbeat was killed).
241
+ const before = writer.readHeartbeat(root, owner);
199
242
  const ok = writer.killHeartbeat(root, owner);
243
+ if (ok && before) {
244
+ for (const held of before.files_touched ?? []) {
245
+ await emitClaimRelease(root, owner, before, held, "heal");
246
+ }
247
+ }
200
248
  process.stdout.write(`${JSON.stringify({ instance_id: owner, removed: ok })}\n`);
201
249
  return ok ? 0 : 1;
202
250
  }
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat-projector.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,oCAAoC;IACpC,OAAO,EAAE;QACP,cAAc,EAAE,CAAC,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAE,CAoD9D;AA8VD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3E"}
1
+ {"version":3,"file":"heartbeat-projector.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,oCAAoC;IACpC,OAAO,EAAE;QACP,cAAc,EAAE,CAAC,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAE,CAoD9D;AAsWD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3E"}
@@ -38,7 +38,7 @@ export function projectHeartbeats(coordRoot, events) {
38
38
  continue;
39
39
  perOwner[ev.instance_id] = existing ?? seed(ev, coordRoot);
40
40
  }
41
- apply(perOwner[ev.instance_id], ev);
41
+ apply(perOwner[ev.instance_id], ev, coordRoot);
42
42
  }
43
43
  const written = [];
44
44
  for (const [instance_id, hb] of Object.entries(perOwner)) {
@@ -101,7 +101,7 @@ function seed(ev, coordRoot) {
101
101
  }
102
102
  return hb;
103
103
  }
104
- function apply(hb, ev) {
104
+ function apply(hb, ev, coordRoot) {
105
105
  hb.last_heartbeat = ev.ts;
106
106
  hb.last_event_id = ev.event_id;
107
107
  hb.events_applied += 1;
@@ -238,7 +238,14 @@ function apply(hb, ev) {
238
238
  case "claim.release": {
239
239
  const path = pickStr(d, "path");
240
240
  if (path && hb.files_touched) {
241
- hb.files_touched = hb.files_touched.filter((p) => p !== path);
241
+ // files_touched holds a mix of absolute-under-coordRoot and canonical
242
+ // repo-relative entries (Edit events report absolute; release-claim
243
+ // canonicalizes to relative). Normalize both sides so a release
244
+ // subtracts regardless of form — an exact-string compare silently
245
+ // no-ops on the mismatch and the claim resurrects on the next replay.
246
+ const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
247
+ const target = norm(path);
248
+ hb.files_touched = hb.files_touched.filter((p) => norm(p) !== target);
242
249
  }
243
250
  break;
244
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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,39 @@ async function handleProject(root: string, rest: string[]): Promise<number> {
164
164
  return 0;
165
165
  }
166
166
 
167
+ /**
168
+ * Append a canonical `claim.release` event for a path dropped from an owner's
169
+ * files_touched. The path is canonicalized to repo-relative (matching the
170
+ * projector's normalization) so the subtraction matches on replay regardless
171
+ * of the form the caller passed. Soft-fails: a failed emit must never break
172
+ * the release/kill flow — the file mutation already happened.
173
+ */
174
+ async function emitClaimRelease(
175
+ root: string,
176
+ owner: string,
177
+ hb: { session_id?: string; platform?: string },
178
+ path: string,
179
+ reason: "explicit" | "heal",
180
+ ): Promise<void> {
181
+ try {
182
+ const { emit } = await import("./events/emit.ts");
183
+ const canonical = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
184
+ const platform = hb.platform;
185
+ const harness =
186
+ platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code";
187
+ emit(root, {
188
+ event_type: "claim.release",
189
+ instance_id: owner,
190
+ session_id: hb.session_id ?? owner,
191
+ harness,
192
+ source: "agent-coord",
193
+ data: { path: canonical, reason },
194
+ });
195
+ } catch {
196
+ /* soft-fail: never break the caller */
197
+ }
198
+ }
199
+
167
200
  async function handleStateAction(root: string, action: string, rest: string[]): Promise<number> {
168
201
  const writer = await import("./state/heartbeat-writer.ts");
169
202
  const [owner, ...args] = rest;
@@ -210,15 +243,37 @@ async function handleStateAction(root: string, action: string, rest: string[]):
210
243
  process.stderr.write("agent-coord release-claim: missing <path>\n");
211
244
  return 2;
212
245
  }
246
+ const before = writer.readHeartbeat(root, owner);
213
247
  const hb = writer.releaseClaim(root, owner, path);
214
248
  if (!hb) return 1;
249
+ // Durability: the projector rebuilds files_touched by replaying the
250
+ // permanent Edit/Write events, so a file-only release is silently
251
+ // reverted by the next full replay. Emitting claim.release puts the
252
+ // subtraction into the stream so every future replay honors it. Only
253
+ // emit when the release actually removed a held path (idempotent
254
+ // re-releases stay quiet).
255
+ const heldBefore = before?.files_touched?.length ?? 0;
256
+ const heldAfter = hb.files_touched?.length ?? 0;
257
+ if (heldBefore > heldAfter) {
258
+ await emitClaimRelease(root, owner, before ?? hb, path, "explicit");
259
+ }
215
260
  process.stdout.write(
216
261
  `${JSON.stringify({ instance_id: owner, files_touched: hb.files_touched })}\n`,
217
262
  );
218
263
  return 0;
219
264
  }
220
265
  case "kill-heartbeat": {
266
+ // Read held claims BEFORE the unlink so they can be released durably —
267
+ // killing only the file leaves the claims resurrectable from the
268
+ // permanent Edit/Write events on the next full replay (observed: a
269
+ // 6-day-dead agent's claims returning after its heartbeat was killed).
270
+ const before = writer.readHeartbeat(root, owner);
221
271
  const ok = writer.killHeartbeat(root, owner);
272
+ if (ok && before) {
273
+ for (const held of before.files_touched ?? []) {
274
+ await emitClaimRelease(root, owner, before, held, "heal");
275
+ }
276
+ }
222
277
  process.stdout.write(`${JSON.stringify({ instance_id: owner, removed: ok })}\n`);
223
278
  return ok ? 0 : 1;
224
279
  }
@@ -88,7 +88,7 @@ export function projectHeartbeats(
88
88
  if (!existing && TERMINAL.has(ev.event_type)) continue;
89
89
  perOwner[ev.instance_id] = existing ?? seed(ev, coordRoot);
90
90
  }
91
- apply(perOwner[ev.instance_id]!, ev);
91
+ apply(perOwner[ev.instance_id]!, ev, coordRoot);
92
92
  }
93
93
 
94
94
  const written: string[] = [];
@@ -153,7 +153,7 @@ function seed(ev: CanonicalEvent, coordRoot: string): V2Heartbeat {
153
153
  return hb;
154
154
  }
155
155
 
156
- function apply(hb: V2Heartbeat, ev: CanonicalEvent): void {
156
+ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
157
157
  hb.last_heartbeat = ev.ts;
158
158
  hb.last_event_id = ev.event_id;
159
159
  hb.events_applied += 1;
@@ -285,7 +285,15 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent): void {
285
285
  case "claim.release": {
286
286
  const path = pickStr(d, "path");
287
287
  if (path && hb.files_touched) {
288
- hb.files_touched = hb.files_touched.filter((p) => p !== path);
288
+ // files_touched holds a mix of absolute-under-coordRoot and canonical
289
+ // repo-relative entries (Edit events report absolute; release-claim
290
+ // canonicalizes to relative). Normalize both sides so a release
291
+ // subtracts regardless of form — an exact-string compare silently
292
+ // no-ops on the mismatch and the claim resurrects on the next replay.
293
+ const norm = (p: string): string =>
294
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
295
+ const target = norm(path);
296
+ hb.files_touched = hb.files_touched.filter((p) => norm(p) !== target);
289
297
  }
290
298
  break;
291
299
  }