@gethmy/agent 1.7.1 → 1.7.3
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/cli.js +6386 -141
- package/dist/index.js +6216 -333
- package/package.json +2 -2
- package/dist/board-helpers.d.ts +0 -31
- package/dist/board-helpers.js +0 -150
- package/dist/budget.d.ts +0 -39
- package/dist/budget.js +0 -73
- package/dist/cli.d.ts +0 -14
- package/dist/completion.d.ts +0 -36
- package/dist/completion.js +0 -322
- package/dist/config-validation.d.ts +0 -23
- package/dist/config-validation.js +0 -77
- package/dist/config.d.ts +0 -23
- package/dist/config.js +0 -103
- package/dist/episode-writer.d.ts +0 -116
- package/dist/episode-writer.js +0 -349
- package/dist/git-diff-stat.d.ts +0 -24
- package/dist/git-diff-stat.js +0 -56
- package/dist/git-pr.d.ts +0 -38
- package/dist/git-pr.js +0 -399
- package/dist/http-server.d.ts +0 -66
- package/dist/http-server.js +0 -96
- package/dist/index.d.ts +0 -5
- package/dist/log.d.ts +0 -34
- package/dist/log.js +0 -100
- package/dist/merge-monitor.d.ts +0 -23
- package/dist/merge-monitor.js +0 -169
- package/dist/pm.d.ts +0 -14
- package/dist/pm.js +0 -63
- package/dist/pool.d.ts +0 -71
- package/dist/pool.js +0 -259
- package/dist/process-group.d.ts +0 -26
- package/dist/process-group.js +0 -72
- package/dist/progress-tracker.d.ts +0 -82
- package/dist/progress-tracker.js +0 -457
- package/dist/prompt.d.ts +0 -23
- package/dist/prompt.js +0 -160
- package/dist/queue.d.ts +0 -39
- package/dist/queue.js +0 -100
- package/dist/reconcile.d.ts +0 -35
- package/dist/reconcile.js +0 -174
- package/dist/recovery.d.ts +0 -30
- package/dist/recovery.js +0 -141
- package/dist/review-completion.d.ts +0 -35
- package/dist/review-completion.js +0 -475
- package/dist/review-knowledge.d.ts +0 -14
- package/dist/review-knowledge.js +0 -89
- package/dist/review-prompt.d.ts +0 -12
- package/dist/review-prompt.js +0 -103
- package/dist/review-worker.d.ts +0 -56
- package/dist/review-worker.js +0 -638
- package/dist/review-worktree.d.ts +0 -12
- package/dist/review-worktree.js +0 -95
- package/dist/run-log.d.ts +0 -6
- package/dist/run-log.js +0 -19
- package/dist/startup-banner.d.ts +0 -29
- package/dist/startup-banner.js +0 -143
- package/dist/state-store.d.ts +0 -89
- package/dist/state-store.js +0 -230
- package/dist/stream-parser-selftest.d.ts +0 -9
- package/dist/stream-parser-selftest.js +0 -97
- package/dist/stream-parser.d.ts +0 -43
- package/dist/stream-parser.js +0 -174
- package/dist/transitions.d.ts +0 -57
- package/dist/transitions.js +0 -131
- package/dist/types.d.ts +0 -167
- package/dist/types.js +0 -76
- package/dist/verification.d.ts +0 -39
- package/dist/verification.js +0 -317
- package/dist/watcher.d.ts +0 -53
- package/dist/watcher.js +0 -153
- package/dist/worker.d.ts +0 -54
- package/dist/worker.js +0 -507
- package/dist/worktree-gc.d.ts +0 -67
- package/dist/worktree-gc.js +0 -245
- package/dist/worktree.d.ts +0 -18
- package/dist/worktree.js +0 -177
package/dist/review-worktree.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import { log } from "./log.js";
|
|
5
|
-
import { installCommand } from "./pm.js";
|
|
6
|
-
import { cleanupWorktree } from "./worktree.js";
|
|
7
|
-
const TAG = "review-worktree";
|
|
8
|
-
/**
|
|
9
|
-
* Checkout an existing remote branch into a worktree for review.
|
|
10
|
-
* Unlike createWorktree() which creates a new branch, this checks out
|
|
11
|
-
* an existing branch that was pushed by the implementation worker.
|
|
12
|
-
*/
|
|
13
|
-
export function checkoutExistingBranch(basePath, branchName) {
|
|
14
|
-
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
15
|
-
encoding: "utf-8",
|
|
16
|
-
}).trim();
|
|
17
|
-
const worktreeDir = resolve(repoRoot, basePath, `review-${branchName}`);
|
|
18
|
-
if (existsSync(worktreeDir)) {
|
|
19
|
-
log.warn(TAG, `Review worktree already exists at ${worktreeDir}, cleaning up`);
|
|
20
|
-
cleanupWorktree(worktreeDir);
|
|
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
|
-
}
|
|
34
|
-
// Fetch latest to ensure we have the remote branch
|
|
35
|
-
try {
|
|
36
|
-
execFileSync("git", ["fetch", "origin", branchName], {
|
|
37
|
-
cwd: repoRoot,
|
|
38
|
-
stdio: "pipe",
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
throw new Error(`Failed to fetch remote branch: ${branchName}`);
|
|
43
|
-
}
|
|
44
|
-
// Delete stale local branch if it exists
|
|
45
|
-
try {
|
|
46
|
-
execFileSync("git", ["branch", "-D", branchName], {
|
|
47
|
-
cwd: repoRoot,
|
|
48
|
-
stdio: "pipe",
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
// Branch doesn't exist locally — that's fine
|
|
53
|
-
}
|
|
54
|
-
// Create worktree from the remote branch
|
|
55
|
-
log.info(TAG, `Creating review worktree: ${worktreeDir} (branch: ${branchName})`);
|
|
56
|
-
execFileSync("git", [
|
|
57
|
-
"worktree",
|
|
58
|
-
"add",
|
|
59
|
-
"--track",
|
|
60
|
-
"-b",
|
|
61
|
-
branchName,
|
|
62
|
-
worktreeDir,
|
|
63
|
-
`origin/${branchName}`,
|
|
64
|
-
], { cwd: repoRoot, stdio: "pipe" });
|
|
65
|
-
// Install dependencies
|
|
66
|
-
log.info(TAG, "Installing dependencies in review worktree...");
|
|
67
|
-
try {
|
|
68
|
-
execSync(installCommand(), {
|
|
69
|
-
cwd: worktreeDir,
|
|
70
|
-
stdio: "pipe",
|
|
71
|
-
timeout: 60_000,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
log.warn(TAG, "Install failed (may be fine if deps are hoisted)");
|
|
76
|
-
}
|
|
77
|
-
return worktreeDir;
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Extract branch name from card description's completion summary.
|
|
81
|
-
* Looks for: Branch: `agent/...`
|
|
82
|
-
* Validates that the extracted name contains only safe characters.
|
|
83
|
-
*/
|
|
84
|
-
export function extractBranchFromDescription(description) {
|
|
85
|
-
if (!description)
|
|
86
|
-
return null;
|
|
87
|
-
const match = description.match(/Branch:\s*`([^`]+)`/);
|
|
88
|
-
const branch = match?.[1] ?? null;
|
|
89
|
-
// Validate branch name contains only safe git ref characters
|
|
90
|
-
if (branch && !/^[a-zA-Z0-9/_.-]+$/.test(branch)) {
|
|
91
|
-
log.warn(TAG, `Extracted branch name contains unsafe characters: ${branch}`);
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
return branch;
|
|
95
|
-
}
|
package/dist/run-log.d.ts
DELETED
package/dist/run-log.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { createWriteStream, mkdirSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { log } from "./log.js";
|
|
5
|
-
export function openRunLog(tag, runId, shortId) {
|
|
6
|
-
if (!runId)
|
|
7
|
-
return null;
|
|
8
|
-
try {
|
|
9
|
-
const dir = join(homedir(), ".harmony-mcp", "runs");
|
|
10
|
-
mkdirSync(dir, { recursive: true });
|
|
11
|
-
const path = join(dir, `${runId}-card-${shortId}.log`);
|
|
12
|
-
const stream = createWriteStream(path, { flags: "a" });
|
|
13
|
-
return { path, stream };
|
|
14
|
-
}
|
|
15
|
-
catch (err) {
|
|
16
|
-
log.warn(tag, `Failed to open run log: ${err instanceof Error ? err.message : err}`);
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
}
|
package/dist/startup-banner.d.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
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;
|
package/dist/startup-banner.js
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
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 + ${config.agent.review.poolSize} 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/state-store.d.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
export type RunPipeline = "implement" | "review";
|
|
2
|
-
export type RunStatus = "active" | "completed" | "paused" | "orphaned";
|
|
3
|
-
export interface RunRecord {
|
|
4
|
-
runId: string;
|
|
5
|
-
cardId: string;
|
|
6
|
-
cardShortId: number;
|
|
7
|
-
pipeline: RunPipeline;
|
|
8
|
-
workerId: number;
|
|
9
|
-
sessionId: string | null;
|
|
10
|
-
worktreePath: string | null;
|
|
11
|
-
branchName: string | null;
|
|
12
|
-
daemonPid: number;
|
|
13
|
-
phase: string;
|
|
14
|
-
startedAt: number;
|
|
15
|
-
lastHeartbeatAt: number;
|
|
16
|
-
endedAt: number | null;
|
|
17
|
-
status: RunStatus;
|
|
18
|
-
costCents: number;
|
|
19
|
-
errorMessage?: string;
|
|
20
|
-
}
|
|
21
|
-
export interface FailureSummaryRecord {
|
|
22
|
-
summary: string;
|
|
23
|
-
ts: number;
|
|
24
|
-
reason?: "verification" | "review" | "daemon_restart" | "budget" | "other";
|
|
25
|
-
recoveryBranch?: string;
|
|
26
|
-
}
|
|
27
|
-
export interface CardRecord {
|
|
28
|
-
cardId: string;
|
|
29
|
-
attempts: number;
|
|
30
|
-
totalCostCents: number;
|
|
31
|
-
lastAttemptAt: number | null;
|
|
32
|
-
lastOutcome: "success" | "failure" | null;
|
|
33
|
-
failureHistory?: FailureSummaryRecord[];
|
|
34
|
-
}
|
|
35
|
-
export declare function newRunId(): string;
|
|
36
|
-
export declare function defaultStatePath(): string;
|
|
37
|
-
/**
|
|
38
|
-
* Durable run-and-card state for the agent daemon. One writer (this daemon),
|
|
39
|
-
* so in-process serialization via a promise chain is sufficient — no file locks.
|
|
40
|
-
* Persists to JSON via write-to-tmp + rename, which is atomic on POSIX.
|
|
41
|
-
*/
|
|
42
|
-
export declare class StateStore {
|
|
43
|
-
private path;
|
|
44
|
-
private state;
|
|
45
|
-
private writeQueue;
|
|
46
|
-
constructor(path: string);
|
|
47
|
-
static open(path?: string): StateStore;
|
|
48
|
-
private load;
|
|
49
|
-
private persist;
|
|
50
|
-
/** Await any pending writes. Useful for tests and shutdown. */
|
|
51
|
-
flush(): Promise<void>;
|
|
52
|
-
setDaemon(daemonId: string, pid: number): Promise<void>;
|
|
53
|
-
getDaemon(): {
|
|
54
|
-
daemonId: string | null;
|
|
55
|
-
daemonPid: number | null;
|
|
56
|
-
daemonStartedAt: number | null;
|
|
57
|
-
};
|
|
58
|
-
insertRun(run: RunRecord): Promise<void>;
|
|
59
|
-
updateRun(runId: string, patch: Partial<RunRecord>): Promise<void>;
|
|
60
|
-
heartbeat(runId: string): Promise<void>;
|
|
61
|
-
endRun(runId: string, status: RunStatus, errorMessage?: string): Promise<void>;
|
|
62
|
-
getRun(runId: string): RunRecord | null;
|
|
63
|
-
getActiveRuns(): RunRecord[];
|
|
64
|
-
getRunsForCard(cardId: string): RunRecord[];
|
|
65
|
-
/** Trim completed runs older than `beforeTs` to keep the file small. */
|
|
66
|
-
purgeOldRuns(beforeTs: number): Promise<void>;
|
|
67
|
-
private ensureCard;
|
|
68
|
-
getCard(cardId: string): CardRecord | null;
|
|
69
|
-
incrementAttempt(cardId: string): Promise<number>;
|
|
70
|
-
recordOutcome(cardId: string, outcome: "success" | "failure"): Promise<void>;
|
|
71
|
-
/**
|
|
72
|
-
* Reset a card's attempt counter so a card that previously gave up
|
|
73
|
-
* (attempts >= maxAttemptsPerCard) becomes eligible again. Called when a
|
|
74
|
-
* card is unassigned/reassigned — reassignment is the human's "try again".
|
|
75
|
-
* No-op for an unknown card.
|
|
76
|
-
*/
|
|
77
|
-
resetAttempts(cardId: string): Promise<void>;
|
|
78
|
-
/**
|
|
79
|
-
* Push a failure summary onto the card's bounded history (most-recent first,
|
|
80
|
-
* capped at 5). Read back by the give-up comment and the Agent History
|
|
81
|
-
* UI section to give users a post-mortem trail across attempts.
|
|
82
|
-
*/
|
|
83
|
-
recordFailureSummary(cardId: string, entry: Omit<FailureSummaryRecord, "ts"> & {
|
|
84
|
-
ts?: number;
|
|
85
|
-
}): Promise<void>;
|
|
86
|
-
getRecentFailures(cardId: string, limit?: number): FailureSummaryRecord[];
|
|
87
|
-
addCost(cardId: string, cents: number): Promise<void>;
|
|
88
|
-
getDailyCostCents(date?: string): number;
|
|
89
|
-
}
|
package/dist/state-store.js
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { log } from "./log.js";
|
|
5
|
-
const TAG = "state-store";
|
|
6
|
-
const SCHEMA_VERSION = 1;
|
|
7
|
-
function emptyState() {
|
|
8
|
-
return {
|
|
9
|
-
version: SCHEMA_VERSION,
|
|
10
|
-
daemonId: null,
|
|
11
|
-
daemonPid: null,
|
|
12
|
-
daemonStartedAt: null,
|
|
13
|
-
runs: [],
|
|
14
|
-
cards: [],
|
|
15
|
-
daily: [],
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
function todayUtc() {
|
|
19
|
-
return new Date().toISOString().slice(0, 10);
|
|
20
|
-
}
|
|
21
|
-
export function newRunId() {
|
|
22
|
-
const ts = Date.now().toString(36);
|
|
23
|
-
const rand = Math.random().toString(36).slice(2, 10);
|
|
24
|
-
return `r_${ts}_${rand}`;
|
|
25
|
-
}
|
|
26
|
-
export function defaultStatePath() {
|
|
27
|
-
return join(homedir(), ".harmony-mcp", "agent-state.json");
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Durable run-and-card state for the agent daemon. One writer (this daemon),
|
|
31
|
-
* so in-process serialization via a promise chain is sufficient — no file locks.
|
|
32
|
-
* Persists to JSON via write-to-tmp + rename, which is atomic on POSIX.
|
|
33
|
-
*/
|
|
34
|
-
export class StateStore {
|
|
35
|
-
path;
|
|
36
|
-
state;
|
|
37
|
-
writeQueue = Promise.resolve();
|
|
38
|
-
constructor(path) {
|
|
39
|
-
this.path = path;
|
|
40
|
-
this.state = this.load();
|
|
41
|
-
}
|
|
42
|
-
static open(path) {
|
|
43
|
-
const resolved = path ?? defaultStatePath();
|
|
44
|
-
const dir = dirname(resolved);
|
|
45
|
-
if (!existsSync(dir))
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
-
return new StateStore(resolved);
|
|
48
|
-
}
|
|
49
|
-
load() {
|
|
50
|
-
if (!existsSync(this.path))
|
|
51
|
-
return emptyState();
|
|
52
|
-
try {
|
|
53
|
-
const raw = readFileSync(this.path, "utf-8");
|
|
54
|
-
const parsed = JSON.parse(raw);
|
|
55
|
-
if (parsed?.version !== SCHEMA_VERSION) {
|
|
56
|
-
log.warn(TAG, `state file has version ${parsed?.version}, expected ${SCHEMA_VERSION} — starting fresh`);
|
|
57
|
-
return emptyState();
|
|
58
|
-
}
|
|
59
|
-
return {
|
|
60
|
-
version: SCHEMA_VERSION,
|
|
61
|
-
daemonId: parsed.daemonId ?? null,
|
|
62
|
-
daemonPid: parsed.daemonPid ?? null,
|
|
63
|
-
daemonStartedAt: parsed.daemonStartedAt ?? null,
|
|
64
|
-
runs: parsed.runs ?? [],
|
|
65
|
-
cards: parsed.cards ?? [],
|
|
66
|
-
daily: parsed.daily ?? [],
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
log.error(TAG, `failed to read state file: ${err instanceof Error ? err.message : err}`);
|
|
71
|
-
return emptyState();
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
persist() {
|
|
75
|
-
const fire = async () => {
|
|
76
|
-
const tmp = `${this.path}.tmp`;
|
|
77
|
-
const data = JSON.stringify(this.state, null, 2);
|
|
78
|
-
writeFileSync(tmp, data, "utf-8");
|
|
79
|
-
renameSync(tmp, this.path);
|
|
80
|
-
};
|
|
81
|
-
this.writeQueue = this.writeQueue.then(fire, fire);
|
|
82
|
-
return this.writeQueue;
|
|
83
|
-
}
|
|
84
|
-
/** Await any pending writes. Useful for tests and shutdown. */
|
|
85
|
-
flush() {
|
|
86
|
-
return this.writeQueue;
|
|
87
|
-
}
|
|
88
|
-
// ---------- Daemon metadata ----------
|
|
89
|
-
setDaemon(daemonId, pid) {
|
|
90
|
-
this.state.daemonId = daemonId;
|
|
91
|
-
this.state.daemonPid = pid;
|
|
92
|
-
this.state.daemonStartedAt = Date.now();
|
|
93
|
-
return this.persist();
|
|
94
|
-
}
|
|
95
|
-
getDaemon() {
|
|
96
|
-
return {
|
|
97
|
-
daemonId: this.state.daemonId,
|
|
98
|
-
daemonPid: this.state.daemonPid,
|
|
99
|
-
daemonStartedAt: this.state.daemonStartedAt,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
// ---------- Runs ----------
|
|
103
|
-
insertRun(run) {
|
|
104
|
-
this.state.runs.push(run);
|
|
105
|
-
return this.persist();
|
|
106
|
-
}
|
|
107
|
-
updateRun(runId, patch) {
|
|
108
|
-
const idx = this.state.runs.findIndex((r) => r.runId === runId);
|
|
109
|
-
if (idx === -1)
|
|
110
|
-
return Promise.resolve();
|
|
111
|
-
this.state.runs[idx] = { ...this.state.runs[idx], ...patch };
|
|
112
|
-
return this.persist();
|
|
113
|
-
}
|
|
114
|
-
heartbeat(runId) {
|
|
115
|
-
return this.updateRun(runId, { lastHeartbeatAt: Date.now() });
|
|
116
|
-
}
|
|
117
|
-
endRun(runId, status, errorMessage) {
|
|
118
|
-
const patch = {
|
|
119
|
-
status,
|
|
120
|
-
endedAt: Date.now(),
|
|
121
|
-
};
|
|
122
|
-
if (errorMessage !== undefined)
|
|
123
|
-
patch.errorMessage = errorMessage;
|
|
124
|
-
return this.updateRun(runId, patch);
|
|
125
|
-
}
|
|
126
|
-
getRun(runId) {
|
|
127
|
-
return this.state.runs.find((r) => r.runId === runId) ?? null;
|
|
128
|
-
}
|
|
129
|
-
getActiveRuns() {
|
|
130
|
-
return this.state.runs.filter((r) => r.endedAt === null);
|
|
131
|
-
}
|
|
132
|
-
getRunsForCard(cardId) {
|
|
133
|
-
return this.state.runs.filter((r) => r.cardId === cardId);
|
|
134
|
-
}
|
|
135
|
-
/** Trim completed runs older than `beforeTs` to keep the file small. */
|
|
136
|
-
purgeOldRuns(beforeTs) {
|
|
137
|
-
this.state.runs = this.state.runs.filter((r) => r.endedAt === null || r.endedAt >= beforeTs);
|
|
138
|
-
return this.persist();
|
|
139
|
-
}
|
|
140
|
-
// ---------- Cards ----------
|
|
141
|
-
ensureCard(cardId) {
|
|
142
|
-
let rec = this.state.cards.find((c) => c.cardId === cardId);
|
|
143
|
-
if (!rec) {
|
|
144
|
-
rec = {
|
|
145
|
-
cardId,
|
|
146
|
-
attempts: 0,
|
|
147
|
-
totalCostCents: 0,
|
|
148
|
-
lastAttemptAt: null,
|
|
149
|
-
lastOutcome: null,
|
|
150
|
-
};
|
|
151
|
-
this.state.cards.push(rec);
|
|
152
|
-
}
|
|
153
|
-
return rec;
|
|
154
|
-
}
|
|
155
|
-
getCard(cardId) {
|
|
156
|
-
return this.state.cards.find((c) => c.cardId === cardId) ?? null;
|
|
157
|
-
}
|
|
158
|
-
async incrementAttempt(cardId) {
|
|
159
|
-
const rec = this.ensureCard(cardId);
|
|
160
|
-
rec.attempts += 1;
|
|
161
|
-
rec.lastAttemptAt = Date.now();
|
|
162
|
-
await this.persist();
|
|
163
|
-
return rec.attempts;
|
|
164
|
-
}
|
|
165
|
-
async recordOutcome(cardId, outcome) {
|
|
166
|
-
const rec = this.ensureCard(cardId);
|
|
167
|
-
rec.lastOutcome = outcome;
|
|
168
|
-
if (outcome === "success")
|
|
169
|
-
rec.attempts = 0;
|
|
170
|
-
await this.persist();
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Reset a card's attempt counter so a card that previously gave up
|
|
174
|
-
* (attempts >= maxAttemptsPerCard) becomes eligible again. Called when a
|
|
175
|
-
* card is unassigned/reassigned — reassignment is the human's "try again".
|
|
176
|
-
* No-op for an unknown card.
|
|
177
|
-
*/
|
|
178
|
-
async resetAttempts(cardId) {
|
|
179
|
-
const rec = this.getCard(cardId);
|
|
180
|
-
if (!rec || rec.attempts === 0)
|
|
181
|
-
return;
|
|
182
|
-
rec.attempts = 0;
|
|
183
|
-
await this.persist();
|
|
184
|
-
}
|
|
185
|
-
/**
|
|
186
|
-
* Push a failure summary onto the card's bounded history (most-recent first,
|
|
187
|
-
* capped at 5). Read back by the give-up comment and the Agent History
|
|
188
|
-
* UI section to give users a post-mortem trail across attempts.
|
|
189
|
-
*/
|
|
190
|
-
async recordFailureSummary(cardId, entry) {
|
|
191
|
-
const rec = this.ensureCard(cardId);
|
|
192
|
-
const next = {
|
|
193
|
-
summary: entry.summary.slice(0, 500),
|
|
194
|
-
ts: entry.ts ?? Date.now(),
|
|
195
|
-
reason: entry.reason,
|
|
196
|
-
recoveryBranch: entry.recoveryBranch,
|
|
197
|
-
};
|
|
198
|
-
const existing = rec.failureHistory ?? [];
|
|
199
|
-
rec.failureHistory = [next, ...existing].slice(0, 5);
|
|
200
|
-
await this.persist();
|
|
201
|
-
}
|
|
202
|
-
getRecentFailures(cardId, limit = 3) {
|
|
203
|
-
const rec = this.getCard(cardId);
|
|
204
|
-
if (!rec?.failureHistory)
|
|
205
|
-
return [];
|
|
206
|
-
return rec.failureHistory.slice(0, limit);
|
|
207
|
-
}
|
|
208
|
-
async addCost(cardId, cents) {
|
|
209
|
-
if (cents <= 0)
|
|
210
|
-
return;
|
|
211
|
-
const rec = this.ensureCard(cardId);
|
|
212
|
-
rec.totalCostCents += cents;
|
|
213
|
-
const today = todayUtc();
|
|
214
|
-
let daily = this.state.daily.find((d) => d.date === today);
|
|
215
|
-
if (!daily) {
|
|
216
|
-
daily = { date: today, costCents: 0 };
|
|
217
|
-
this.state.daily.push(daily);
|
|
218
|
-
}
|
|
219
|
-
daily.costCents += cents;
|
|
220
|
-
const cutoff = new Date(Date.now() - 30 * 86_400_000)
|
|
221
|
-
.toISOString()
|
|
222
|
-
.slice(0, 10);
|
|
223
|
-
this.state.daily = this.state.daily.filter((d) => d.date >= cutoff);
|
|
224
|
-
await this.persist();
|
|
225
|
-
}
|
|
226
|
-
getDailyCostCents(date) {
|
|
227
|
-
const key = date ?? todayUtc();
|
|
228
|
-
return this.state.daily.find((d) => d.date === key)?.costCents ?? 0;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Feed a minimal Claude-CLI stream-json fixture through StreamParser and
|
|
3
|
-
* assert the expected events fire. Intended as a startup canary: if the CLI
|
|
4
|
-
* ever changes its envelope shape, the daemon fails loud on boot instead of
|
|
5
|
-
* silently parking every card with a parse error (see card #128).
|
|
6
|
-
*
|
|
7
|
-
* Throws on any missing event; returns silently on success.
|
|
8
|
-
*/
|
|
9
|
-
export declare function verifyStreamParserFormat(): void;
|