@gethmy/agent 1.0.0 → 1.0.2
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 +7 -6
- package/dist/board-helpers.d.ts +31 -0
- package/dist/board-helpers.js +150 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2 -11761
- package/dist/completion.d.ts +14 -0
- package/dist/completion.js +142 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +91 -0
- package/dist/git-pr.d.ts +25 -0
- package/dist/git-pr.js +305 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +169 -11730
- package/dist/log.d.ts +10 -0
- package/dist/log.js +35 -0
- package/dist/merge-monitor.d.ts +23 -0
- package/dist/merge-monitor.js +167 -0
- package/dist/pm.d.ts +14 -0
- package/dist/pm.js +63 -0
- package/dist/pool.d.ts +40 -0
- package/dist/pool.js +157 -0
- package/dist/progress-tracker.d.ts +64 -0
- package/dist/progress-tracker.js +361 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +40 -0
- package/dist/queue.d.ts +37 -0
- package/dist/queue.js +96 -0
- package/dist/reconcile.d.ts +21 -0
- package/dist/reconcile.js +114 -0
- package/dist/review-completion.d.ts +31 -0
- package/dist/review-completion.js +253 -0
- package/dist/review-knowledge.d.ts +14 -0
- package/dist/review-knowledge.js +89 -0
- package/dist/review-prompt.d.ts +12 -0
- package/dist/review-prompt.js +103 -0
- package/dist/review-worker.d.ts +46 -0
- package/dist/review-worker.js +437 -0
- package/dist/review-worktree.d.ts +12 -0
- package/dist/review-worktree.js +83 -0
- package/dist/stream-parser.d.ts +31 -0
- package/dist/stream-parser.js +95 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +56 -0
- package/dist/verification.d.ts +16 -0
- package/dist/verification.js +251 -0
- package/dist/watcher.d.ts +27 -0
- package/dist/watcher.js +74 -0
- package/dist/worker.d.ts +43 -0
- package/dist/worker.js +327 -0
- package/dist/worktree.d.ts +13 -0
- package/dist/worktree.js +115 -0
- package/package.json +8 -7
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging for the agent daemon.
|
|
3
|
+
* Always logs to stderr so stdout stays clean for potential piping.
|
|
4
|
+
*/
|
|
5
|
+
export declare const log: {
|
|
6
|
+
info(tag: string, msg: string): void;
|
|
7
|
+
warn(tag: string, msg: string): void;
|
|
8
|
+
error(tag: string, msg: string): void;
|
|
9
|
+
debug(tag: string, msg: string): void;
|
|
10
|
+
};
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging for the agent daemon.
|
|
3
|
+
* Always logs to stderr so stdout stays clean for potential piping.
|
|
4
|
+
*/
|
|
5
|
+
const COLORS = {
|
|
6
|
+
reset: "\x1b[0m",
|
|
7
|
+
dim: "\x1b[2m",
|
|
8
|
+
red: "\x1b[31m",
|
|
9
|
+
green: "\x1b[32m",
|
|
10
|
+
yellow: "\x1b[33m",
|
|
11
|
+
blue: "\x1b[34m",
|
|
12
|
+
cyan: "\x1b[36m",
|
|
13
|
+
};
|
|
14
|
+
function timestamp() {
|
|
15
|
+
return new Date().toISOString().slice(11, 23);
|
|
16
|
+
}
|
|
17
|
+
function fmt(level, color, tag, msg) {
|
|
18
|
+
return `${COLORS.dim}${timestamp()}${COLORS.reset} ${color}${level}${COLORS.reset} ${COLORS.cyan}[${tag}]${COLORS.reset} ${msg}`;
|
|
19
|
+
}
|
|
20
|
+
export const log = {
|
|
21
|
+
info(tag, msg) {
|
|
22
|
+
console.error(fmt("INFO ", COLORS.green, tag, msg));
|
|
23
|
+
},
|
|
24
|
+
warn(tag, msg) {
|
|
25
|
+
console.error(fmt("WARN ", COLORS.yellow, tag, msg));
|
|
26
|
+
},
|
|
27
|
+
error(tag, msg) {
|
|
28
|
+
console.error(fmt("ERROR", COLORS.red, tag, msg));
|
|
29
|
+
},
|
|
30
|
+
debug(tag, msg) {
|
|
31
|
+
if (process.env.DEBUG) {
|
|
32
|
+
console.error(fmt("DEBUG", COLORS.dim, tag, msg));
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
+
import type { AgentConfig } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Polls "Ready to Merge" cards and detects when their PRs get merged,
|
|
5
|
+
* then moves the card to Done and swaps labels.
|
|
6
|
+
*/
|
|
7
|
+
export declare class MergeMonitor {
|
|
8
|
+
private client;
|
|
9
|
+
private projectId;
|
|
10
|
+
private config;
|
|
11
|
+
private intervalMs;
|
|
12
|
+
private timer;
|
|
13
|
+
private running;
|
|
14
|
+
private readonly provider;
|
|
15
|
+
private readonly cwd;
|
|
16
|
+
constructor(client: HarmonyApiClient, projectId: string, config: AgentConfig, intervalMs?: number);
|
|
17
|
+
start(): void;
|
|
18
|
+
stop(): void;
|
|
19
|
+
/** Schedule the next tick after `delayMs`, avoiding overlapping ticks. */
|
|
20
|
+
private scheduleNext;
|
|
21
|
+
private tick;
|
|
22
|
+
private completeMergedCard;
|
|
23
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { addLabelByName, buildLabelMap, hasLabel, moveCardToColumn, resolveCardLabels, } from "./board-helpers.js";
|
|
4
|
+
import { checkPrMergeStatus, detectGitProvider, extractPrUrl, } from "./git-pr.js";
|
|
5
|
+
import { log } from "./log.js";
|
|
6
|
+
import { extractBranchFromDescription } from "./review-worktree.js";
|
|
7
|
+
const TAG = "merge-monitor";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
/**
|
|
10
|
+
* Polls "Ready to Merge" cards and detects when their PRs get merged,
|
|
11
|
+
* then moves the card to Done and swaps labels.
|
|
12
|
+
*/
|
|
13
|
+
export class MergeMonitor {
|
|
14
|
+
client;
|
|
15
|
+
projectId;
|
|
16
|
+
config;
|
|
17
|
+
intervalMs;
|
|
18
|
+
timer = null;
|
|
19
|
+
running = false;
|
|
20
|
+
provider;
|
|
21
|
+
cwd;
|
|
22
|
+
constructor(client, projectId, config, intervalMs = 60_000) {
|
|
23
|
+
this.client = client;
|
|
24
|
+
this.projectId = projectId;
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.intervalMs = intervalMs;
|
|
27
|
+
this.provider = detectGitProvider();
|
|
28
|
+
this.cwd = process.cwd();
|
|
29
|
+
}
|
|
30
|
+
start() {
|
|
31
|
+
this.running = true;
|
|
32
|
+
log.info(TAG, `Merge monitor every ${this.intervalMs / 1000}s`);
|
|
33
|
+
void this.scheduleNext(0);
|
|
34
|
+
}
|
|
35
|
+
stop() {
|
|
36
|
+
this.running = false;
|
|
37
|
+
if (this.timer) {
|
|
38
|
+
clearTimeout(this.timer);
|
|
39
|
+
this.timer = null;
|
|
40
|
+
}
|
|
41
|
+
log.info(TAG, "Merge monitor stopped");
|
|
42
|
+
}
|
|
43
|
+
/** Schedule the next tick after `delayMs`, avoiding overlapping ticks. */
|
|
44
|
+
async scheduleNext(delayMs) {
|
|
45
|
+
await new Promise((resolve) => {
|
|
46
|
+
this.timer = setTimeout(() => resolve(), delayMs);
|
|
47
|
+
});
|
|
48
|
+
if (!this.running)
|
|
49
|
+
return; // stopped while waiting
|
|
50
|
+
await this.tick();
|
|
51
|
+
if (this.running) {
|
|
52
|
+
void this.scheduleNext(this.intervalMs);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async tick() {
|
|
56
|
+
try {
|
|
57
|
+
const board = await this.client.getBoard(this.projectId, {
|
|
58
|
+
labelName: this.config.review.approvedLabel,
|
|
59
|
+
});
|
|
60
|
+
const cards = (board.cards ?? []);
|
|
61
|
+
const columns = (board.columns ?? []);
|
|
62
|
+
// Build label lookup (id → Label) to resolve card.labelIds
|
|
63
|
+
const labelMap = buildLabelMap((board.labels ?? []));
|
|
64
|
+
// Find review column IDs
|
|
65
|
+
const reviewColumnIds = new Set(columns
|
|
66
|
+
.filter((c) => this.config.review.pickupColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
|
|
67
|
+
.map((c) => c.id));
|
|
68
|
+
// Cards are already pre-filtered by the approved label server-side via
|
|
69
|
+
// the `labelName` param. The column + label checks below are kept as
|
|
70
|
+
// defense-in-depth against API changes or stale cache.
|
|
71
|
+
const approvedLabel = this.config.review.approvedLabel;
|
|
72
|
+
const candidatesWithLabels = [];
|
|
73
|
+
for (const c of cards) {
|
|
74
|
+
if (c.archived_at || !reviewColumnIds.has(c.column_id))
|
|
75
|
+
continue;
|
|
76
|
+
const labels = resolveCardLabels(c, labelMap);
|
|
77
|
+
if (hasLabel(labels, approvedLabel)) {
|
|
78
|
+
candidatesWithLabels.push({ card: c, labels });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (candidatesWithLabels.length === 0) {
|
|
82
|
+
log.debug(TAG, "No Ready to Merge cards found");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Process max 5 per tick to respect rate limits
|
|
86
|
+
const batch = candidatesWithLabels.slice(0, 5);
|
|
87
|
+
log.debug(TAG, `Checking ${batch.length} Ready to Merge card(s)`);
|
|
88
|
+
// Check PR states concurrently (async, non-blocking)
|
|
89
|
+
const results = await Promise.allSettled(batch.map(async ({ card, labels }) => {
|
|
90
|
+
const prUrl = extractPrUrl(card.description ?? null);
|
|
91
|
+
if (!prUrl) {
|
|
92
|
+
log.debug(TAG, `#${card.short_id} has no PR URL — skipping`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const state = await checkPrMergeStatus(prUrl, this.cwd, this.provider);
|
|
96
|
+
if (state === "merged") {
|
|
97
|
+
log.info(TAG, `#${card.short_id} PR merged — completing`);
|
|
98
|
+
await this.completeMergedCard(card, labels);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
log.debug(TAG, `#${card.short_id} PR state: ${state}`);
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
for (const r of results) {
|
|
105
|
+
if (r.status === "rejected") {
|
|
106
|
+
log.warn(TAG, `Card processing failed: ${r.reason}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
log.error(TAG, `Tick failed: ${err instanceof Error ? err.message : err}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async completeMergedCard(card, resolvedLabels) {
|
|
115
|
+
// 1. Move to Done — bail if this fails since subsequent steps assume card is in Done
|
|
116
|
+
try {
|
|
117
|
+
await moveCardToColumn(this.client, card, this.config.review.moveToColumn);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
log.error(TAG, `Failed to move #${card.short_id} to Done: ${err instanceof Error ? err.message : err}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// 2. Add "Merged" label
|
|
124
|
+
await addLabelByName(this.client, card, this.config.review.mergedLabel, this.config.review.mergedLabelColor);
|
|
125
|
+
// 3. Remove "Ready to Merge" label
|
|
126
|
+
const approvedLabelName = this.config.review.approvedLabel.toLowerCase();
|
|
127
|
+
const approvedLabelObj = resolvedLabels.find((l) => l.name.toLowerCase() === approvedLabelName);
|
|
128
|
+
if (approvedLabelObj) {
|
|
129
|
+
try {
|
|
130
|
+
await this.client.removeLabelFromCard(card.id, approvedLabelObj.id);
|
|
131
|
+
log.info(TAG, `Removed "${this.config.review.approvedLabel}" from #${card.short_id}`);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
log.warn(TAG, `Failed to remove label: ${err instanceof Error ? err.message : err}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// 4. Mark done + append merge timestamp to description (idempotent)
|
|
138
|
+
const existing = card.description || "";
|
|
139
|
+
const alreadyStamped = existing.includes("Merged at");
|
|
140
|
+
try {
|
|
141
|
+
const update = { done: true };
|
|
142
|
+
if (!alreadyStamped) {
|
|
143
|
+
const timestamp = new Date().toISOString();
|
|
144
|
+
const separator = existing ? "\n" : "";
|
|
145
|
+
update.description = `${existing}${separator}Merged at ${timestamp}`;
|
|
146
|
+
}
|
|
147
|
+
await this.client.updateCard(card.id, update);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
log.warn(TAG, `Failed to update card: ${err instanceof Error ? err.message : err}`);
|
|
151
|
+
}
|
|
152
|
+
// 5. Best-effort: clean up local branch (uses shared extraction with validation)
|
|
153
|
+
const branchName = extractBranchFromDescription(card.description);
|
|
154
|
+
if (branchName) {
|
|
155
|
+
try {
|
|
156
|
+
await execFileAsync("git", ["branch", "-D", "--", branchName], {
|
|
157
|
+
cwd: this.cwd,
|
|
158
|
+
});
|
|
159
|
+
log.info(TAG, `Deleted local branch ${branchName}`);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// best-effort
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
log.info(TAG, `#${card.short_id} completed (merged)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/dist/pm.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
|
|
2
|
+
/**
|
|
3
|
+
* Detect the package manager based on lockfiles in the repo root.
|
|
4
|
+
*/
|
|
5
|
+
export declare function detectPackageManager(): PackageManager;
|
|
6
|
+
/**
|
|
7
|
+
* Return the install command string for the detected package manager.
|
|
8
|
+
*/
|
|
9
|
+
export declare function installCommand(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Return [cmd, args[]] suitable for spawn() or execFileSync() to run a package script.
|
|
12
|
+
* Adds `--` separator for npm and pnpm when extra args are present.
|
|
13
|
+
*/
|
|
14
|
+
export declare function spawnRunArgs(script: string, ...extra: string[]): [string, string[]];
|
package/dist/pm.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { log } from "./log.js";
|
|
4
|
+
const TAG = "pm";
|
|
5
|
+
let cached = null;
|
|
6
|
+
/**
|
|
7
|
+
* Detect the package manager based on lockfiles in the repo root.
|
|
8
|
+
*/
|
|
9
|
+
export function detectPackageManager() {
|
|
10
|
+
if (cached)
|
|
11
|
+
return cached;
|
|
12
|
+
let repoRoot;
|
|
13
|
+
try {
|
|
14
|
+
repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
15
|
+
encoding: "utf-8",
|
|
16
|
+
}).trim();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
repoRoot = process.cwd();
|
|
20
|
+
}
|
|
21
|
+
if (existsSync(`${repoRoot}/bun.lock`) ||
|
|
22
|
+
existsSync(`${repoRoot}/bun.lockb`)) {
|
|
23
|
+
cached = "bun";
|
|
24
|
+
}
|
|
25
|
+
else if (existsSync(`${repoRoot}/pnpm-lock.yaml`)) {
|
|
26
|
+
cached = "pnpm";
|
|
27
|
+
}
|
|
28
|
+
else if (existsSync(`${repoRoot}/yarn.lock`)) {
|
|
29
|
+
cached = "yarn";
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
cached = "npm";
|
|
33
|
+
}
|
|
34
|
+
log.info(TAG, `Detected package manager: ${cached}`);
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Return the install command string for the detected package manager.
|
|
39
|
+
*/
|
|
40
|
+
export function installCommand() {
|
|
41
|
+
const pm = detectPackageManager();
|
|
42
|
+
switch (pm) {
|
|
43
|
+
case "bun":
|
|
44
|
+
return "bun install --frozen-lockfile";
|
|
45
|
+
case "pnpm":
|
|
46
|
+
return "pnpm install --frozen-lockfile";
|
|
47
|
+
case "yarn":
|
|
48
|
+
return "yarn install --frozen-lockfile";
|
|
49
|
+
case "npm":
|
|
50
|
+
return "npm ci";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Return [cmd, args[]] suitable for spawn() or execFileSync() to run a package script.
|
|
55
|
+
* Adds `--` separator for npm and pnpm when extra args are present.
|
|
56
|
+
*/
|
|
57
|
+
export function spawnRunArgs(script, ...extra) {
|
|
58
|
+
const pm = detectPackageManager();
|
|
59
|
+
if (extra.length > 0 && (pm === "npm" || pm === "pnpm")) {
|
|
60
|
+
return [pm, ["run", script, "--", ...extra]];
|
|
61
|
+
}
|
|
62
|
+
return [pm, ["run", script, ...extra]];
|
|
63
|
+
}
|
package/dist/pool.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
+
import type { Card, Column, Label, Subtask } from "@harmony/shared";
|
|
3
|
+
import type { AgentConfig, WorkMode } from "./types.js";
|
|
4
|
+
export declare class Pool {
|
|
5
|
+
private implWorkers;
|
|
6
|
+
private reviewWorkers;
|
|
7
|
+
private implQueue;
|
|
8
|
+
private reviewQueue;
|
|
9
|
+
constructor(config: AgentConfig, client: HarmonyApiClient, userEmail: string);
|
|
10
|
+
/**
|
|
11
|
+
* Enqueue a card for processing with the given mode.
|
|
12
|
+
*/
|
|
13
|
+
enqueue(card: Card, column: Column, labels: Label[], subtasks: Subtask[], mode?: WorkMode): void;
|
|
14
|
+
/**
|
|
15
|
+
* Remove a card from any queue or cancel an active worker.
|
|
16
|
+
*/
|
|
17
|
+
removeCard(cardId: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Check if a card is currently being worked on by any worker.
|
|
20
|
+
*/
|
|
21
|
+
isCardActive(cardId: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Check if a card is known to the pool (queued or active).
|
|
24
|
+
*/
|
|
25
|
+
isCardKnown(cardId: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Get all card IDs that are either queued or active.
|
|
28
|
+
*/
|
|
29
|
+
knownCardIds(): Set<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Handle an agent command (pause/resume/stop) for a specific card.
|
|
32
|
+
*/
|
|
33
|
+
handleAgentCommand(cardId: string, command: "pause" | "resume" | "stop"): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Gracefully shutdown all workers.
|
|
36
|
+
*/
|
|
37
|
+
shutdown(): Promise<void>;
|
|
38
|
+
private cardDataCache;
|
|
39
|
+
private tryDispatchFor;
|
|
40
|
+
}
|
package/dist/pool.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
import { PriorityQueue } from "./queue.js";
|
|
3
|
+
import { ReviewWorker } from "./review-worker.js";
|
|
4
|
+
import { Worker } from "./worker.js";
|
|
5
|
+
const TAG = "pool";
|
|
6
|
+
export class Pool {
|
|
7
|
+
implWorkers = [];
|
|
8
|
+
reviewWorkers = [];
|
|
9
|
+
implQueue;
|
|
10
|
+
reviewQueue;
|
|
11
|
+
constructor(config, client, userEmail) {
|
|
12
|
+
this.implQueue = new PriorityQueue(config);
|
|
13
|
+
this.reviewQueue = new PriorityQueue(config);
|
|
14
|
+
// Create implementation workers
|
|
15
|
+
for (let i = 0; i < config.poolSize; i++) {
|
|
16
|
+
this.implWorkers.push(new Worker(i, config, client, userEmail, () => {
|
|
17
|
+
this.tryDispatchFor(this.implWorkers, this.implQueue, "impl");
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
// Create review worker(s) — 1 review worker per pool
|
|
21
|
+
if (config.review.enabled) {
|
|
22
|
+
const reviewWorkerId = config.poolSize; // offset to avoid ID collision
|
|
23
|
+
this.reviewWorkers.push(new ReviewWorker(reviewWorkerId, config, client, userEmail, () => {
|
|
24
|
+
this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
const reviewCount = this.reviewWorkers.length;
|
|
28
|
+
log.info(TAG, `Pool initialized: ${config.poolSize} impl worker(s), ${reviewCount} review worker(s)`);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Enqueue a card for processing with the given mode.
|
|
32
|
+
*/
|
|
33
|
+
enqueue(card, column, labels, subtasks, mode = "implement") {
|
|
34
|
+
// Don't enqueue if already in any queue or actively being worked on
|
|
35
|
+
if (this.implQueue.has(card.id) ||
|
|
36
|
+
this.reviewQueue.has(card.id) ||
|
|
37
|
+
this.isCardActive(card.id)) {
|
|
38
|
+
log.debug(TAG, `Card ${card.id} already queued or active, skipping`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const queue = mode === "review" ? this.reviewQueue : this.implQueue;
|
|
42
|
+
queue.enqueue(card, column, labels, mode);
|
|
43
|
+
// Store card data for when it gets dispatched
|
|
44
|
+
this.cardDataCache.set(card.id, { card, column, labels, subtasks, mode });
|
|
45
|
+
const workers = mode === "review" ? this.reviewWorkers : this.implWorkers;
|
|
46
|
+
this.tryDispatchFor(workers, queue, mode);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove a card from any queue or cancel an active worker.
|
|
50
|
+
*/
|
|
51
|
+
async removeCard(cardId) {
|
|
52
|
+
// Try both queues
|
|
53
|
+
for (const queue of [this.implQueue, this.reviewQueue]) {
|
|
54
|
+
const removed = queue.remove(cardId);
|
|
55
|
+
if (removed) {
|
|
56
|
+
this.cardDataCache.delete(cardId);
|
|
57
|
+
log.info(TAG, `Removed #${removed.shortId} from ${removed.mode} queue`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Cancel active worker (check impl first, then review)
|
|
62
|
+
const worker = this.implWorkers.find((w) => w.cardId === cardId) ??
|
|
63
|
+
this.reviewWorkers.find((w) => w.cardId === cardId);
|
|
64
|
+
if (worker) {
|
|
65
|
+
log.info(TAG, `Cancelling worker ${worker.id} for card ${cardId}`);
|
|
66
|
+
await worker.cancel();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check if a card is currently being worked on by any worker.
|
|
71
|
+
*/
|
|
72
|
+
isCardActive(cardId) {
|
|
73
|
+
return (this.implWorkers.some((w) => w.cardId === cardId && w.isActive) ||
|
|
74
|
+
this.reviewWorkers.some((w) => w.cardId === cardId && w.isActive));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Check if a card is known to the pool (queued or active).
|
|
78
|
+
*/
|
|
79
|
+
isCardKnown(cardId) {
|
|
80
|
+
return (this.implQueue.has(cardId) ||
|
|
81
|
+
this.reviewQueue.has(cardId) ||
|
|
82
|
+
this.isCardActive(cardId));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get all card IDs that are either queued or active.
|
|
86
|
+
*/
|
|
87
|
+
knownCardIds() {
|
|
88
|
+
const ids = new Set([
|
|
89
|
+
...this.implQueue.cardIds(),
|
|
90
|
+
...this.reviewQueue.cardIds(),
|
|
91
|
+
]);
|
|
92
|
+
for (const w of this.implWorkers) {
|
|
93
|
+
if (w.cardId)
|
|
94
|
+
ids.add(w.cardId);
|
|
95
|
+
}
|
|
96
|
+
for (const w of this.reviewWorkers) {
|
|
97
|
+
if (w.cardId)
|
|
98
|
+
ids.add(w.cardId);
|
|
99
|
+
}
|
|
100
|
+
return ids;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Handle an agent command (pause/resume/stop) for a specific card.
|
|
104
|
+
*/
|
|
105
|
+
async handleAgentCommand(cardId, command) {
|
|
106
|
+
const worker = this.implWorkers.find((w) => w.cardId === cardId && w.isActive) ??
|
|
107
|
+
this.reviewWorkers.find((w) => w.cardId === cardId && w.isActive);
|
|
108
|
+
if (!worker) {
|
|
109
|
+
log.debug(TAG, `No active worker for card ${cardId}, ignoring ${command}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
log.info(TAG, `Agent command: ${command} → worker ${worker.id} (card ${cardId})`);
|
|
113
|
+
switch (command) {
|
|
114
|
+
case "pause":
|
|
115
|
+
await worker.pause();
|
|
116
|
+
break;
|
|
117
|
+
case "resume":
|
|
118
|
+
await worker.resume();
|
|
119
|
+
break;
|
|
120
|
+
case "stop":
|
|
121
|
+
await worker.cancel();
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Gracefully shutdown all workers.
|
|
127
|
+
*/
|
|
128
|
+
async shutdown() {
|
|
129
|
+
log.info(TAG, "Shutting down pool...");
|
|
130
|
+
const active = [
|
|
131
|
+
...this.implWorkers.filter((w) => w.isActive),
|
|
132
|
+
...this.reviewWorkers.filter((w) => w.isActive),
|
|
133
|
+
];
|
|
134
|
+
await Promise.all(active.map((w) => w.cancel()));
|
|
135
|
+
log.info(TAG, "Pool shutdown complete");
|
|
136
|
+
}
|
|
137
|
+
// --- Private ---
|
|
138
|
+
cardDataCache = new Map();
|
|
139
|
+
tryDispatchFor(workers, queue, label) {
|
|
140
|
+
const idle = workers.find((w) => w.isIdle);
|
|
141
|
+
if (!idle) {
|
|
142
|
+
log.debug(TAG, `No idle ${label} workers (queue: ${queue.length})`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const next = queue.dequeue();
|
|
146
|
+
if (!next)
|
|
147
|
+
return;
|
|
148
|
+
const data = this.cardDataCache.get(next.cardId);
|
|
149
|
+
if (!data) {
|
|
150
|
+
log.warn(TAG, `No cached data for card ${next.cardId}, skipping`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.cardDataCache.delete(next.cardId);
|
|
154
|
+
log.info(TAG, `Dispatching #${next.shortId} to ${label} worker ${idle.id}`);
|
|
155
|
+
idle.run(data.card, data.column, data.labels, data.subtasks);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
+
import type { CostUpdate, StreamParser } from "./stream-parser.js";
|
|
3
|
+
export declare class ProgressTracker {
|
|
4
|
+
private client;
|
|
5
|
+
private cardId;
|
|
6
|
+
private workerId;
|
|
7
|
+
private phase;
|
|
8
|
+
private progress;
|
|
9
|
+
private toolCallCount;
|
|
10
|
+
private hasEdited;
|
|
11
|
+
private lastUpdateAt;
|
|
12
|
+
private pendingUpdate;
|
|
13
|
+
private pendingTask;
|
|
14
|
+
private heartbeatTimer;
|
|
15
|
+
private stopped;
|
|
16
|
+
private lastAction;
|
|
17
|
+
private subtaskTotal;
|
|
18
|
+
private subtaskCompleted;
|
|
19
|
+
private subtaskMode;
|
|
20
|
+
private filesEdited;
|
|
21
|
+
private filesRead;
|
|
22
|
+
private lastCost;
|
|
23
|
+
private recentActions;
|
|
24
|
+
constructor(client: HarmonyApiClient, cardId: string, workerId: number, subtasks: {
|
|
25
|
+
completed: boolean;
|
|
26
|
+
}[]);
|
|
27
|
+
/**
|
|
28
|
+
* Wire up the parser events and start the heartbeat.
|
|
29
|
+
*/
|
|
30
|
+
attach(parser: StreamParser): void;
|
|
31
|
+
/**
|
|
32
|
+
* Stop all timers and flush any pending update.
|
|
33
|
+
*/
|
|
34
|
+
stop(): void;
|
|
35
|
+
/** Get a summary of the session stats. */
|
|
36
|
+
get stats(): {
|
|
37
|
+
filesEdited: number;
|
|
38
|
+
filesRead: number;
|
|
39
|
+
toolCalls: number;
|
|
40
|
+
cost: CostUpdate | null;
|
|
41
|
+
};
|
|
42
|
+
private onToolStart;
|
|
43
|
+
private onToolEnd;
|
|
44
|
+
private onText;
|
|
45
|
+
private transitionTo;
|
|
46
|
+
private incrementProgress;
|
|
47
|
+
private currentTaskLabel;
|
|
48
|
+
/**
|
|
49
|
+
* Build a human-readable description of what a tool call is doing.
|
|
50
|
+
*/
|
|
51
|
+
private describeToolAction;
|
|
52
|
+
/**
|
|
53
|
+
* Strip absolute paths to show only meaningful segments from src/ or packages/.
|
|
54
|
+
*/
|
|
55
|
+
private shortPath;
|
|
56
|
+
private scheduleUpdate;
|
|
57
|
+
private pushRecentAction;
|
|
58
|
+
private sendUpdate;
|
|
59
|
+
private startHeartbeat;
|
|
60
|
+
/**
|
|
61
|
+
* Safely extract a string property from an unknown tool input.
|
|
62
|
+
*/
|
|
63
|
+
private extractString;
|
|
64
|
+
}
|