@gethmy/agent 1.1.2 → 1.3.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/dist/__tests__/process-group.test.js +27 -7
- package/dist/__tests__/stream-parser.test.js +4 -1
- package/dist/cli.js +6 -2
- package/dist/completion.d.ts +6 -0
- package/dist/completion.js +12 -2
- package/dist/config-validation.d.ts +5 -0
- package/dist/config-validation.js +16 -5
- package/dist/http-server.js +0 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +59 -19
- package/dist/merge-monitor.js +0 -1
- package/dist/pool.js +0 -2
- package/dist/progress-tracker.js +3 -0
- package/dist/reconcile.js +0 -1
- package/dist/recovery.js +0 -1
- package/dist/review-worktree.js +12 -0
- package/dist/startup-banner.d.ts +29 -0
- package/dist/startup-banner.js +143 -0
- package/dist/stream-parser.d.ts +5 -0
- package/dist/stream-parser.js +17 -4
- package/dist/watcher.d.ts +15 -0
- package/dist/watcher.js +41 -4
- package/dist/worktree-gc.js +1 -2
- package/dist/worktree.js +68 -11
- package/package.json +1 -1
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { signalGroup, spawnInGroup, terminateGroup } from "../process-group.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wait until the child writes a "ready" line to stdout. This is the only
|
|
5
|
+
* reliable way to know a Node child has actually installed its signal
|
|
6
|
+
* handlers — time-based waits flake under CI/test-suite load because the
|
|
7
|
+
* handler may not be registered before the test sends SIGTERM, in which case
|
|
8
|
+
* Node's default handler terminates the process and the escalation test
|
|
9
|
+
* can't observe SIGKILL. Child scripts in this file print "ready\n" after
|
|
10
|
+
* calling process.on(...).
|
|
11
|
+
*/
|
|
12
|
+
function waitForReady(proc, timeoutMs = 3000) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const timer = setTimeout(() => reject(new Error("child never reported ready")), timeoutMs);
|
|
15
|
+
let buf = "";
|
|
16
|
+
proc.stdout?.on("data", (d) => {
|
|
17
|
+
buf += d.toString();
|
|
18
|
+
if (buf.includes("ready")) {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
resolve();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
3
25
|
describe("process-group", () => {
|
|
4
26
|
it("places the child in its own process group (pid === pgid)", async () => {
|
|
5
27
|
if (process.platform === "win32")
|
|
@@ -28,15 +50,13 @@ describe("process-group", () => {
|
|
|
28
50
|
return;
|
|
29
51
|
const proc = spawnInGroup(process.execPath, [
|
|
30
52
|
"-e",
|
|
31
|
-
"process.on('SIGINT', () => process.exit(0)); setInterval(()=>{}, 1000);",
|
|
53
|
+
"process.on('SIGINT', () => process.exit(0)); process.stdout.write('ready\\n'); setInterval(()=>{}, 1000);",
|
|
32
54
|
]);
|
|
33
55
|
// Capture the exit state up front so we never miss it.
|
|
34
56
|
const exited = new Promise((resolve) => {
|
|
35
57
|
proc.once("exit", (code, signal) => resolve({ code, signal }));
|
|
36
58
|
});
|
|
37
|
-
|
|
38
|
-
// under contention (9 parallel test files, git spawning, etc.).
|
|
39
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
59
|
+
await waitForReady(proc);
|
|
40
60
|
await terminateGroup(proc, {
|
|
41
61
|
sigintTimeoutMs: 2000,
|
|
42
62
|
sigtermTimeoutMs: 500,
|
|
@@ -52,17 +72,17 @@ describe("process-group", () => {
|
|
|
52
72
|
return;
|
|
53
73
|
const proc = spawnInGroup(process.execPath, [
|
|
54
74
|
"-e",
|
|
55
|
-
"process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); setInterval(()=>{}, 1000);",
|
|
75
|
+
"process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); process.stdout.write('ready\\n'); setInterval(()=>{}, 1000);",
|
|
56
76
|
]);
|
|
57
77
|
const exited = new Promise((resolve) => {
|
|
58
78
|
proc.once("exit", (code, signal) => resolve({ code, signal }));
|
|
59
79
|
});
|
|
60
|
-
await
|
|
80
|
+
await waitForReady(proc);
|
|
61
81
|
await terminateGroup(proc, {
|
|
62
82
|
sigintTimeoutMs: 200,
|
|
63
83
|
sigtermTimeoutMs: 200,
|
|
64
84
|
});
|
|
65
85
|
const result = await exited;
|
|
66
|
-
expect(result.signal
|
|
86
|
+
expect(result.signal).toBe("SIGKILL");
|
|
67
87
|
});
|
|
68
88
|
});
|
|
@@ -104,11 +104,14 @@ describe("StreamParser", () => {
|
|
|
104
104
|
expect(events.costs).toEqual([
|
|
105
105
|
{
|
|
106
106
|
totalCostUsd: 0.5,
|
|
107
|
-
totalInputTokens:
|
|
107
|
+
totalInputTokens: 10,
|
|
108
108
|
totalOutputTokens: 20,
|
|
109
|
+
totalCacheCreationInputTokens: 5,
|
|
110
|
+
totalCacheReadInputTokens: 100,
|
|
109
111
|
durationMs: 1000,
|
|
110
112
|
durationApiMs: 900,
|
|
111
113
|
numTurns: 3,
|
|
114
|
+
modelName: undefined,
|
|
112
115
|
},
|
|
113
116
|
]);
|
|
114
117
|
expect(events.results).toEqual(["success"]);
|
package/dist/cli.js
CHANGED
|
@@ -117,10 +117,14 @@ async function healthCommand() {
|
|
|
117
117
|
async function doctorCommand() {
|
|
118
118
|
const { loadDaemonConfig } = await import("./config.js");
|
|
119
119
|
const { validatePrerequisites } = await import("./index.js");
|
|
120
|
+
const { createStartupBanner } = await import("./startup-banner.js");
|
|
121
|
+
const { createRequire } = await import("node:module");
|
|
122
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
120
123
|
try {
|
|
121
124
|
const config = loadDaemonConfig();
|
|
122
|
-
const
|
|
123
|
-
|
|
125
|
+
const banner = createStartupBanner(config, version);
|
|
126
|
+
const userId = await validatePrerequisites(config, banner);
|
|
127
|
+
await banner.ready(`preflight ok — agent user ${userId}`);
|
|
124
128
|
return 0;
|
|
125
129
|
}
|
|
126
130
|
catch (err) {
|
package/dist/completion.d.ts
CHANGED
|
@@ -12,10 +12,16 @@ export declare function buildTokenPayload(stats?: SessionStats | null): {
|
|
|
12
12
|
costCents?: undefined;
|
|
13
13
|
inputTokens?: undefined;
|
|
14
14
|
outputTokens?: undefined;
|
|
15
|
+
cacheCreationInputTokens?: undefined;
|
|
16
|
+
cacheReadInputTokens?: undefined;
|
|
17
|
+
modelName?: undefined;
|
|
15
18
|
} | {
|
|
16
19
|
costCents: number;
|
|
17
20
|
inputTokens: number;
|
|
18
21
|
outputTokens: number;
|
|
22
|
+
cacheCreationInputTokens: number;
|
|
23
|
+
cacheReadInputTokens: number;
|
|
24
|
+
modelName: string | undefined;
|
|
19
25
|
};
|
|
20
26
|
/**
|
|
21
27
|
* Post-work pipeline: push branch, create PR, move card, post summary.
|
package/dist/completion.js
CHANGED
|
@@ -20,6 +20,9 @@ export function buildTokenPayload(stats) {
|
|
|
20
20
|
costCents: Math.round(stats.cost.totalCostUsd * 100),
|
|
21
21
|
inputTokens: stats.cost.totalInputTokens,
|
|
22
22
|
outputTokens: stats.cost.totalOutputTokens,
|
|
23
|
+
cacheCreationInputTokens: stats.cost.totalCacheCreationInputTokens,
|
|
24
|
+
cacheReadInputTokens: stats.cost.totalCacheReadInputTokens,
|
|
25
|
+
modelName: stats.cost.modelName,
|
|
23
26
|
};
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
@@ -140,11 +143,18 @@ async function postSummary(client, card, branchName, worktreePath, prUrl, baseBr
|
|
|
140
143
|
if (sessionStats.cost) {
|
|
141
144
|
statParts.push(`$${sessionStats.cost.totalCostUsd.toFixed(2)} cost`);
|
|
142
145
|
statParts.push(`${sessionStats.cost.numTurns} turns`);
|
|
143
|
-
const
|
|
144
|
-
sessionStats.cost.
|
|
146
|
+
const totalInput = sessionStats.cost.totalInputTokens +
|
|
147
|
+
sessionStats.cost.totalCacheCreationInputTokens +
|
|
148
|
+
sessionStats.cost.totalCacheReadInputTokens;
|
|
149
|
+
const totalTokens = totalInput + sessionStats.cost.totalOutputTokens;
|
|
145
150
|
if (totalTokens > 0) {
|
|
146
151
|
statParts.push(`${formatTokenCount(totalTokens)} tokens`);
|
|
147
152
|
}
|
|
153
|
+
const cacheRead = sessionStats.cost.totalCacheReadInputTokens;
|
|
154
|
+
if (totalInput > 0 && cacheRead > 0) {
|
|
155
|
+
const hitPct = Math.round((cacheRead / totalInput) * 100);
|
|
156
|
+
statParts.push(`${hitPct}% cache hit`);
|
|
157
|
+
}
|
|
148
158
|
}
|
|
149
159
|
parts.push(`Stats: ${statParts.join(" · ")}`);
|
|
150
160
|
}
|
|
@@ -16,3 +16,8 @@ export declare class ConfigValidationError extends Error {
|
|
|
16
16
|
* board before the daemon is useful.
|
|
17
17
|
*/
|
|
18
18
|
export declare function validateColumnReferences(client: HarmonyApiClient, projectId: string, config: AgentConfig): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Same checks as validateColumnReferences but returns the unique list of
|
|
21
|
+
* validated column names (used by the startup banner). Throws on mismatch.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateAndListColumns(client: HarmonyApiClient, projectId: string, config: AgentConfig): Promise<string[]>;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { log } from "./log.js";
|
|
2
|
-
const TAG = "config-validation";
|
|
3
1
|
export class ConfigValidationError extends Error {
|
|
4
2
|
issues;
|
|
5
3
|
constructor(message, issues) {
|
|
@@ -60,7 +58,20 @@ export async function validateColumnReferences(client, projectId, config) {
|
|
|
60
58
|
const help = `Available columns: ${known.join(", ")}`;
|
|
61
59
|
throw new ConfigValidationError(`Invalid agent config — the following columns are missing:\n - ${issues.join("\n - ")}\n${help}`, issues);
|
|
62
60
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Same checks as validateColumnReferences but returns the unique list of
|
|
64
|
+
* validated column names (used by the startup banner). Throws on mismatch.
|
|
65
|
+
*/
|
|
66
|
+
export async function validateAndListColumns(client, projectId, config) {
|
|
67
|
+
await validateColumnReferences(client, projectId, config);
|
|
68
|
+
const names = [
|
|
69
|
+
...config.pickupColumns,
|
|
70
|
+
config.completion.moveToColumn,
|
|
71
|
+
config.verification.failColumn,
|
|
72
|
+
];
|
|
73
|
+
if (config.review.enabled) {
|
|
74
|
+
names.push(...config.review.pickupColumns, config.review.moveToColumn, config.review.failColumn);
|
|
75
|
+
}
|
|
76
|
+
return Array.from(new Set(names.filter(Boolean)));
|
|
66
77
|
}
|
package/dist/http-server.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DaemonConfig } from "./config.js";
|
|
2
|
-
|
|
2
|
+
import { type StartupBanner } from "./startup-banner.js";
|
|
3
|
+
declare function validatePrerequisites(config: DaemonConfig, banner: StartupBanner): Promise<string>;
|
|
3
4
|
export { validatePrerequisites };
|
|
4
5
|
export declare function main(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
|
|
5
5
|
import { createApiClient, fetchRealtimeCredentials, loadDaemonConfig, } from "./config.js";
|
|
6
|
-
import { ConfigValidationError,
|
|
6
|
+
import { ConfigValidationError, validateAndListColumns, } from "./config-validation.js";
|
|
7
7
|
import { detectGitProvider, validateGitProviderCli } from "./git-pr.js";
|
|
8
8
|
import { HttpServer } from "./http-server.js";
|
|
9
9
|
import { log } from "./log.js";
|
|
@@ -12,6 +12,7 @@ import { Pool } from "./pool.js";
|
|
|
12
12
|
import { Reconciler } from "./reconcile.js";
|
|
13
13
|
import { recoverOrphans } from "./recovery.js";
|
|
14
14
|
import { extractBranchFromDescription } from "./review-worktree.js";
|
|
15
|
+
import { createStartupBanner } from "./startup-banner.js";
|
|
15
16
|
import { StateStore } from "./state-store.js";
|
|
16
17
|
import { verifyStreamParserFormat } from "./stream-parser-selftest.js";
|
|
17
18
|
import { AGENT_NAME } from "./types.js";
|
|
@@ -20,13 +21,13 @@ import { WorktreeGc } from "./worktree-gc.js";
|
|
|
20
21
|
const TAG = "daemon";
|
|
21
22
|
const { version: PKG_VERSION } = createRequire(import.meta.url)("../package.json");
|
|
22
23
|
// ============ STARTUP VALIDATION ============
|
|
23
|
-
async function validatePrerequisites(config) {
|
|
24
|
+
async function validatePrerequisites(config, banner) {
|
|
24
25
|
// 1. Check claude CLI
|
|
25
26
|
try {
|
|
26
27
|
const ver = execFileSync("claude", ["--version"], {
|
|
27
28
|
encoding: "utf-8",
|
|
28
29
|
}).trim();
|
|
29
|
-
|
|
30
|
+
banner.check(`Claude CLI ${ver}`);
|
|
30
31
|
}
|
|
31
32
|
catch {
|
|
32
33
|
throw new Error("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code");
|
|
@@ -34,11 +35,11 @@ async function validatePrerequisites(config) {
|
|
|
34
35
|
// 1b. Stream parser canary — if the CLI stream-json envelope has drifted,
|
|
35
36
|
// every review would silently park with a parse error (see #128).
|
|
36
37
|
verifyStreamParserFormat();
|
|
37
|
-
|
|
38
|
+
banner.check("Stream parser canary");
|
|
38
39
|
// 2. Check git provider CLI (if PR creation enabled in either pipeline)
|
|
39
40
|
if (config.agent.completion.createPR || config.agent.review.createPR) {
|
|
40
41
|
const provider = detectGitProvider();
|
|
41
|
-
|
|
42
|
+
banner.setGitProvider(provider);
|
|
42
43
|
validateGitProviderCli(provider);
|
|
43
44
|
}
|
|
44
45
|
// 3. Check git repo is clean and base branch exists
|
|
@@ -47,6 +48,7 @@ async function validatePrerequisites(config) {
|
|
|
47
48
|
encoding: "utf-8",
|
|
48
49
|
}).trim();
|
|
49
50
|
if (status) {
|
|
51
|
+
banner.fail();
|
|
50
52
|
log.warn(TAG, `Working directory has uncommitted changes:\n${status}`);
|
|
51
53
|
}
|
|
52
54
|
execFileSync("git", ["rev-parse", "--verify", `origin/${config.agent.worktree.baseBranch}`], {
|
|
@@ -61,7 +63,7 @@ async function validatePrerequisites(config) {
|
|
|
61
63
|
const client = createApiClient(config);
|
|
62
64
|
try {
|
|
63
65
|
await client.listWorkspaces();
|
|
64
|
-
|
|
66
|
+
banner.check("API key validated");
|
|
65
67
|
}
|
|
66
68
|
catch (err) {
|
|
67
69
|
throw new Error(`API key validation failed: ${err instanceof Error ? err.message : err}`);
|
|
@@ -75,7 +77,8 @@ async function validatePrerequisites(config) {
|
|
|
75
77
|
.join("\n");
|
|
76
78
|
throw new Error(`Project "${config.projectId}" not found in workspace.\nAvailable projects:\n${available}`);
|
|
77
79
|
}
|
|
78
|
-
|
|
80
|
+
banner.setProjectName(project.name);
|
|
81
|
+
banner.check(`Project access (${project.name})`);
|
|
79
82
|
// 6. Resolve the agent user ID from workspace members
|
|
80
83
|
const members = await client.getWorkspaceMembers(config.workspaceId);
|
|
81
84
|
const agentMember = members.members.find((m) => m.email === config.userEmail);
|
|
@@ -87,25 +90,24 @@ async function validatePrerequisites(config) {
|
|
|
87
90
|
// ============ MAIN DAEMON ============
|
|
88
91
|
export { validatePrerequisites };
|
|
89
92
|
export async function main() {
|
|
90
|
-
log.info(TAG, `Harmony Agent Daemon v${PKG_VERSION} starting...`);
|
|
91
93
|
// Load config
|
|
92
94
|
const config = loadDaemonConfig();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
95
|
+
// Startup banner — collapses 21 INFO lines into a single grouped block
|
|
96
|
+
// in pretty mode. Falls through to per-line log.info in JSON mode.
|
|
97
|
+
const banner = createStartupBanner(config, PKG_VERSION);
|
|
97
98
|
// Validate
|
|
98
|
-
const agentUserId = await validatePrerequisites(config);
|
|
99
|
-
log.info(TAG, `Agent user: ${config.userEmail} (${agentUserId})`);
|
|
99
|
+
const agentUserId = await validatePrerequisites(config, banner);
|
|
100
100
|
// Create API client
|
|
101
101
|
const client = createApiClient(config);
|
|
102
102
|
// Fail fast if configured columns don't exist on the board. A stuck
|
|
103
103
|
// card with no log output is worse than a clear startup error.
|
|
104
104
|
try {
|
|
105
|
-
await
|
|
105
|
+
const cols = await validateAndListColumns(client, config.projectId, config.agent);
|
|
106
|
+
banner.check(`Columns: ${cols.join(", ")}`);
|
|
106
107
|
}
|
|
107
108
|
catch (err) {
|
|
108
109
|
if (err instanceof ConfigValidationError) {
|
|
110
|
+
banner.fail();
|
|
109
111
|
log.error(TAG, err.message);
|
|
110
112
|
process.exit(1);
|
|
111
113
|
}
|
|
@@ -117,12 +119,16 @@ export async function main() {
|
|
|
117
119
|
const daemonId = randomUUID();
|
|
118
120
|
await stateStore.setDaemon(daemonId, process.pid);
|
|
119
121
|
const outcomes = await recoverOrphans(stateStore, client, config.agent);
|
|
120
|
-
if (outcomes.length
|
|
121
|
-
|
|
122
|
+
if (outcomes.length === 0) {
|
|
123
|
+
banner.check("Recovery: no orphans");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const errored = outcomes.filter((o) => o.errors.length).length;
|
|
127
|
+
banner.check(`Recovery: ${outcomes.length} orphan(s) handled${errored > 0 ? `, ${errored} with errors` : ""}`);
|
|
122
128
|
}
|
|
123
129
|
// Fetch realtime credentials
|
|
124
130
|
const realtimeCreds = await fetchRealtimeCredentials(client);
|
|
125
|
-
|
|
131
|
+
banner.check("Realtime credentials");
|
|
126
132
|
// Create pool
|
|
127
133
|
const pool = new Pool(config.agent, client, config.userEmail, config.workspaceId, config.projectId, stateStore);
|
|
128
134
|
// Create reconciler
|
|
@@ -250,10 +256,44 @@ export async function main() {
|
|
|
250
256
|
await httpServer.start();
|
|
251
257
|
}
|
|
252
258
|
catch (err) {
|
|
259
|
+
banner.fail();
|
|
253
260
|
log.warn(TAG, `HTTP server failed to bind: ${err instanceof Error ? err.message : err}`);
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
|
-
|
|
263
|
+
// Banner check lines for service intervals + pool. Compose into one
|
|
264
|
+
// line each so the banner stays compact.
|
|
265
|
+
const reviewCount = config.agent.review.enabled ? 1 : 0;
|
|
266
|
+
banner.check(`Pool: ${config.agent.poolSize} impl${reviewCount > 0 ? ` + ${reviewCount} review` : ""}`);
|
|
267
|
+
const services = [
|
|
268
|
+
`Heartbeat ${config.agent.timing.reconcileIntervalMs / 1000}s`,
|
|
269
|
+
];
|
|
270
|
+
if (mergeMonitor) {
|
|
271
|
+
// MergeMonitor's interval isn't configurable in AgentConfig; matches
|
|
272
|
+
// its constructor default.
|
|
273
|
+
services.push("Merge monitor 60s");
|
|
274
|
+
}
|
|
275
|
+
services.push(`Worktree GC ${config.agent.timing.worktreeGcIntervalMs / 1000}s`);
|
|
276
|
+
banner.check(services.join(" · "));
|
|
277
|
+
// Wait briefly for realtime to fully subscribe so the banner can show
|
|
278
|
+
// its "✓ Realtime: broadcast + presence" line. If it doesn't arrive in
|
|
279
|
+
// 3s, the banner shows "✓ Realtime: connecting…" instead and the
|
|
280
|
+
// watcher's eventual subscribe lines emit normally afterwards.
|
|
281
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(() => resolve("timeout"), ms));
|
|
282
|
+
const winner = await Promise.race([
|
|
283
|
+
watcher.ready().then(() => "ready"),
|
|
284
|
+
sleep(3000),
|
|
285
|
+
]);
|
|
286
|
+
if (winner === "ready") {
|
|
287
|
+
banner.check("Realtime: broadcast + presence");
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
banner.check("Realtime: connecting…");
|
|
291
|
+
}
|
|
292
|
+
await banner.ready("watching for card assignments");
|
|
293
|
+
// Banner has rendered. Allow any realtime events that arrive *after*
|
|
294
|
+
// the banner's 3 s deadline to log normally — otherwise a slow subscribe
|
|
295
|
+
// would leave the user with no signal that realtime ever came up.
|
|
296
|
+
watcher.allowStartupLogs();
|
|
257
297
|
}
|
|
258
298
|
// ============ EVENT HANDLER ============
|
|
259
299
|
async function handleBroadcast(event, client, pool, config, agentUserId) {
|
package/dist/merge-monitor.js
CHANGED
package/dist/pool.js
CHANGED
|
@@ -29,8 +29,6 @@ export class Pool {
|
|
|
29
29
|
this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
|
|
30
30
|
}, stateStore));
|
|
31
31
|
}
|
|
32
|
-
const reviewCount = this.reviewWorkers.length;
|
|
33
|
-
log.info(TAG, `Pool initialized: ${config.poolSize} impl worker(s), ${reviewCount} review worker(s)`);
|
|
34
32
|
}
|
|
35
33
|
/**
|
|
36
34
|
* Enqueue a card for processing with the given mode.
|
package/dist/progress-tracker.js
CHANGED
|
@@ -349,6 +349,9 @@ export class ProgressTracker {
|
|
|
349
349
|
costCents: Math.round((this.lastCost?.totalCostUsd ?? 0) * 100),
|
|
350
350
|
inputTokens: this.lastCost?.totalInputTokens ?? 0,
|
|
351
351
|
outputTokens: this.lastCost?.totalOutputTokens ?? 0,
|
|
352
|
+
cacheCreationInputTokens: this.lastCost?.totalCacheCreationInputTokens ?? 0,
|
|
353
|
+
cacheReadInputTokens: this.lastCost?.totalCacheReadInputTokens ?? 0,
|
|
354
|
+
modelName: this.lastCost?.modelName,
|
|
352
355
|
})
|
|
353
356
|
.catch((err) => {
|
|
354
357
|
log.warn(TAG, `Failed to send progress update: ${err}`);
|
package/dist/reconcile.js
CHANGED
|
@@ -40,7 +40,6 @@ export class Reconciler {
|
|
|
40
40
|
this.agentConfig = agentConfig;
|
|
41
41
|
}
|
|
42
42
|
start() {
|
|
43
|
-
log.info(TAG, `Heartbeat every ${this.intervalMs / 1000}s`);
|
|
44
43
|
// Run immediately, then on interval
|
|
45
44
|
this.tick();
|
|
46
45
|
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
package/dist/recovery.js
CHANGED
|
@@ -44,7 +44,6 @@ async function fetchCardSafely(client, cardId) {
|
|
|
44
44
|
export async function recoverOrphans(store, client, config) {
|
|
45
45
|
const active = store.getActiveRuns();
|
|
46
46
|
if (active.length === 0) {
|
|
47
|
-
log.info(TAG, "no orphan runs to recover");
|
|
48
47
|
return [];
|
|
49
48
|
}
|
|
50
49
|
const outcomes = [];
|
package/dist/review-worktree.js
CHANGED
|
@@ -19,6 +19,18 @@ export function checkoutExistingBranch(basePath, branchName) {
|
|
|
19
19
|
log.warn(TAG, `Review worktree already exists at ${worktreeDir}, cleaning up`);
|
|
20
20
|
cleanupWorktree(worktreeDir);
|
|
21
21
|
}
|
|
22
|
+
// Force-prune orphaned worktree metadata (dir removed externally but git
|
|
23
|
+
// still registers the branch as checked out). Default expiry is 3 months,
|
|
24
|
+
// which leaves fresh stale entries in place.
|
|
25
|
+
try {
|
|
26
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
27
|
+
cwd: repoRoot,
|
|
28
|
+
stdio: "pipe",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// non-fatal
|
|
33
|
+
}
|
|
22
34
|
// Fetch latest to ensure we have the remote branch
|
|
23
35
|
try {
|
|
24
36
|
execFileSync("git", ["fetch", "origin", branchName], {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup banner — collapses the ~21 individually-timestamped INFO lines
|
|
3
|
+
* the daemon used to emit at startup into a single grouped block in
|
|
4
|
+
* pretty/TTY mode. JSON mode is unchanged (each `check()` falls through
|
|
5
|
+
* to `log.info("daemon", msg)` so log shippers see the same records).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const banner = createStartupBanner(config, PKG_VERSION);
|
|
9
|
+
* banner.check("Claude CLI 2.1.126");
|
|
10
|
+
* banner.setProjectName("Harmony");
|
|
11
|
+
* banner.check("API key validated");
|
|
12
|
+
* ...
|
|
13
|
+
* await banner.ready("watching for card assignments");
|
|
14
|
+
*
|
|
15
|
+
* Pretty rendering is deferred until `ready()` so the whole block lands
|
|
16
|
+
* atomically with one stderr.write — nothing can interleave inside it.
|
|
17
|
+
* If a startup error occurs and a warn/error needs to fire, call
|
|
18
|
+
* `banner.fail()` first so the banner suppresses itself and the warning
|
|
19
|
+
* isn't hidden under the block.
|
|
20
|
+
*/
|
|
21
|
+
import type { DaemonConfig } from "./config.js";
|
|
22
|
+
export interface StartupBanner {
|
|
23
|
+
setProjectName(name: string): void;
|
|
24
|
+
setGitProvider(provider: string): void;
|
|
25
|
+
check(message: string): void;
|
|
26
|
+
ready(message: string): Promise<void>;
|
|
27
|
+
fail(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createStartupBanner(config: DaemonConfig, version: string): StartupBanner;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { isPretty, log } from "./log.js";
|
|
2
|
+
const TAG = "daemon";
|
|
3
|
+
const RULE_WIDTH = 70;
|
|
4
|
+
const ANSI = {
|
|
5
|
+
reset: "\x1b[0m",
|
|
6
|
+
dim: "\x1b[2m",
|
|
7
|
+
cyan: "\x1b[36m",
|
|
8
|
+
};
|
|
9
|
+
export function createStartupBanner(config, version) {
|
|
10
|
+
return isPretty()
|
|
11
|
+
? prettyBanner(config, version)
|
|
12
|
+
: jsonBanner(config, version);
|
|
13
|
+
}
|
|
14
|
+
function prettyBanner(config, version) {
|
|
15
|
+
const checks = [];
|
|
16
|
+
let projectName;
|
|
17
|
+
let gitProvider;
|
|
18
|
+
let failed = false;
|
|
19
|
+
let rendered = false;
|
|
20
|
+
return {
|
|
21
|
+
setProjectName(name) {
|
|
22
|
+
projectName = name;
|
|
23
|
+
},
|
|
24
|
+
setGitProvider(provider) {
|
|
25
|
+
gitProvider = provider;
|
|
26
|
+
},
|
|
27
|
+
check(message) {
|
|
28
|
+
checks.push(message);
|
|
29
|
+
},
|
|
30
|
+
fail() {
|
|
31
|
+
failed = true;
|
|
32
|
+
},
|
|
33
|
+
async ready(message) {
|
|
34
|
+
if (failed || rendered)
|
|
35
|
+
return;
|
|
36
|
+
rendered = true;
|
|
37
|
+
const block = renderPretty({
|
|
38
|
+
version,
|
|
39
|
+
config,
|
|
40
|
+
projectName,
|
|
41
|
+
gitProvider,
|
|
42
|
+
checks,
|
|
43
|
+
readyMessage: message,
|
|
44
|
+
});
|
|
45
|
+
process.stderr.write(block);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function jsonBanner(config, version) {
|
|
50
|
+
// Mirror today's structured output: one log.info per startup event.
|
|
51
|
+
// Emit the version line eagerly so JSON consumers still see it first.
|
|
52
|
+
log.info(TAG, `Harmony Agent Daemon v${version} starting...`);
|
|
53
|
+
log.info(TAG, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
|
|
54
|
+
if (config.agent.review.enabled) {
|
|
55
|
+
log.info(TAG, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
|
|
56
|
+
}
|
|
57
|
+
let failed = false;
|
|
58
|
+
return {
|
|
59
|
+
setProjectName(_name) {
|
|
60
|
+
// No-op in JSON mode — the explicit `Project access (X)` check
|
|
61
|
+
// line that follows already conveys this and avoids duplication.
|
|
62
|
+
},
|
|
63
|
+
setGitProvider(provider) {
|
|
64
|
+
log.info(TAG, `Git provider: ${provider}`);
|
|
65
|
+
},
|
|
66
|
+
check(message) {
|
|
67
|
+
log.info(TAG, message);
|
|
68
|
+
},
|
|
69
|
+
fail() {
|
|
70
|
+
failed = true;
|
|
71
|
+
},
|
|
72
|
+
async ready(message) {
|
|
73
|
+
if (failed)
|
|
74
|
+
return;
|
|
75
|
+
log.info(TAG, message);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function renderPretty(input) {
|
|
80
|
+
const { version, config, projectName, gitProvider, checks, readyMessage } = input;
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(titleRule(`Harmony Agent Daemon v${version}`));
|
|
84
|
+
lines.push("");
|
|
85
|
+
for (const row of configRows(config, projectName, gitProvider)) {
|
|
86
|
+
lines.push(` ${dim(row.label.padEnd(9))} ${row.value}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push("");
|
|
89
|
+
for (const msg of checks) {
|
|
90
|
+
lines.push(` ${cyan("✓")} ${msg}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push(`${cyan("▶")} ${cyan("Ready")} — ${readyMessage}`);
|
|
94
|
+
lines.push(dim("─".repeat(RULE_WIDTH)));
|
|
95
|
+
lines.push("");
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
function configRows(config, projectName, gitProvider) {
|
|
99
|
+
const rows = [];
|
|
100
|
+
const projectLabel = projectName
|
|
101
|
+
? `${projectName} (${shortenId(config.projectId)})`
|
|
102
|
+
: shortenId(config.projectId);
|
|
103
|
+
rows.push({ label: "Project", value: projectLabel });
|
|
104
|
+
rows.push({ label: "User", value: config.userEmail });
|
|
105
|
+
const reviewEnabled = config.agent.review.enabled;
|
|
106
|
+
const poolDesc = reviewEnabled
|
|
107
|
+
? `Pool ${config.agent.poolSize} impl + 1 review`
|
|
108
|
+
: `Pool ${config.agent.poolSize} impl`;
|
|
109
|
+
const flowDesc = reviewEnabled
|
|
110
|
+
? `Pickup ${config.agent.pickupColumns[0]} → ${config.agent.completion.moveToColumn} → ${config.agent.review.moveToColumn}`
|
|
111
|
+
: `Pickup ${config.agent.pickupColumns.join(", ")}`;
|
|
112
|
+
rows.push({
|
|
113
|
+
label: "Model",
|
|
114
|
+
value: `${config.agent.claude.model} · ${poolDesc} · ${flowDesc}`,
|
|
115
|
+
});
|
|
116
|
+
const tail = [];
|
|
117
|
+
if (gitProvider)
|
|
118
|
+
tail.push(gitProvider);
|
|
119
|
+
if (config.agent.http.enabled) {
|
|
120
|
+
tail.push(`HTTP http://${config.agent.http.bindAddr}:${config.agent.http.port}`);
|
|
121
|
+
}
|
|
122
|
+
if (tail.length > 0) {
|
|
123
|
+
rows.push({ label: "Git", value: tail.join(" · ") });
|
|
124
|
+
}
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
127
|
+
function titleRule(title) {
|
|
128
|
+
const prefix = "─── ";
|
|
129
|
+
const surround = ` `;
|
|
130
|
+
const suffix = "─".repeat(Math.max(3, RULE_WIDTH - prefix.length - title.length - surround.length));
|
|
131
|
+
return dim(`${prefix}${title}${surround}${suffix}`);
|
|
132
|
+
}
|
|
133
|
+
function shortenId(id) {
|
|
134
|
+
if (id.length <= 8)
|
|
135
|
+
return id;
|
|
136
|
+
return `${id.slice(0, 8)}…`;
|
|
137
|
+
}
|
|
138
|
+
function dim(s) {
|
|
139
|
+
return `${ANSI.dim}${s}${ANSI.reset}`;
|
|
140
|
+
}
|
|
141
|
+
function cyan(s) {
|
|
142
|
+
return `${ANSI.cyan}${s}${ANSI.reset}`;
|
|
143
|
+
}
|
package/dist/stream-parser.d.ts
CHANGED
|
@@ -2,11 +2,15 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import type { Readable } from "node:stream";
|
|
3
3
|
export interface CostUpdate {
|
|
4
4
|
totalCostUsd: number;
|
|
5
|
+
/** Fresh input tokens only — does NOT include cache_read or cache_creation. */
|
|
5
6
|
totalInputTokens: number;
|
|
6
7
|
totalOutputTokens: number;
|
|
8
|
+
totalCacheCreationInputTokens: number;
|
|
9
|
+
totalCacheReadInputTokens: number;
|
|
7
10
|
durationMs: number;
|
|
8
11
|
durationApiMs: number;
|
|
9
12
|
numTurns: number;
|
|
13
|
+
modelName?: string;
|
|
10
14
|
}
|
|
11
15
|
export interface StreamParserEvents {
|
|
12
16
|
tool_start: [name: string, input: unknown];
|
|
@@ -21,6 +25,7 @@ export declare class StreamParser extends EventEmitter<StreamParserEvents> {
|
|
|
21
25
|
private attached;
|
|
22
26
|
private toolNames;
|
|
23
27
|
private hasEmittedText;
|
|
28
|
+
private observedModel?;
|
|
24
29
|
/**
|
|
25
30
|
* Attach a readable stream (Claude CLI stdout) to the parser.
|
|
26
31
|
* Parses NDJSON lines and emits typed events.
|
package/dist/stream-parser.js
CHANGED
|
@@ -6,6 +6,7 @@ export class StreamParser extends EventEmitter {
|
|
|
6
6
|
attached = false;
|
|
7
7
|
toolNames = new Map();
|
|
8
8
|
hasEmittedText = false;
|
|
9
|
+
observedModel;
|
|
9
10
|
/**
|
|
10
11
|
* Attach a readable stream (Claude CLI stdout) to the parser.
|
|
11
12
|
* Parses NDJSON lines and emits typed events.
|
|
@@ -66,6 +67,17 @@ export class StreamParser extends EventEmitter {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
handleMessage(msg) {
|
|
70
|
+
// Capture model from any envelope that carries it. The Claude CLI exposes
|
|
71
|
+
// `model` on the top-level `system` envelope and inside each assistant
|
|
72
|
+
// envelope's message, but the final `result` envelope does not.
|
|
73
|
+
if (!this.observedModel) {
|
|
74
|
+
if (typeof msg.model === "string") {
|
|
75
|
+
this.observedModel = msg.model;
|
|
76
|
+
}
|
|
77
|
+
else if (typeof msg.message?.model === "string") {
|
|
78
|
+
this.observedModel = msg.message.model;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
69
81
|
switch (msg.type) {
|
|
70
82
|
case "assistant": {
|
|
71
83
|
const blocks = msg.message?.content;
|
|
@@ -114,16 +126,17 @@ export class StreamParser extends EventEmitter {
|
|
|
114
126
|
}
|
|
115
127
|
if (typeof msg.total_cost_usd === "number") {
|
|
116
128
|
const usage = msg.usage;
|
|
117
|
-
const totalInputTokens = (usage?.input_tokens ?? 0) +
|
|
118
|
-
(usage?.cache_creation_input_tokens ?? 0) +
|
|
119
|
-
(usage?.cache_read_input_tokens ?? 0);
|
|
120
129
|
this.emit("cost_update", {
|
|
121
130
|
totalCostUsd: msg.total_cost_usd,
|
|
122
|
-
totalInputTokens,
|
|
131
|
+
totalInputTokens: usage?.input_tokens ?? 0,
|
|
123
132
|
totalOutputTokens: usage?.output_tokens ?? 0,
|
|
133
|
+
totalCacheCreationInputTokens: usage?.cache_creation_input_tokens ?? 0,
|
|
134
|
+
totalCacheReadInputTokens: usage?.cache_read_input_tokens ?? 0,
|
|
124
135
|
durationMs: msg.duration_ms ?? 0,
|
|
125
136
|
durationApiMs: msg.duration_api_ms ?? 0,
|
|
126
137
|
numTurns: msg.num_turns ?? 0,
|
|
138
|
+
modelName: this.observedModel ??
|
|
139
|
+
(typeof msg.model === "string" ? msg.model : undefined),
|
|
127
140
|
});
|
|
128
141
|
}
|
|
129
142
|
this.emit("result", msg.stop_reason ?? msg.subtype ?? "unknown");
|
package/dist/watcher.d.ts
CHANGED
|
@@ -31,7 +31,22 @@ export declare class Watcher {
|
|
|
31
31
|
private supabase;
|
|
32
32
|
private daemonId;
|
|
33
33
|
private connected;
|
|
34
|
+
private presenceTracked;
|
|
35
|
+
private suppressStartupLogs;
|
|
36
|
+
private readyResolve;
|
|
37
|
+
private readyPromise;
|
|
34
38
|
get isConnected(): boolean;
|
|
39
|
+
/** Resolves once both broadcast subscription is active AND presence is tracked. */
|
|
40
|
+
ready(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Re-enable startup-state log lines. The startup banner suppresses them
|
|
43
|
+
* by default in pretty mode (it absorbs subscribe/presence into one
|
|
44
|
+
* "✓ Realtime" line). After the banner has rendered, main() calls this
|
|
45
|
+
* so any realtime events that arrive *after* the banner — e.g. a slow
|
|
46
|
+
* subscribe that missed the banner's 3 s deadline — log normally.
|
|
47
|
+
*/
|
|
48
|
+
allowStartupLogs(): void;
|
|
49
|
+
private maybeResolveReady;
|
|
35
50
|
constructor(credentials: RealtimeCredentials, projectId: string, identity: PresenceIdentity, onCardBroadcast: CardBroadcastHandler, onAgentCommand?: AgentCommandHandler | undefined);
|
|
36
51
|
start(): Promise<void>;
|
|
37
52
|
stop(): Promise<void>;
|
package/dist/watcher.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createClient } from "@supabase/supabase-js";
|
|
3
|
-
import { log } from "./log.js";
|
|
3
|
+
import { isPretty, log } from "./log.js";
|
|
4
4
|
const TAG = "watcher";
|
|
5
5
|
/**
|
|
6
6
|
* Subscribes to Supabase broadcast events on the board channel.
|
|
@@ -18,9 +18,35 @@ export class Watcher {
|
|
|
18
18
|
supabase = null;
|
|
19
19
|
daemonId = randomUUID();
|
|
20
20
|
connected = false;
|
|
21
|
+
presenceTracked = false;
|
|
22
|
+
suppressStartupLogs = true;
|
|
23
|
+
readyResolve = null;
|
|
24
|
+
readyPromise = new Promise((resolve) => {
|
|
25
|
+
this.readyResolve = resolve;
|
|
26
|
+
});
|
|
21
27
|
get isConnected() {
|
|
22
28
|
return this.connected;
|
|
23
29
|
}
|
|
30
|
+
/** Resolves once both broadcast subscription is active AND presence is tracked. */
|
|
31
|
+
ready() {
|
|
32
|
+
return this.readyPromise;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Re-enable startup-state log lines. The startup banner suppresses them
|
|
36
|
+
* by default in pretty mode (it absorbs subscribe/presence into one
|
|
37
|
+
* "✓ Realtime" line). After the banner has rendered, main() calls this
|
|
38
|
+
* so any realtime events that arrive *after* the banner — e.g. a slow
|
|
39
|
+
* subscribe that missed the banner's 3 s deadline — log normally.
|
|
40
|
+
*/
|
|
41
|
+
allowStartupLogs() {
|
|
42
|
+
this.suppressStartupLogs = false;
|
|
43
|
+
}
|
|
44
|
+
maybeResolveReady() {
|
|
45
|
+
if (this.connected && this.presenceTracked && this.readyResolve) {
|
|
46
|
+
this.readyResolve();
|
|
47
|
+
this.readyResolve = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
24
50
|
constructor(credentials, projectId, identity, onCardBroadcast, onAgentCommand) {
|
|
25
51
|
this.credentials = credentials;
|
|
26
52
|
this.projectId = projectId;
|
|
@@ -29,7 +55,11 @@ export class Watcher {
|
|
|
29
55
|
this.onAgentCommand = onAgentCommand;
|
|
30
56
|
}
|
|
31
57
|
async start() {
|
|
32
|
-
|
|
58
|
+
// In pretty mode the startup banner absorbs realtime readiness into
|
|
59
|
+
// its "✓ Realtime" line, so this connect message is redundant noise.
|
|
60
|
+
if (!isPretty()) {
|
|
61
|
+
log.info(TAG, "Connecting to Supabase realtime (broadcast)...");
|
|
62
|
+
}
|
|
33
63
|
this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
|
|
34
64
|
// Presence channel — separate from the broadcast channel to avoid
|
|
35
65
|
// conflicting with frontend BoardContext's board-{projectId} subscription
|
|
@@ -62,7 +92,10 @@ export class Watcher {
|
|
|
62
92
|
.subscribe((status) => {
|
|
63
93
|
if (status === "SUBSCRIBED") {
|
|
64
94
|
this.connected = true;
|
|
65
|
-
|
|
95
|
+
if (!isPretty() || !this.suppressStartupLogs) {
|
|
96
|
+
log.info(TAG, "Broadcast subscription active");
|
|
97
|
+
}
|
|
98
|
+
this.maybeResolveReady();
|
|
66
99
|
}
|
|
67
100
|
else if (status === "CHANNEL_ERROR") {
|
|
68
101
|
this.connected = false;
|
|
@@ -92,7 +125,11 @@ export class Watcher {
|
|
|
92
125
|
agentIdentifier: this.identity.agentIdentifier,
|
|
93
126
|
agentName: this.identity.agentName,
|
|
94
127
|
});
|
|
95
|
-
|
|
128
|
+
if (!isPretty() || !this.suppressStartupLogs) {
|
|
129
|
+
log.info(TAG, "Presence tracked on board-presence channel");
|
|
130
|
+
}
|
|
131
|
+
this.presenceTracked = true;
|
|
132
|
+
this.maybeResolveReady();
|
|
96
133
|
}
|
|
97
134
|
});
|
|
98
135
|
this.presenceChannel = presenceChannel;
|
package/dist/worktree-gc.js
CHANGED
|
@@ -78,7 +78,7 @@ export function runWorktreeGc(basePath, store, opts = {}) {
|
|
|
78
78
|
}
|
|
79
79
|
// Prune any stale metadata leftover from deleted worktrees.
|
|
80
80
|
try {
|
|
81
|
-
execFileSync("git", ["worktree", "prune"], {
|
|
81
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
82
82
|
cwd: repoRoot,
|
|
83
83
|
stdio: "pipe",
|
|
84
84
|
});
|
|
@@ -107,7 +107,6 @@ export class WorktreeGc {
|
|
|
107
107
|
this.intervalMs = intervalMs;
|
|
108
108
|
}
|
|
109
109
|
start() {
|
|
110
|
-
log.info(TAG, `worktree GC every ${this.intervalMs / 1000}s`);
|
|
111
110
|
// Run once at startup, then on interval.
|
|
112
111
|
this.tick();
|
|
113
112
|
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
package/dist/worktree.js
CHANGED
|
@@ -15,32 +15,89 @@ export function createWorktree(basePath, baseBranch, branchName) {
|
|
|
15
15
|
const worktreeDir = resolve(repoRoot, basePath, branchName);
|
|
16
16
|
if (existsSync(worktreeDir)) {
|
|
17
17
|
log.warn(TAG, `Worktree already exists at ${worktreeDir}, cleaning up`);
|
|
18
|
-
cleanupWorktree(worktreeDir);
|
|
18
|
+
cleanupWorktree(worktreeDir, branchName);
|
|
19
19
|
}
|
|
20
|
-
//
|
|
20
|
+
// Prune stale worktree metadata. If a previous daemon crashed or its
|
|
21
|
+
// worktree dir was deleted externally, git may still think the branch is
|
|
22
|
+
// checked out, which blocks `git branch -D` and `git worktree add`.
|
|
23
|
+
// `--expire=now` overrides `gc.worktreePruneExpire` (default 3 months) so
|
|
24
|
+
// freshly-orphaned entries are removed immediately.
|
|
21
25
|
try {
|
|
22
|
-
execFileSync("git", ["
|
|
26
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
23
27
|
cwd: repoRoot,
|
|
24
28
|
stdio: "pipe",
|
|
25
29
|
});
|
|
26
30
|
}
|
|
27
31
|
catch {
|
|
28
|
-
|
|
32
|
+
// non-fatal
|
|
29
33
|
}
|
|
30
|
-
//
|
|
34
|
+
// Fetch latest from remote to ensure base branch is up to date
|
|
31
35
|
try {
|
|
32
|
-
execFileSync("git", ["
|
|
36
|
+
execFileSync("git", ["fetch", "origin", baseBranch], {
|
|
33
37
|
cwd: repoRoot,
|
|
34
38
|
stdio: "pipe",
|
|
35
39
|
});
|
|
36
|
-
log.info(TAG, `Deleted stale branch: ${branchName}`);
|
|
37
40
|
}
|
|
38
41
|
catch {
|
|
39
|
-
|
|
42
|
+
log.warn(TAG, "Failed to fetch latest — continuing with local state");
|
|
40
43
|
}
|
|
41
|
-
// Create worktree with a
|
|
44
|
+
// Create worktree with a fresh branch based on origin/<baseBranch>.
|
|
45
|
+
// `-B` resets the branch if it already exists — agent branches are owned
|
|
46
|
+
// per-attempt, so starting fresh from origin is the desired behavior.
|
|
42
47
|
log.info(TAG, `Creating worktree: ${worktreeDir} (branch: ${branchName})`);
|
|
43
|
-
|
|
48
|
+
try {
|
|
49
|
+
execFileSync("git", [
|
|
50
|
+
"worktree",
|
|
51
|
+
"add",
|
|
52
|
+
"-B",
|
|
53
|
+
branchName,
|
|
54
|
+
worktreeDir,
|
|
55
|
+
`origin/${baseBranch}`,
|
|
56
|
+
], { cwd: repoRoot, stdio: "pipe" });
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
// Last-resort recovery: if `-B` still fails (e.g. branch checked out in
|
|
60
|
+
// another registered worktree), force-delete the branch and retry.
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
log.warn(TAG, `worktree add failed, attempting forced recovery: ${msg}`);
|
|
63
|
+
// Remove any registered worktree at this path (phantom or otherwise).
|
|
64
|
+
try {
|
|
65
|
+
execFileSync("git", ["worktree", "remove", worktreeDir, "--force"], {
|
|
66
|
+
cwd: repoRoot,
|
|
67
|
+
stdio: "pipe",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// best-effort
|
|
72
|
+
}
|
|
73
|
+
// Force-prune any stale worktree admin entries referencing this branch.
|
|
74
|
+
try {
|
|
75
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
76
|
+
cwd: repoRoot,
|
|
77
|
+
stdio: "pipe",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// best-effort
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
execFileSync("git", ["branch", "-D", branchName], {
|
|
85
|
+
cwd: repoRoot,
|
|
86
|
+
stdio: "pipe",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// ignore; retry will surface the real error
|
|
91
|
+
}
|
|
92
|
+
execFileSync("git", [
|
|
93
|
+
"worktree",
|
|
94
|
+
"add",
|
|
95
|
+
"-B",
|
|
96
|
+
branchName,
|
|
97
|
+
worktreeDir,
|
|
98
|
+
`origin/${baseBranch}`,
|
|
99
|
+
], { cwd: repoRoot, stdio: "pipe" });
|
|
100
|
+
}
|
|
44
101
|
// Install dependencies in the worktree
|
|
45
102
|
log.info(TAG, "Installing dependencies in worktree...");
|
|
46
103
|
try {
|
|
@@ -77,7 +134,7 @@ export function cleanupWorktree(worktreePath, branchName) {
|
|
|
77
134
|
}
|
|
78
135
|
// Prune stale worktree entries
|
|
79
136
|
try {
|
|
80
|
-
execFileSync("git", ["worktree", "prune"], {
|
|
137
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
81
138
|
cwd: repoRoot,
|
|
82
139
|
stdio: "pipe",
|
|
83
140
|
});
|