harnery 0.7.1 → 0.9.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.
- package/README.md +2 -1
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/callers.d.ts.map +1 -1
- package/dist/commands/callers.js +69 -17
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +84 -2
- package/dist/commands/grep.d.ts +77 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +598 -109
- package/dist/commands/init.d.ts +15 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +125 -14
- package/dist/commands/workflow.d.ts +4 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +89 -0
- package/dist/core/agents/cli.js +10 -3
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +26 -1
- package/dist/core/agents/rules/stop-hook.d.ts +8 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +8 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +13 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +21 -4
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +38 -0
- package/dist/core/hooks/cli.js +29 -3
- package/dist/core/hooks/events/schema.d.ts +4 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts +7 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +1 -0
- package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +28 -6
- package/dist/core/workflow/billing.d.ts +48 -0
- package/dist/core/workflow/billing.d.ts.map +1 -0
- package/dist/core/workflow/billing.js +102 -0
- package/dist/core/workflow/child-env.d.ts +30 -0
- package/dist/core/workflow/child-env.d.ts.map +1 -0
- package/dist/core/workflow/child-env.js +43 -0
- package/dist/core/workflow/engine.d.ts +21 -0
- package/dist/core/workflow/engine.d.ts.map +1 -0
- package/dist/core/workflow/engine.js +340 -0
- package/dist/core/workflow/harnesses.d.ts +17 -0
- package/dist/core/workflow/harnesses.d.ts.map +1 -0
- package/dist/core/workflow/harnesses.js +30 -0
- package/dist/core/workflow/spawn-claude.d.ts +22 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
- package/dist/core/workflow/spawn-claude.js +82 -0
- package/dist/core/workflow/spawn-codex.d.ts +19 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
- package/dist/core/workflow/spawn-codex.js +66 -0
- package/dist/core/workflow/spawn-cursor.d.ts +26 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
- package/dist/core/workflow/spawn-cursor.js +72 -0
- package/dist/core/workflow/types.d.ts +145 -0
- package/dist/core/workflow/types.d.ts.map +1 -0
- package/dist/core/workflow/types.js +9 -0
- package/dist/core/workflow/validate.d.ts +15 -0
- package/dist/core/workflow/validate.d.ts.map +1 -0
- package/dist/core/workflow/validate.js +70 -0
- package/dist/lib/tools/ripgrep.d.ts +61 -0
- package/dist/lib/tools/ripgrep.d.ts.map +1 -0
- package/dist/lib/tools/ripgrep.js +217 -0
- package/package.json +1 -1
- package/src/commander.ts +11 -0
- package/src/commands/callers.ts +90 -26
- package/src/commands/doctor.ts +95 -2
- package/src/commands/grep.ts +737 -113
- package/src/commands/init.ts +138 -17
- package/src/commands/workflow.ts +132 -0
- package/src/core/agents/cli.ts +11 -4
- package/src/core/agents/rules/claim-conflict.ts +26 -1
- package/src/core/agents/rules/stop-hook.ts +17 -0
- package/src/core/agents/state/heartbeat-projector.ts +13 -1
- package/src/core/agents/state/heartbeat-writer.ts +30 -5
- package/src/core/config.ts +47 -0
- package/src/core/hooks/cli.ts +30 -3
- package/src/core/hooks/events/schema.ts +4 -0
- package/src/core/hooks/harness/events.ts +8 -0
- package/src/core/hooks/resolve/coord-root.ts +28 -6
- package/src/core/workflow/billing.ts +146 -0
- package/src/core/workflow/child-env.ts +47 -0
- package/src/core/workflow/engine.ts +394 -0
- package/src/core/workflow/harnesses.ts +38 -0
- package/src/core/workflow/spawn-claude.ts +99 -0
- package/src/core/workflow/spawn-codex.ts +74 -0
- package/src/core/workflow/spawn-cursor.ts +89 -0
- package/src/core/workflow/types.ts +153 -0
- package/src/core/workflow/validate.ts +75 -0
- package/src/lib/tools/ripgrep.ts +244 -0
package/dist/commands/init.d.ts
CHANGED
|
@@ -23,11 +23,12 @@ export type { HookGroup, SettingsFile };
|
|
|
23
23
|
export declare function registerInitCommand(program: Command, emit: EmitContext, binName?: string): void;
|
|
24
24
|
/**
|
|
25
25
|
* Merge agent-hook entries into a harness settings object in place, idempotently.
|
|
26
|
-
* Preserves every existing hook;
|
|
27
|
-
* `agent-hook <subcommand
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* Preserves every existing hook; rewrites events already wired to
|
|
27
|
+
* `agent-hook <subcommand>` when their command string is stale (e.g. an older
|
|
28
|
+
* harnery wired a bare relative path). Honors the harness's entry shape
|
|
29
|
+
* (Claude/Codex nest under an inner `hooks` array; Cursor uses a flat
|
|
30
|
+
* `{ command }`) and ensures the root `version` key when the harness requires
|
|
31
|
+
* one. Pure (no fs/git) so it's unit-testable.
|
|
31
32
|
*
|
|
32
33
|
* The trailing space in the match (`agent-hook ${subcommand} `) is load-bearing:
|
|
33
34
|
* it keeps `stop` from matching `stop-failure`.
|
|
@@ -36,6 +37,7 @@ export declare function wireHooks(settings: SettingsFile, spec: HarnessSpec, age
|
|
|
36
37
|
wired: number;
|
|
37
38
|
already: number;
|
|
38
39
|
removed: number;
|
|
40
|
+
upgraded: number;
|
|
39
41
|
};
|
|
40
42
|
/**
|
|
41
43
|
* Inverse of {@link wireHooks}: strip every harnery-owned hook entry from a
|
|
@@ -61,4 +63,12 @@ export declare function unwireHooks(settings: SettingsFile): {
|
|
|
61
63
|
* - key absent → splice it in as the first key (comment-safe).
|
|
62
64
|
*/
|
|
63
65
|
export declare function stampBinName(configPath: string, binName: string, dryRun: boolean): string | null;
|
|
66
|
+
/**
|
|
67
|
+
* Idempotently pin `workflow.subscriptionOnly: true` in `.harnery/config.jsonc`
|
|
68
|
+
* (see `workflowSubscriptionOnly()` in core/config.ts and the `workflow run`
|
|
69
|
+
* billing safeguards). Same comment-preserving discipline as `stampBinName`.
|
|
70
|
+
* A `workflow` key of ANY shape (including `subscriptionOnly: false`) is a
|
|
71
|
+
* deliberate project choice and is left alone.
|
|
72
|
+
*/
|
|
73
|
+
export declare function stampWorkflowDefaults(configPath: string, dryRun: boolean): string | null;
|
|
64
74
|
//# sourceMappingURL=init.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,OAAO,EAAiB,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAClG,OAAO,EAGL,KAAK,SAAS,EAEd,KAAK,YAAY,EAClB,MAAM,iCAAiC,CAAC;AAKzC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AAYxC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,OAAO,EAAiB,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAClG,OAAO,EAGL,KAAK,SAAS,EAEd,KAAK,YAAY,EAClB,MAAM,iCAAiC,CAAC;AAKzC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AAYxC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAiI/F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,WAAW,EACjB,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,SAAS,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAqCvE;AAyDD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAmB1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAmDhG;AASD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAkCxF"}
|
package/dist/commands/init.js
CHANGED
|
@@ -92,6 +92,17 @@ export function registerInitCommand(program, emit, binName) {
|
|
|
92
92
|
if (stamp)
|
|
93
93
|
actions.push(stamp);
|
|
94
94
|
}
|
|
95
|
+
// ── 1c. workflow billing default ───────────────────────────────────────
|
|
96
|
+
// Every init'd project gets `workflow.subscriptionOnly: true`: workflow
|
|
97
|
+
// children ride the logged-in (subscription) harness auth, and the pin
|
|
98
|
+
// makes per-token API billing structurally impossible unless the project
|
|
99
|
+
// deliberately flips it. A committed `workflow` key of any shape is a
|
|
100
|
+
// deliberate choice and is never touched.
|
|
101
|
+
{
|
|
102
|
+
const stamp = stampWorkflowDefaults(resolve(coordDir, "config.jsonc"), dryRun);
|
|
103
|
+
if (stamp)
|
|
104
|
+
actions.push(stamp);
|
|
105
|
+
}
|
|
95
106
|
// ── 2. harness hooks ───────────────────────────────────────────────────
|
|
96
107
|
const settingsPath = resolve(projectRoot, spec.settingsFile);
|
|
97
108
|
const agentHook = relative(projectRoot, resolve(HARNERY_ROOT, "bin", "agent-hook"));
|
|
@@ -110,19 +121,19 @@ export function registerInitCommand(program, emit, binName) {
|
|
|
110
121
|
else {
|
|
111
122
|
settings = {};
|
|
112
123
|
}
|
|
113
|
-
const { wired, already, removed } = wireHooks(settings, spec, agentHook, harness);
|
|
114
|
-
if (wired === 0 && removed === 0) {
|
|
124
|
+
const { wired, already, removed, upgraded } = wireHooks(settings, spec, agentHook, harness);
|
|
125
|
+
if (wired === 0 && removed === 0 && upgraded === 0) {
|
|
115
126
|
actions.push(`· all ${spec.events.length} ${harness} hooks already wired in ${rel(projectRoot, settingsPath)}`);
|
|
116
127
|
}
|
|
117
128
|
else if (dryRun) {
|
|
118
|
-
actions.push(`+ would wire ${wired} hook(s)
|
|
119
|
-
`${rel(projectRoot, settingsPath)} (${already} already present)`);
|
|
129
|
+
actions.push(`+ would wire ${wired} hook(s), upgrade ${upgraded} stale command(s), and remove ` +
|
|
130
|
+
`${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`);
|
|
120
131
|
}
|
|
121
132
|
else {
|
|
122
133
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
123
134
|
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
124
|
-
actions.push(`+ wired ${wired} hook(s)
|
|
125
|
-
`${rel(projectRoot, settingsPath)} (${already} already present)`);
|
|
135
|
+
actions.push(`+ wired ${wired} hook(s), upgraded ${upgraded} stale command(s), and removed ` +
|
|
136
|
+
`${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`);
|
|
126
137
|
}
|
|
127
138
|
// ── 3. agent-facing instructions block + skills ────────────────────────
|
|
128
139
|
const applied = applyInstructions(projectRoot, { binName: bin, harness, dryRun });
|
|
@@ -132,11 +143,12 @@ export function registerInitCommand(program, emit, binName) {
|
|
|
132
143
|
}
|
|
133
144
|
/**
|
|
134
145
|
* Merge agent-hook entries into a harness settings object in place, idempotently.
|
|
135
|
-
* Preserves every existing hook;
|
|
136
|
-
* `agent-hook <subcommand
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
146
|
+
* Preserves every existing hook; rewrites events already wired to
|
|
147
|
+
* `agent-hook <subcommand>` when their command string is stale (e.g. an older
|
|
148
|
+
* harnery wired a bare relative path). Honors the harness's entry shape
|
|
149
|
+
* (Claude/Codex nest under an inner `hooks` array; Cursor uses a flat
|
|
150
|
+
* `{ command }`) and ensures the root `version` key when the harness requires
|
|
151
|
+
* one. Pure (no fs/git) so it's unit-testable.
|
|
140
152
|
*
|
|
141
153
|
* The trailing space in the match (`agent-hook ${subcommand} `) is load-bearing:
|
|
142
154
|
* it keeps `stop` from matching `stop-failure`.
|
|
@@ -150,6 +162,7 @@ export function wireHooks(settings, spec, agentHookPath, harness) {
|
|
|
150
162
|
let wired = 0;
|
|
151
163
|
let already = 0;
|
|
152
164
|
let removed = 0;
|
|
165
|
+
let upgraded = 0;
|
|
153
166
|
for (const { settingsKey, subcommand } of spec.legacyEvents ?? []) {
|
|
154
167
|
const groups = settings.hooks[settingsKey] ?? [];
|
|
155
168
|
const kept = groups.filter((group) => !groupCommands(group).some((command) => commandWiresSubcommand(command, subcommand)));
|
|
@@ -160,9 +173,14 @@ export function wireHooks(settings, spec, agentHookPath, harness) {
|
|
|
160
173
|
settings.hooks[settingsKey] = kept;
|
|
161
174
|
}
|
|
162
175
|
for (const { settingsKey, subcommand } of spec.events) {
|
|
163
|
-
const command =
|
|
176
|
+
const command = hookCommand(spec, agentHookPath, subcommand, harness);
|
|
164
177
|
const groups = settings.hooks[settingsKey] ?? [];
|
|
165
|
-
|
|
178
|
+
let present = false;
|
|
179
|
+
for (const group of groups) {
|
|
180
|
+
upgraded += rewriteStaleCommands(group, subcommand, command, () => {
|
|
181
|
+
present = true;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
166
184
|
if (present) {
|
|
167
185
|
already++;
|
|
168
186
|
continue;
|
|
@@ -171,7 +189,52 @@ export function wireHooks(settings, spec, agentHookPath, harness) {
|
|
|
171
189
|
settings.hooks[settingsKey] = groups;
|
|
172
190
|
wired++;
|
|
173
191
|
}
|
|
174
|
-
return { wired, already, removed };
|
|
192
|
+
return { wired, already, removed, upgraded };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The canonical hook command for one event. When the harness exports a
|
|
196
|
+
* project-dir env var to hook processes (Claude Code's CLAUDE_PROJECT_DIR),
|
|
197
|
+
* anchor the agent-hook path on it: hook processes inherit the session
|
|
198
|
+
* shell's cwd, which follows `cd` away from the project root — a bare
|
|
199
|
+
* relative path silently fails to spawn from there (no events, no image
|
|
200
|
+
* capture, no guards). `:-.` keeps the command working on harness versions
|
|
201
|
+
* that don't set the var. Only the env expansion is quoted so the
|
|
202
|
+
* `agent-hook <subcommand> ` wiring match stays byte-identical.
|
|
203
|
+
*/
|
|
204
|
+
function hookCommand(spec, agentHookPath, subcommand, harness) {
|
|
205
|
+
const anchor = spec.projectDirEnv ? `"\${${spec.projectDirEnv}:-.}"/` : "";
|
|
206
|
+
return `bash ${anchor}${agentHookPath} ${subcommand} --harness ${harness}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Rewrite any harnery-owned command for `subcommand` inside one hook group to
|
|
210
|
+
* the canonical form, in place. Returns the number of commands rewritten and
|
|
211
|
+
* calls `onPresent` when the subcommand is wired in this group at all.
|
|
212
|
+
*/
|
|
213
|
+
function rewriteStaleCommands(group, subcommand, canonical, onPresent) {
|
|
214
|
+
let rewritten = 0;
|
|
215
|
+
if ("command" in group && typeof group.command === "string") {
|
|
216
|
+
if (commandWiresSubcommand(group.command, subcommand)) {
|
|
217
|
+
onPresent();
|
|
218
|
+
if (group.command !== canonical) {
|
|
219
|
+
group.command = canonical;
|
|
220
|
+
rewritten++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if ("hooks" in group && Array.isArray(group.hooks)) {
|
|
225
|
+
for (const hook of group.hooks) {
|
|
226
|
+
if (typeof hook.command !== "string")
|
|
227
|
+
continue;
|
|
228
|
+
if (!commandWiresSubcommand(hook.command, subcommand))
|
|
229
|
+
continue;
|
|
230
|
+
onPresent();
|
|
231
|
+
if (hook.command !== canonical) {
|
|
232
|
+
hook.command = canonical;
|
|
233
|
+
rewritten++;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return rewritten;
|
|
175
238
|
}
|
|
176
239
|
/**
|
|
177
240
|
* Inverse of {@link wireHooks}: strip every harnery-owned hook entry from a
|
|
@@ -270,6 +333,54 @@ export function stampBinName(configPath, binName, dryRun) {
|
|
|
270
333
|
writeFileSync(configPath, next);
|
|
271
334
|
return `+ added binName "${binName}" to ${rel(configPath)}`;
|
|
272
335
|
}
|
|
336
|
+
/** The commented workflow-billing default block init stamps into config.jsonc. */
|
|
337
|
+
const WORKFLOW_DEFAULT_BLOCK = ` // Workflow children ride the logged-in (subscription) harness auth; this pin\n` +
|
|
338
|
+
` // scrubs API-key vars from child envs so runs can never bill per-token rates.\n` +
|
|
339
|
+
` // Key-only hosts (CI): set false, or HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=0.\n` +
|
|
340
|
+
` "workflow": { "subscriptionOnly": true }`;
|
|
341
|
+
/**
|
|
342
|
+
* Idempotently pin `workflow.subscriptionOnly: true` in `.harnery/config.jsonc`
|
|
343
|
+
* (see `workflowSubscriptionOnly()` in core/config.ts and the `workflow run`
|
|
344
|
+
* billing safeguards). Same comment-preserving discipline as `stampBinName`.
|
|
345
|
+
* A `workflow` key of ANY shape (including `subscriptionOnly: false`) is a
|
|
346
|
+
* deliberate project choice and is left alone.
|
|
347
|
+
*/
|
|
348
|
+
export function stampWorkflowDefaults(configPath, dryRun) {
|
|
349
|
+
const rel = (p) => relative(dirname(dirname(configPath)), p) || p;
|
|
350
|
+
if (!existsSync(configPath)) {
|
|
351
|
+
if (dryRun)
|
|
352
|
+
return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
|
|
353
|
+
writeFileSync(configPath, `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`);
|
|
354
|
+
return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
|
|
355
|
+
}
|
|
356
|
+
let raw;
|
|
357
|
+
try {
|
|
358
|
+
raw = readFileSync(configPath, "utf8");
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
let parsed;
|
|
364
|
+
try {
|
|
365
|
+
parsed = JSON.parse(stripJsonComments(raw)) ?? {};
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return `· ${rel(configPath)} isn't valid JSONC; skipped workflow.subscriptionOnly pin`;
|
|
369
|
+
}
|
|
370
|
+
if ("workflow" in parsed)
|
|
371
|
+
return null; // deliberate config; never touch
|
|
372
|
+
const at = firstBraceIndex(raw);
|
|
373
|
+
if (at < 0)
|
|
374
|
+
return `· ${rel(configPath)} has no object literal; skipped workflow pin`;
|
|
375
|
+
const keys = Object.keys(parsed);
|
|
376
|
+
const next = keys.length === 0
|
|
377
|
+
? `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`
|
|
378
|
+
: `${raw.slice(0, at + 1)}\n${WORKFLOW_DEFAULT_BLOCK},${raw.slice(at + 1)}`;
|
|
379
|
+
if (dryRun)
|
|
380
|
+
return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
|
|
381
|
+
writeFileSync(configPath, next);
|
|
382
|
+
return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
|
|
383
|
+
}
|
|
273
384
|
/** Index of the first structural `{`, skipping leading whitespace + comments. */
|
|
274
385
|
function firstBraceIndex(raw) {
|
|
275
386
|
let i = 0;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workflow.d.ts","sourceRoot":"","sources":["../../src/commands/workflow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA2BnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAuGjF"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { workflowSubscriptionOnly } from "../core/config.js";
|
|
2
|
+
import { findCoordRoot } from "../core/hooks/resolve/coord-root.js";
|
|
3
|
+
const HARNESSES = ["claude-code", "codex", "cursor"];
|
|
4
|
+
export function registerWorkflowCommand(program, emit) {
|
|
5
|
+
const workflow = program
|
|
6
|
+
.command("workflow")
|
|
7
|
+
.description("Run bounded, schema-gated multi-subagent workflow scripts.");
|
|
8
|
+
workflow
|
|
9
|
+
.command("run <script>")
|
|
10
|
+
.description("Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
|
|
11
|
+
"Subagents spawn as headless harness-CLI subprocesses, coordination-registered.")
|
|
12
|
+
.option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
|
|
13
|
+
.option("--concurrency <n>", "Concurrent-subagent cap (default 4)")
|
|
14
|
+
.option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
|
|
15
|
+
.option("--harness <name>", `Default harness for agent() calls: ${HARNESSES.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`)
|
|
16
|
+
.option("--resume-from <run-id>", "Reuse completed agent results from a prior run's journal; only changed/failed calls re-run")
|
|
17
|
+
.option("--subscription-only", "Guarantee subscription billing: scrub API-key vars from child envs; fail loud when a " +
|
|
18
|
+
"harness has no stored login (repo default via config.jsonc workflow.subscriptionOnly)")
|
|
19
|
+
.option("--allow-api-billing", "Permit an exported API key to override a stored subscription login (per-token billing); " +
|
|
20
|
+
"without this the engine refuses that silent-override state")
|
|
21
|
+
.option("--json", "Emit the full RunReport as JSON")
|
|
22
|
+
.action(async (script, opts) => {
|
|
23
|
+
const coordRoot = findCoordRoot();
|
|
24
|
+
if (!coordRoot) {
|
|
25
|
+
emit.error({
|
|
26
|
+
code: "no_coord_root",
|
|
27
|
+
message: "no .harnery/ coordination root found; run `init` first",
|
|
28
|
+
});
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
if (opts.harness && !HARNESSES.includes(opts.harness)) {
|
|
32
|
+
emit.error({
|
|
33
|
+
code: "bad_harness",
|
|
34
|
+
message: `unknown harness "${opts.harness}" (expected: ${HARNESSES.join(" | ")})`,
|
|
35
|
+
});
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
// Flag beats config; the two flags contradict each other by design.
|
|
39
|
+
const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
|
|
40
|
+
if (subscriptionOnly && opts.allowApiBilling) {
|
|
41
|
+
emit.error({
|
|
42
|
+
code: "billing_flags_conflict",
|
|
43
|
+
message: "--allow-api-billing contradicts subscription-only mode " +
|
|
44
|
+
"(flag or config.jsonc workflow.subscriptionOnly); pick one",
|
|
45
|
+
});
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const [{ runWorkflow }, { claudeCodeSpawner }, { codexSpawner }, { cursorSpawner }] = await Promise.all([
|
|
50
|
+
import("../core/workflow/engine.js"),
|
|
51
|
+
import("../core/workflow/spawn-claude.js"),
|
|
52
|
+
import("../core/workflow/spawn-codex.js"),
|
|
53
|
+
import("../core/workflow/spawn-cursor.js"),
|
|
54
|
+
]);
|
|
55
|
+
const report = await runWorkflow(script, {
|
|
56
|
+
coordRoot,
|
|
57
|
+
spawners: {
|
|
58
|
+
"claude-code": claudeCodeSpawner,
|
|
59
|
+
codex: codexSpawner,
|
|
60
|
+
cursor: cursorSpawner,
|
|
61
|
+
},
|
|
62
|
+
defaultHarness: opts.harness,
|
|
63
|
+
resumeFrom: opts.resumeFrom,
|
|
64
|
+
subscriptionOnly,
|
|
65
|
+
allowApiBilling: opts.allowApiBilling,
|
|
66
|
+
maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
|
|
67
|
+
concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
|
|
68
|
+
cwd: opts.cwd,
|
|
69
|
+
});
|
|
70
|
+
if (opts.json) {
|
|
71
|
+
emit.config({ format: "json" });
|
|
72
|
+
emit.data(report);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const cachedPart = report.agentsCached > 0 ? ` (+${report.agentsCached} cached)` : "";
|
|
76
|
+
const billingPart = report.billing.length
|
|
77
|
+
? `billing: ${report.billing.map((b) => `${b.harness}=${b.mode}`).join(", ")}\n`
|
|
78
|
+
: "";
|
|
79
|
+
emit.text(`run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
|
|
80
|
+
`$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
|
|
81
|
+
`journal: ${report.journalPath}\n` +
|
|
82
|
+
`result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
emit.error({ code: "workflow_failed", message: err.message });
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
package/dist/core/agents/cli.js
CHANGED
|
@@ -427,11 +427,16 @@ async function handlePostCommit(root) {
|
|
|
427
427
|
}
|
|
428
428
|
const { groupUnclaim } = await import("./state/heartbeat-writer.js");
|
|
429
429
|
// Session-group-wide unclaim. `owner` is the parent's session_id which is
|
|
430
|
-
// also the group key (parent + subagents share session_id).
|
|
430
|
+
// also the group key (parent + subagents share session_id). Each actual
|
|
431
|
+
// removal also emits a durable claim.release event — the projector rebuilds
|
|
432
|
+
// files_touched from the permanent Edit/Write events, so a file-only prune
|
|
433
|
+
// would resurrect on the next replay.
|
|
431
434
|
if (req.owner && Array.isArray(req.prune)) {
|
|
432
435
|
for (const path of req.prune) {
|
|
433
436
|
try {
|
|
434
|
-
groupUnclaim(root, req.owner, path)
|
|
437
|
+
for (const hit of groupUnclaim(root, req.owner, path)) {
|
|
438
|
+
await emitClaimRelease(root, hit.instance_id, hit, path, "commit");
|
|
439
|
+
}
|
|
435
440
|
}
|
|
436
441
|
catch {
|
|
437
442
|
/* best-effort */
|
|
@@ -453,7 +458,9 @@ async function handlePostCheckout(root, _rest) {
|
|
|
453
458
|
if (req.owner && Array.isArray(req.removed)) {
|
|
454
459
|
for (const path of req.removed) {
|
|
455
460
|
try {
|
|
456
|
-
groupUnclaim(root, req.owner, path)
|
|
461
|
+
for (const hit of groupUnclaim(root, req.owner, path)) {
|
|
462
|
+
await emitClaimRelease(root, hit.instance_id, hit, path, "checkout");
|
|
463
|
+
}
|
|
457
464
|
}
|
|
458
465
|
catch {
|
|
459
466
|
/* best-effort */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;
|
|
1
|
+
{"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AASH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAoGjF"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
17
|
import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
+
import { emit } from "../events/emit.js";
|
|
19
20
|
const FRESHNESS_SECS = 600;
|
|
20
21
|
/**
|
|
21
22
|
* Evaluate a file-claim. Returns deny when another fresh peer claims the
|
|
@@ -267,13 +268,37 @@ function pruneClaimFromPeer(coordRoot, instanceId, relPath) {
|
|
|
267
268
|
try {
|
|
268
269
|
const body = JSON.parse(readFileSync(path, "utf8"));
|
|
269
270
|
const files = body.files_touched ?? [];
|
|
270
|
-
|
|
271
|
+
// Normalize both sides: files_touched can hold absolute-under-coordRoot
|
|
272
|
+
// entries (legacy projections) as well as canonical relative ones — an
|
|
273
|
+
// exact-string filter silently no-ops on the mixed-form case and the
|
|
274
|
+
// stale claim never heals.
|
|
275
|
+
const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
|
|
276
|
+
const target = norm(relPath);
|
|
277
|
+
const next = files.filter((p) => norm(p) !== target);
|
|
271
278
|
if (next.length === files.length)
|
|
272
279
|
return;
|
|
273
280
|
body.files_touched = next;
|
|
274
281
|
const tmp = `${path}.tmp.${process.pid}`;
|
|
275
282
|
writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
|
|
276
283
|
renameSync(tmp, path);
|
|
284
|
+
// Durable subtraction: the projector rebuilds files_touched from the
|
|
285
|
+
// permanent Edit/Write events, so a file-only prune resurrects on the
|
|
286
|
+
// next replay. Soft-fail — the heartbeat write already landed.
|
|
287
|
+
try {
|
|
288
|
+
const platform = body.platform;
|
|
289
|
+
emit(coordRoot, {
|
|
290
|
+
event_type: "claim.release",
|
|
291
|
+
instance_id: instanceId,
|
|
292
|
+
session_id: body.session_id ?? instanceId,
|
|
293
|
+
harness: platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code",
|
|
294
|
+
source: "agent-coord",
|
|
295
|
+
data: { path: target, reason: "heal" },
|
|
296
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* soft-fail */
|
|
301
|
+
}
|
|
277
302
|
}
|
|
278
303
|
catch {
|
|
279
304
|
/* silent */
|
|
@@ -34,6 +34,14 @@ export interface StopHookRequest {
|
|
|
34
34
|
};
|
|
35
35
|
/** Bypass switch: operator escape hatch identical to HARNERY_AGENT_COORD_BYPASS_STOP. */
|
|
36
36
|
bypass?: boolean;
|
|
37
|
+
/** Headless child spawned by `harn workflow` (HARNERY_WORKFLOW_CHILD=1).
|
|
38
|
+
* The end-of-turn ritual exists to surface status to a HUMAN reader; a
|
|
39
|
+
* workflow child reports to the engine's journal instead, so the ritual is
|
|
40
|
+
* meaningless there — worse, blocking burns the child's turn budget on
|
|
41
|
+
* re-prompts (observed as error_max_turns in the Phase 1 spike). Exempting
|
|
42
|
+
* here, rather than disabling the child's hooks wholesale, keeps heartbeat +
|
|
43
|
+
* event capture on: the child stays visible to peers and the coord layer. */
|
|
44
|
+
workflow_child?: boolean;
|
|
37
45
|
}
|
|
38
46
|
/**
|
|
39
47
|
* Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAcF,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;4EACwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAcF,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;4EACwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;iFAM6E;IAC7E,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AA+BD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CAoGvF"}
|
|
@@ -33,6 +33,14 @@ export function evaluateStopHook(coordRoot, req) {
|
|
|
33
33
|
reason: "HARNERY_AGENT_COORD_BYPASS_STOP=1",
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
if (req.workflow_child) {
|
|
37
|
+
return {
|
|
38
|
+
allow: true,
|
|
39
|
+
exit_code: 0,
|
|
40
|
+
rule: "stop-hook.workflow_child",
|
|
41
|
+
reason: "HARNERY_WORKFLOW_CHILD=1: headless workflow child; ritual not applicable",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
36
44
|
let events;
|
|
37
45
|
try {
|
|
38
46
|
events = readRecentEvents(coordRoot, RECENT_EVENT_WINDOW_LINES);
|
|
@@ -20,6 +20,8 @@ export interface V2Heartbeat {
|
|
|
20
20
|
platform?: string;
|
|
21
21
|
subagent_call_id?: string;
|
|
22
22
|
parent_session_id?: string;
|
|
23
|
+
/** Set iff this owner is a `workflow run` child (joins to the run journal). */
|
|
24
|
+
workflow_run_id?: string;
|
|
23
25
|
started_at?: string;
|
|
24
26
|
last_heartbeat: string;
|
|
25
27
|
last_tool?: string;
|
|
@@ -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,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,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;AAgXD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3E"}
|
|
@@ -138,6 +138,9 @@ function apply(hb, ev, coordRoot) {
|
|
|
138
138
|
const parentSession = pickStr(d, "parent_session_id");
|
|
139
139
|
if (parentSession)
|
|
140
140
|
hb.parent_session_id = parentSession;
|
|
141
|
+
const workflowRunId = pickStr(d, "workflow_run_id");
|
|
142
|
+
if (workflowRunId)
|
|
143
|
+
hb.workflow_run_id = workflowRunId;
|
|
141
144
|
if (!hb.files_touched)
|
|
142
145
|
hb.files_touched = [];
|
|
143
146
|
}
|
|
@@ -202,10 +205,17 @@ function apply(hb, ev, coordRoot) {
|
|
|
202
205
|
if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
|
|
203
206
|
const target = extractFilePath(d);
|
|
204
207
|
if (target) {
|
|
208
|
+
// Canonicalize to repo-relative before storing: the claim guard
|
|
209
|
+
// writes canonical paths directly, so an absolute entry here would
|
|
210
|
+
// double-count the same file (inflated "N files" display) and
|
|
211
|
+
// defeat exact-match pruning on commit.
|
|
212
|
+
const canonical = target.startsWith(`${coordRoot}/`)
|
|
213
|
+
? target.slice(coordRoot.length + 1)
|
|
214
|
+
: target;
|
|
205
215
|
if (!hb.files_touched)
|
|
206
216
|
hb.files_touched = [];
|
|
207
|
-
if (!hb.files_touched.includes(
|
|
208
|
-
hb.files_touched.push(
|
|
217
|
+
if (!hb.files_touched.includes(canonical))
|
|
218
|
+
hb.files_touched.push(canonical);
|
|
209
219
|
}
|
|
210
220
|
}
|
|
211
221
|
break;
|
|
@@ -391,6 +401,7 @@ function writeHeartbeat(coordRoot, instanceId, hb) {
|
|
|
391
401
|
setIfDefined(merged, "kind", hb.kind);
|
|
392
402
|
setIfDefined(merged, "agent_id", hb.agent_id);
|
|
393
403
|
setIfDefined(merged, "subagent_call_id", hb.subagent_call_id);
|
|
404
|
+
setIfDefined(merged, "workflow_run_id", hb.workflow_run_id);
|
|
394
405
|
setIfDefined(merged, "model", hb.model);
|
|
395
406
|
setIfDefined(merged, "platform", hb.platform);
|
|
396
407
|
setIfDefined(merged, "started_at", hb.started_at);
|
|
@@ -42,17 +42,31 @@ export declare function setTask(coordRoot: string, instanceId: string, task: str
|
|
|
42
42
|
export declare function stampStatusCheck(coordRoot: string, instanceId: string): Heartbeat | null;
|
|
43
43
|
export declare function setTurnSummary(coordRoot: string, instanceId: string, summary: string): Heartbeat | null;
|
|
44
44
|
export declare function releaseClaim(coordRoot: string, instanceId: string, path: string): Heartbeat | null;
|
|
45
|
+
/** A heartbeat that actually dropped a path during a group unclaim. */
|
|
46
|
+
export interface GroupUnclaimHit {
|
|
47
|
+
instance_id: string;
|
|
48
|
+
session_id?: string;
|
|
49
|
+
platform?: string;
|
|
50
|
+
}
|
|
45
51
|
/**
|
|
46
52
|
* Session-group-wide unclaim. Walks every heartbeat sharing `groupId`
|
|
47
53
|
* (parent's session_id == group_id;
|
|
48
54
|
* subagents inherit it) and removes the path from each one's files_touched.
|
|
49
|
-
* Idempotent: heartbeats that don't hold the path are untouched.
|
|
55
|
+
* Idempotent: heartbeats that don't hold the path are untouched. Returns the
|
|
56
|
+
* heartbeats that actually dropped the path so the caller can emit the
|
|
57
|
+
* durable `claim.release` events — a file-only prune is silently reverted by
|
|
58
|
+
* the next projector replay.
|
|
59
|
+
*
|
|
60
|
+
* files_touched can hold either absolute-under-coordRoot or canonical
|
|
61
|
+
* repo-relative entries (legacy projections stored the raw tool_input path),
|
|
62
|
+
* so both sides are normalized before comparing — an exact-string match
|
|
63
|
+
* silently no-ops on the mixed-form case and the claim never releases.
|
|
50
64
|
*
|
|
51
65
|
* This is the Option B fix for post-commit's pid-map attribution hole: a
|
|
52
66
|
* subagent-held claim that doesn't live on the parent's heartbeat still gets
|
|
53
67
|
* pruned because the walk covers the whole group.
|
|
54
68
|
*/
|
|
55
|
-
export declare function groupUnclaim(coordRoot: string, groupId: string, path: string):
|
|
69
|
+
export declare function groupUnclaim(coordRoot: string, groupId: string, path: string): GroupUnclaimHit[];
|
|
56
70
|
export declare function killHeartbeat(coordRoot: string, instanceId: string): boolean;
|
|
57
71
|
export declare function healPidmap(coordRoot: string, instanceId: string, pid: number): void;
|
|
58
72
|
export declare function healHeartbeat(coordRoot: string, instanceId: string, sessionId?: string, model?: string, harness?: string): Heartbeat | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"heartbeat-writer.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6DH,MAAM,WAAW,SAAS;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAiBD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQrF;AAeD,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAS7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKxF;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,GAAG,IAAI,CAMlB;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,SAAS,GAAG,IAAI,CAYlB;AAED
|
|
1
|
+
{"version":3,"file":"heartbeat-writer.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6DH,MAAM,WAAW,SAAS;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAiBD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQrF;AAeD,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAS7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKxF;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,GAAG,IAAI,CAMlB;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,SAAS,GAAG,IAAI,CAYlB;AAED,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,eAAe,EAAE,CAsChG;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAS5E;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAuBnF;AAED,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,GACf,SAAS,GAAG,IAAI,CAqDlB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,SAAS,GAAG,IAAI,CAOlB"}
|
|
@@ -129,18 +129,29 @@ export function releaseClaim(coordRoot, instanceId, path) {
|
|
|
129
129
|
* Session-group-wide unclaim. Walks every heartbeat sharing `groupId`
|
|
130
130
|
* (parent's session_id == group_id;
|
|
131
131
|
* subagents inherit it) and removes the path from each one's files_touched.
|
|
132
|
-
* Idempotent: heartbeats that don't hold the path are untouched.
|
|
132
|
+
* Idempotent: heartbeats that don't hold the path are untouched. Returns the
|
|
133
|
+
* heartbeats that actually dropped the path so the caller can emit the
|
|
134
|
+
* durable `claim.release` events — a file-only prune is silently reverted by
|
|
135
|
+
* the next projector replay.
|
|
136
|
+
*
|
|
137
|
+
* files_touched can hold either absolute-under-coordRoot or canonical
|
|
138
|
+
* repo-relative entries (legacy projections stored the raw tool_input path),
|
|
139
|
+
* so both sides are normalized before comparing — an exact-string match
|
|
140
|
+
* silently no-ops on the mixed-form case and the claim never releases.
|
|
133
141
|
*
|
|
134
142
|
* This is the Option B fix for post-commit's pid-map attribution hole: a
|
|
135
143
|
* subagent-held claim that doesn't live on the parent's heartbeat still gets
|
|
136
144
|
* pruned because the walk covers the whole group.
|
|
137
145
|
*/
|
|
138
146
|
export function groupUnclaim(coordRoot, groupId, path) {
|
|
147
|
+
const hits = [];
|
|
139
148
|
if (!groupId || !path)
|
|
140
|
-
return;
|
|
149
|
+
return hits;
|
|
141
150
|
const activeDir = join(coordRoot, ".harnery", "active");
|
|
142
151
|
if (!existsSync(activeDir))
|
|
143
|
-
return;
|
|
152
|
+
return hits;
|
|
153
|
+
const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
|
|
154
|
+
const target = norm(path);
|
|
144
155
|
for (const f of readdirSync(activeDir)) {
|
|
145
156
|
if (!f.endsWith(".json"))
|
|
146
157
|
continue;
|
|
@@ -156,7 +167,7 @@ export function groupUnclaim(coordRoot, groupId, path) {
|
|
|
156
167
|
if (peerSession !== groupId)
|
|
157
168
|
continue;
|
|
158
169
|
const files = body.files_touched ?? [];
|
|
159
|
-
const next = files.filter((p) => p !==
|
|
170
|
+
const next = files.filter((p) => norm(p) !== target);
|
|
160
171
|
if (next.length === files.length)
|
|
161
172
|
continue;
|
|
162
173
|
body.files_touched = next;
|
|
@@ -164,11 +175,17 @@ export function groupUnclaim(coordRoot, groupId, path) {
|
|
|
164
175
|
const tmp = `${hbPath}.tmp.${process.pid}`;
|
|
165
176
|
writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
|
|
166
177
|
renameSync(tmp, hbPath);
|
|
178
|
+
hits.push({
|
|
179
|
+
instance_id: body.instance_id ?? f.replace(/\.json$/, ""),
|
|
180
|
+
session_id: body.session_id,
|
|
181
|
+
platform: body.platform,
|
|
182
|
+
});
|
|
167
183
|
}
|
|
168
184
|
catch {
|
|
169
185
|
/* silent */
|
|
170
186
|
}
|
|
171
187
|
}
|
|
188
|
+
return hits;
|
|
172
189
|
}
|
|
173
190
|
export function killHeartbeat(coordRoot, instanceId) {
|
|
174
191
|
const path = heartbeatPath(coordRoot, instanceId);
|