@gethmy/agent 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,11 +36,12 @@ npx @gethmy/mcp setup
36
36
  ```jsonc
37
37
  {
38
38
  "agent": {
39
- "poolSize": 1,
39
+ "poolSize": 3,
40
40
  "pickupColumns": ["To Do"],
41
41
  "claude": { "model": "opus", "reviewModel": "sonnet" },
42
42
  "review": {
43
43
  "enabled": true,
44
+ "poolSize": 2,
44
45
  "pickupColumns": ["Review"],
45
46
  "moveToColumn": "Done",
46
47
  "failColumn": "To Do"
package/dist/budget.js CHANGED
@@ -103,9 +103,18 @@ export class BudgetGuard {
103
103
  log.warn(TAG, `#${card.short_id} DLQ'd — ${reason}: ${detail}`);
104
104
  }
105
105
  }
106
- const DLQ_MARKER = "---\n**Agent DLQ**";
106
+ const DLQ_FENCE_START = "<!-- agent-dlq:start -->";
107
+ const DLQ_FENCE_END = "<!-- agent-dlq:end -->";
108
+ // Legacy marker — pre-fence DLQ blocks written before 2026-05-23. Strip path
109
+ // only; new blocks always emit the fenced form.
110
+ const LEGACY_DLQ_MARKER = "---\n**Agent DLQ**";
107
111
  function buildDlqDescriptionBlock(reason, detail, failures) {
108
- const lines = [DLQ_MARKER, `Cap hit: ${reason} — ${detail}`];
112
+ const lines = [
113
+ DLQ_FENCE_START,
114
+ "---",
115
+ "**Agent DLQ**",
116
+ `Cap hit: ${reason} — ${detail}`,
117
+ ];
109
118
  if (failures.length > 0) {
110
119
  lines.push("", "Recent failures:");
111
120
  for (const f of failures) {
@@ -120,13 +129,32 @@ function buildDlqDescriptionBlock(reason, detail, failures) {
120
129
  else {
121
130
  lines.push("", "_No prior failure summaries recorded._");
122
131
  }
132
+ lines.push(DLQ_FENCE_END);
123
133
  return lines.join("\n");
124
134
  }
125
135
  function stripDlqBlock(description) {
126
- const idx = description.indexOf(DLQ_MARKER);
127
- if (idx < 0)
128
- return description.trimEnd();
129
- return description.slice(0, idx).trimEnd();
136
+ const start = description.indexOf(DLQ_FENCE_START);
137
+ if (start >= 0) {
138
+ const end = description.indexOf(DLQ_FENCE_END, start);
139
+ if (end < 0) {
140
+ // Malformed: opening fence with no closer. Treat the rest of the
141
+ // description as the block — safer than preserving an orphan fence.
142
+ return description.slice(0, start).trimEnd();
143
+ }
144
+ const prefix = description.slice(0, start).trimEnd();
145
+ const suffix = description
146
+ .slice(end + DLQ_FENCE_END.length)
147
+ .replace(/^\s+/, "");
148
+ if (prefix && suffix)
149
+ return `${prefix}\n\n${suffix}`;
150
+ return prefix || suffix;
151
+ }
152
+ // Legacy unfenced block — match the original behavior (no suffix to
153
+ // preserve, since the legacy emitter always wrote to end-of-description).
154
+ const legacy = description.indexOf(LEGACY_DLQ_MARKER);
155
+ if (legacy >= 0)
156
+ return description.slice(0, legacy).trimEnd();
157
+ return description.trimEnd();
130
158
  }
131
159
  function formatCents(cents) {
132
160
  return `$${(cents / 100).toFixed(2)}`;
@@ -29,4 +29,4 @@ export declare function buildTokenPayload(stats?: SessionStats | null): {
29
29
  /**
30
30
  * Post-work pipeline: push branch, create PR, move card, post summary.
31
31
  */
32
- export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId?: number, sessionStats?: SessionStats, workspaceId?: string, agentSessionId?: string | null, stateStore?: StateStore): Promise<void>;
32
+ export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId: number, sessionStats: SessionStats | undefined, workspaceId: string | undefined, agentSessionId: string | null | undefined, stateStore: StateStore): Promise<void>;
@@ -29,7 +29,7 @@ export function buildTokenPayload(stats) {
29
29
  /**
30
30
  * Post-work pipeline: push branch, create PR, move card, post summary.
31
31
  */
32
- export async function runCompletion(client, card, branchName, worktreePath, config, workerId = 0, sessionStats, workspaceId, agentSessionId, stateStore) {
32
+ export async function runCompletion(client, card, branchName, worktreePath, config, workerId, sessionStats, workspaceId, agentSessionId, stateStore) {
33
33
  // Hoisted so the episode write hook can read final verification state.
34
34
  let verificationResult = {
35
35
  passed: true,
@@ -54,16 +54,25 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
54
54
  // under `agent-attempts/*` for `failedAttemptRetentionDays`. Without this
55
55
  // ordering, verify failures used to orphan commits in a deleted worktree —
56
56
  // recoverable only via `git reflog`.
57
+ //
58
+ // `lastPushedSha` tracks the most recent commit we successfully pushed so
59
+ // later push points (post-fix, post-fail) can skip when HEAD hasn't moved.
57
60
  log.info(TAG, `Pushing branch ${branchName} (pre-verify)...`);
61
+ let lastPushedSha = null;
58
62
  try {
59
63
  pushBranch(branchName, worktreePath);
64
+ lastPushedSha = readHeadSha(worktreePath);
60
65
  }
61
66
  catch (err) {
62
67
  // Push failure shouldn't prevent verification from running, but the
63
68
  // safety guarantee is gone — surface it loudly so the operator notices.
64
69
  log.error(TAG, `pre-verify push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
65
70
  }
66
- const recoveryUrl = getBranchWebUrl(branchName, worktreePath);
71
+ // Only surface a recovery URL when the push actually landed — otherwise
72
+ // the link points at a ref that doesn't exist on origin.
73
+ const recoveryUrl = lastPushedSha
74
+ ? getBranchWebUrl(branchName, worktreePath)
75
+ : null;
67
76
  // 2. Verification gate
68
77
  if (config.verification.enabled) {
69
78
  await client.updateAgentProgress(card.id, {
@@ -91,12 +100,18 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
91
100
  autoFixAttempts = attempt + 1;
92
101
  if (result.passed) {
93
102
  log.info(TAG, `Auto-fix succeeded on attempt ${attempt + 1}`);
94
- // Push again so the auto-fix commit is also durable on origin.
95
- try {
96
- pushBranch(branchName, worktreePath);
97
- }
98
- catch (err) {
99
- log.warn(TAG, `post-fix push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
103
+ // Push again so any auto-fix commits are durable on origin. Skip
104
+ // when HEAD hasn't moved since the last successful push — autofix
105
+ // can pass without producing new commits.
106
+ const sha = readHeadSha(worktreePath);
107
+ if (sha && sha !== lastPushedSha) {
108
+ try {
109
+ pushBranch(branchName, worktreePath);
110
+ lastPushedSha = sha;
111
+ }
112
+ catch (err) {
113
+ log.warn(TAG, `post-fix push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
114
+ }
100
115
  }
101
116
  break;
102
117
  }
@@ -106,16 +121,22 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
106
121
  if (!result.passed) {
107
122
  log.warn(TAG, `Verification failed for #${card.short_id} — reporting findings`);
108
123
  // Push the latest tip (including any auto-fix attempts) so the
109
- // failed branch on origin reflects what verify saw.
110
- try {
111
- pushBranch(branchName, worktreePath);
112
- }
113
- catch (err) {
114
- log.warn(TAG, `post-fail push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
124
+ // failed branch on origin reflects what verify saw. Skip when HEAD
125
+ // hasn't advanced since the last successful push — common when
126
+ // every auto-fix attempt produced no new commits.
127
+ const failSha = readHeadSha(worktreePath);
128
+ if (failSha && failSha !== lastPushedSha) {
129
+ try {
130
+ pushBranch(branchName, worktreePath);
131
+ lastPushedSha = failSha;
132
+ }
133
+ catch (err) {
134
+ log.warn(TAG, `post-fail push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
135
+ }
115
136
  }
116
137
  const failureSummary = buildVerificationFailureSummary(result, autoFixAttempts);
117
138
  try {
118
- await stateStore?.recordFailureSummary(card.id, {
139
+ await stateStore.recordFailureSummary(card.id, {
119
140
  summary: failureSummary,
120
141
  reason: "verification",
121
142
  recoveryBranch: branchName,
@@ -124,10 +145,10 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
124
145
  catch (err) {
125
146
  log.debug(TAG, `recordFailureSummary failed: ${err instanceof Error ? err.message : err}`);
126
147
  }
127
- await reportFindings(client, card.id, result, {
128
- branchName,
129
- branchUrl: recoveryUrl,
130
- });
148
+ // Only attach recovery instructions when the branch actually exists
149
+ // on origin — otherwise the `git fetch && git checkout` command we'd
150
+ // surface would fail for the user.
151
+ await reportFindings(client, card.id, result, lastPushedSha ? { branchName, branchUrl: recoveryUrl } : null);
131
152
  await moveCardToColumn(client, card, config.verification.failColumn);
132
153
  await client.endAgentSession(card.id, {
133
154
  status: "failed",
@@ -204,6 +225,17 @@ function buildVerificationFailureSummary(result, autoFixAttempts) {
204
225
  const tail = autoFixAttempts > 0 ? ` after ${autoFixAttempts} auto-fix attempt(s)` : "";
205
226
  return `${head}${tail}`;
206
227
  }
228
+ function readHeadSha(worktreePath) {
229
+ try {
230
+ return execFileSync("git", ["rev-parse", "HEAD"], {
231
+ cwd: worktreePath,
232
+ encoding: "utf-8",
233
+ }).trim();
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
207
239
  function checkHasCommits(worktreePath, baseBranch) {
208
240
  try {
209
241
  const count = execFileSync("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
package/dist/git-pr.js CHANGED
@@ -158,7 +158,28 @@ export function remoteBranchExists(branchName, cwd) {
158
158
  export function pushBranch(branchName, cwd) {
159
159
  if (remoteBranchExists(branchName, cwd)) {
160
160
  log.info(TAG, `Remote branch ${branchName} exists (rework), force-pushing`);
161
- execFileSync("git", ["push", "--force-with-lease", "-u", "origin", branchName], { cwd, stdio: "pipe" });
161
+ // Resolve the remote tip explicitly so the lease anchors to a known SHA.
162
+ // Bare `--force-with-lease` only checks against the local tracking ref,
163
+ // which can be stale if it was never fetched in this worktree — that
164
+ // lets a concurrent update slip through. Fetch + pin the expected SHA.
165
+ let expectedSha = null;
166
+ try {
167
+ execFileSync("git", ["fetch", "origin", branchName], {
168
+ cwd,
169
+ stdio: "pipe",
170
+ });
171
+ expectedSha = execFileSync("git", ["rev-parse", `refs/remotes/origin/${branchName}`], { cwd, encoding: "utf-8" }).trim();
172
+ }
173
+ catch (err) {
174
+ log.warn(TAG, `could not resolve remote tip for ${branchName}, falling back to weak lease: ${err instanceof Error ? err.message : err}`);
175
+ }
176
+ const lease = expectedSha
177
+ ? `--force-with-lease=refs/heads/${branchName}:${expectedSha}`
178
+ : "--force-with-lease";
179
+ execFileSync("git", ["push", lease, "-u", "origin", branchName], {
180
+ cwd,
181
+ stdio: "pipe",
182
+ });
162
183
  }
163
184
  else {
164
185
  execFileSync("git", ["push", "-u", "origin", branchName], {
package/dist/index.js CHANGED
@@ -267,7 +267,9 @@ export async function main() {
267
267
  }
268
268
  // Banner check lines for service intervals + pool. Compose into one
269
269
  // line each so the banner stays compact.
270
- const reviewCount = config.agent.review.enabled ? 1 : 0;
270
+ const reviewCount = config.agent.review.enabled
271
+ ? config.agent.review.poolSize
272
+ : 0;
271
273
  banner.check(`Pool: ${config.agent.poolSize} impl${reviewCount > 0 ? ` + ${reviewCount} review` : ""}`);
272
274
  const services = [
273
275
  `Heartbeat ${config.agent.timing.reconcileIntervalMs / 1000}s`,
package/dist/pool.js CHANGED
@@ -23,12 +23,16 @@ export class Pool {
23
23
  this.tryDispatchFor(this.implWorkers, this.implQueue, "impl");
24
24
  }, workspaceId, projectId, stateStore));
25
25
  }
26
- // Create review worker(s) 1 review worker per pool
26
+ // Create review workers. IDs offset by `config.poolSize` so impl and
27
+ // review worker IDs never collide (verification + review ports both
28
+ // derive from the worker ID, so collision would mean port collision).
27
29
  if (config.review.enabled) {
28
- const reviewWorkerId = config.poolSize; // offset to avoid ID collision
29
- this.reviewWorkers.push(new ReviewWorker(reviewWorkerId, config, client, userEmail, () => {
30
- this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
31
- }, stateStore, workspaceId, projectId));
30
+ for (let i = 0; i < config.review.poolSize; i++) {
31
+ const reviewWorkerId = config.poolSize + i;
32
+ this.reviewWorkers.push(new ReviewWorker(reviewWorkerId, config, client, userEmail, () => {
33
+ this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
34
+ }, stateStore, workspaceId, projectId));
35
+ }
32
36
  }
33
37
  }
34
38
  /**
@@ -37,4 +37,4 @@ export declare function parseReviewOutput(stdout: string): ReviewResult;
37
37
  * Handles approved/rejected verdicts, creates subtasks for findings,
38
38
  * and moves the card to the appropriate column.
39
39
  */
40
- export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats?: SessionStats | null, runLogPath?: string | null, workspaceId?: string, agentSessionId?: string | null, stateStore?: StateStore): Promise<void>;
40
+ export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats: SessionStats | null | undefined, runLogPath: string | null | undefined, workspaceId: string | undefined, agentSessionId: string | null | undefined, stateStore: StateStore): Promise<void>;
@@ -425,7 +425,7 @@ export async function runReviewCompletion(client, card, result, config, worktree
425
425
  ? getBranchWebUrl(branchName, worktreePath)
426
426
  : null;
427
427
  try {
428
- await stateStore?.recordFailureSummary(card.id, {
428
+ await stateStore.recordFailureSummary(card.id, {
429
429
  summary: failureSummary,
430
430
  reason: "review",
431
431
  recoveryBranch,
@@ -104,7 +104,7 @@ function configRows(config, projectName, gitProvider) {
104
104
  rows.push({ label: "User", value: config.userEmail });
105
105
  const reviewEnabled = config.agent.review.enabled;
106
106
  const poolDesc = reviewEnabled
107
- ? `Pool ${config.agent.poolSize} impl + 1 review`
107
+ ? `Pool ${config.agent.poolSize} impl + ${config.agent.review.poolSize} review`
108
108
  : `Pool ${config.agent.poolSize} impl`;
109
109
  const flowDesc = reviewEnabled
110
110
  ? `Pickup ${config.agent.pickupColumns[0]} → ${config.agent.completion.moveToColumn} → ${config.agent.review.moveToColumn}`
package/dist/types.d.ts CHANGED
@@ -40,6 +40,8 @@ export interface AgentConfig {
40
40
  };
41
41
  review: {
42
42
  enabled: boolean;
43
+ /** Concurrent review workers. Each gets its own dev-server port slot. */
44
+ poolSize: number;
43
45
  pickupColumns: string[];
44
46
  moveToColumn: string;
45
47
  failColumn: string;
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_AGENT_CONFIG = {
2
- poolSize: 1,
2
+ poolSize: 3,
3
3
  maxTimeout: 1_800_000, // 30 minutes
4
4
  pickupColumns: ["To Do"],
5
5
  priorityLabels: { urgent: 100, critical: 90, bug: 50 },
@@ -35,6 +35,7 @@ export const DEFAULT_AGENT_CONFIG = {
35
35
  },
36
36
  review: {
37
37
  enabled: true,
38
+ poolSize: 2,
38
39
  pickupColumns: ["Review"],
39
40
  moveToColumn: "Done",
40
41
  failColumn: "To Do",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",