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.
package/dist/core/agents/cli.js
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
package/src/core/agents/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|