@link-assistant/hive-mind 2.11.2 โ 2.11.4
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/agent-commander.lib.mjs +33 -12
- package/src/agent-token-usage.lib.mjs +5 -9
- package/src/agent.lib.mjs +110 -152
- package/src/ai-tool-scratch.lib.mjs +143 -0
- package/src/anthropic-cost-accumulator.lib.mjs +36 -0
- package/src/auto-restart-budget.lib.mjs +107 -0
- package/src/auto-restart-exhaustion.lib.mjs +122 -0
- package/src/claude.lib.mjs +19 -15
- package/src/claude.runtime-switch.lib.mjs +4 -4
- package/src/codex.lib.mjs +46 -8
- package/src/credential-sanitization-core.lib.mjs +27 -3
- package/src/formal-ai-pricing.lib.mjs +110 -0
- package/src/gemini.lib.mjs +34 -43
- package/src/github-cost-info.lib.mjs +5 -0
- package/src/json-stream.lib.mjs +219 -0
- package/src/opencode.lib.mjs +56 -74
- package/src/pull-request-changes.lib.mjs +166 -0
- package/src/qwen.lib.mjs +33 -32
- package/src/reviewers-hive.mjs +2 -2
- package/src/solve.auto-merge.lib.mjs +68 -44
- package/src/solve.auto-pr.lib.mjs +11 -3
- package/src/solve.finalize.lib.mjs +15 -0
- package/src/solve.repository.lib.mjs +21 -4
- package/src/solve.restart-shared.lib.mjs +6 -2
- package/src/solve.results.lib.mjs +29 -16
- package/src/solve.watch.lib.mjs +47 -18
- package/src/use-m-bootstrap.lib.mjs +23 -4
- package/src/use-m-single-flight.lib.mjs +350 -0
- package/src/use-with-retry.lib.mjs +8 -2
- package/src/working-session-summary.lib.mjs +65 -0
- package/src/youtrack/youtrack-sync.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6b3df3c: Stop reporting an empty `--model formal-ai` run as a success. Formal AI sessions are now attributed to Link.Assistant at $0.00, token usage is parsed for all six tools, the two duplicate auto-restart loops are one N/M budget that fails visibly when it is exhausted, and a pull request whose net diff is empty (or holds only the solver's own placeholder) is neither described as changed nor announced as ready to merge. Docker images now pin Formal AI 0.317.0 so the upstream workspace-effect and self-healing fixes are distributed with Hive Mind.
|
|
8
|
+
|
|
9
|
+
## 2.11.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 82053e1: Stop concurrent use-m installs from corrupting the global npm alias (issue #2113). `use()` performs one `npm install -g <alias>@npm:<package>@<version>` per call with no in-flight deduplication, and Node evaluates sibling top-level-await subgraphs concurrently, so a cold container running `fix` or `task` launched six simultaneous global installs of the same directory; npm does not lock the global prefix, so those installs deleted and re-extracted each other's trees, surfacing as `ENOTEMPTY` or as `ERR_MODULE_NOT_FOUND` for an arbitrary internal file. `src/use-m-single-flight.lib.mjs` now wraps `use()` inside `ensureUseM()` with per-specifier single flight, an in-process per-alias mutex, and a cross-process advisory lock over the alias directory (Node built-ins only, `HIVE_MIND_USE_M_LOCK_DIR` to relocate it); measured on a cold prefix, 24 concurrent loads went from 24/24 failures in 54.8s to 0/24 in 3.3s. Dependency loading is also traced under `--verbose` as well as `HIVE_MIND_USE_M_DEBUG=1`, because both reported failures were captured with `--verbose` and contained no loader output at all. Reported upstream as link-foundation/use-m#70 and fixed there in `use-m@8.15.0` (cross-process alias install lock plus a post-install marker); the pinned CDN bootstrap fallback moves from 8.14.4 to 8.15.0, verified with the standalone reproduction โ 22/24 concurrent loads fail on 8.14.4, 0/24 on 8.15.0.
|
|
14
|
+
|
|
3
15
|
## 2.11.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -11,7 +11,9 @@ import { resolveCodexReasoningEffort } from './codex.options.lib.mjs';
|
|
|
11
11
|
import { mapClaudeSubAgentModelToEnvValue, mapModelForTool } from './models/index.mjs';
|
|
12
12
|
import { buildCodexDisable1mContextConfigArgs, buildCodexSubSessionSizeConfigArgs, parseSubSessionSize } from './sub-session-size.lib.mjs';
|
|
13
13
|
import { detectUsageLimit } from './usage-limit.lib.mjs';
|
|
14
|
+
import { applyFormalAiPricingOverride } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
14
15
|
import { getCacheReadTokenCount, getCumulativeContextInputTokens, getOutputTokenCount } from './context-fill.lib.mjs';
|
|
16
|
+
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
15
17
|
|
|
16
18
|
export const AGENT_COMMANDER_TOOLS = new Set(['claude', 'codex', 'opencode', 'agent', 'qwen', 'gemini']);
|
|
17
19
|
|
|
@@ -257,25 +259,34 @@ const enrichPricingInfoWithTokenUsage = ({ pricingInfo = null, usage = null, too
|
|
|
257
259
|
};
|
|
258
260
|
};
|
|
259
261
|
|
|
260
|
-
export const summarizeAgentCommanderResult = ({ result, tool }) => {
|
|
262
|
+
export const summarizeAgentCommanderResult = ({ result, tool, model = null }) => {
|
|
261
263
|
const plainOutput = result?.output?.plain || '';
|
|
262
264
|
if (result?.metadata && typeof result.metadata === 'object') {
|
|
263
265
|
const metadata = result.metadata;
|
|
264
266
|
const streamTokenUsage = metadata.streamTokenUsage || result.usage || null;
|
|
265
|
-
const
|
|
267
|
+
const enrichedPricingInfo = enrichPricingInfoWithTokenUsage({
|
|
266
268
|
pricingInfo: metadata.pricingInfo || null,
|
|
267
269
|
usage: streamTokenUsage,
|
|
268
270
|
tool,
|
|
269
271
|
publicPricingEstimate: metadata.publicPricingEstimate ?? metadata.pricingInfo?.totalCostUSD ?? null,
|
|
270
272
|
});
|
|
273
|
+
// Issue #2119: a Formal AI session belongs to Link.Assistant at $0.00, no
|
|
274
|
+
// matter which agentic CLI agent-commander drove it.
|
|
275
|
+
const { pricingInfo, publicPricingEstimate, anthropicTotalCostUSD } = applyFormalAiPricingOverride({
|
|
276
|
+
model,
|
|
277
|
+
pricingInfo: enrichedPricingInfo,
|
|
278
|
+
publicPricingEstimate: metadata.publicPricingEstimate ?? enrichedPricingInfo?.totalCostUSD ?? null,
|
|
279
|
+
anthropicTotalCostUSD: metadata.anthropicTotalCostUSD ?? null,
|
|
280
|
+
tokenUsage: streamTokenUsage,
|
|
281
|
+
});
|
|
271
282
|
return {
|
|
272
283
|
success: metadata.success === true,
|
|
273
284
|
sessionId: metadata.sessionId || result.sessionId || null,
|
|
274
285
|
limitReached: !!metadata.limitReached,
|
|
275
286
|
limitResetTime: metadata.limitResetTime || null,
|
|
276
287
|
limitTimezone: metadata.limitTimezone || null,
|
|
277
|
-
anthropicTotalCostUSD
|
|
278
|
-
publicPricingEstimate
|
|
288
|
+
anthropicTotalCostUSD,
|
|
289
|
+
publicPricingEstimate,
|
|
279
290
|
pricingInfo,
|
|
280
291
|
resultSummary: metadata.resultSummary || null,
|
|
281
292
|
resultModelUsage: metadata.resultModelUsage || null,
|
|
@@ -291,12 +302,19 @@ export const summarizeAgentCommanderResult = ({ result, tool }) => {
|
|
|
291
302
|
const usage = result?.usage || null;
|
|
292
303
|
const resultMessage = [...messages].reverse().find(message => message?.type === 'result') || null;
|
|
293
304
|
const totalCost = typeof resultMessage?.total_cost_usd === 'number' ? resultMessage.total_cost_usd : null;
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
pricingInfo:
|
|
305
|
+
const rawPublicPricingEstimate = tool === 'agent' && typeof usage?.totalCost === 'number' ? usage.totalCost : null;
|
|
306
|
+
const enrichedPricingInfo = enrichPricingInfoWithTokenUsage({
|
|
307
|
+
pricingInfo: rawPublicPricingEstimate !== null ? { totalCostUSD: rawPublicPricingEstimate, source: 'agent-commander' } : null,
|
|
297
308
|
usage,
|
|
298
309
|
tool,
|
|
299
|
-
publicPricingEstimate,
|
|
310
|
+
publicPricingEstimate: rawPublicPricingEstimate,
|
|
311
|
+
});
|
|
312
|
+
const { pricingInfo, publicPricingEstimate, anthropicTotalCostUSD } = applyFormalAiPricingOverride({
|
|
313
|
+
model,
|
|
314
|
+
pricingInfo: enrichedPricingInfo,
|
|
315
|
+
publicPricingEstimate: rawPublicPricingEstimate ?? enrichedPricingInfo?.totalCostUSD ?? null,
|
|
316
|
+
anthropicTotalCostUSD: tool === 'claude' ? totalCost : null,
|
|
317
|
+
tokenUsage: usage,
|
|
300
318
|
});
|
|
301
319
|
|
|
302
320
|
return {
|
|
@@ -305,8 +323,8 @@ export const summarizeAgentCommanderResult = ({ result, tool }) => {
|
|
|
305
323
|
limitReached: usageLimit.isUsageLimit,
|
|
306
324
|
limitResetTime: usageLimit.resetTime,
|
|
307
325
|
limitTimezone: usageLimit.timezone,
|
|
308
|
-
anthropicTotalCostUSD
|
|
309
|
-
publicPricingEstimate
|
|
326
|
+
anthropicTotalCostUSD,
|
|
327
|
+
publicPricingEstimate,
|
|
310
328
|
pricingInfo,
|
|
311
329
|
resultSummary: extractResultSummary(messages, plainOutput),
|
|
312
330
|
resultModelUsage: null,
|
|
@@ -367,13 +385,16 @@ export const executeWithAgentCommander = async params => {
|
|
|
367
385
|
|
|
368
386
|
const result = await controller.stop();
|
|
369
387
|
await log(`[agent-commander] ${tool} exited with code ${result.exitCode}`);
|
|
370
|
-
return summarizeAgentCommanderResult({ result, tool });
|
|
388
|
+
return summarizeAgentCommanderResult({ result, tool, model: argv.model });
|
|
371
389
|
};
|
|
372
390
|
|
|
373
391
|
export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log = defaultLog, autoCommit = false, autoRestartEnabled = true) => {
|
|
374
392
|
await log('\n๐ Checking for uncommitted changes...');
|
|
393
|
+
// Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
|
|
394
|
+
// the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
|
|
395
|
+
await ensureAiToolScratchIgnored(tempDir, log);
|
|
375
396
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
376
|
-
const statusOutput = gitStatusResult.stdout?.toString().trim() || '';
|
|
397
|
+
const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout?.toString().trim() || '');
|
|
377
398
|
|
|
378
399
|
if (!statusOutput) {
|
|
379
400
|
await log('โ
No uncommitted changes found');
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Decimal from 'decimal.js-light';
|
|
4
4
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
5
|
+
import { parseJsonRecords } from './json-stream.lib.mjs';
|
|
5
6
|
import { getCumulativeContextInputTokens, getRestoredContextInputTokens } from './context-fill.lib.mjs';
|
|
6
7
|
|
|
7
8
|
export const createTokenFieldAvailability = () => ({
|
|
@@ -95,15 +96,10 @@ export const accumulateAgentStepFinishUsage = (usage, data) => {
|
|
|
95
96
|
export const parseAgentTokenUsage = output => {
|
|
96
97
|
const usage = createAgentTokenUsage();
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
accumulateAgentStepFinishUsage(usage, sanitizeObjectStrings(JSON.parse(line)));
|
|
104
|
-
} catch {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
99
|
+
// Issue #2119: records are framed by balanced JSON rather than by newlines,
|
|
100
|
+
// so pretty-printed (multi-line) and concatenated records are counted too.
|
|
101
|
+
for (const record of parseJsonRecords(output)) {
|
|
102
|
+
accumulateAgentStepFinishUsage(usage, sanitizeObjectStrings(record));
|
|
107
103
|
}
|
|
108
104
|
|
|
109
105
|
return usage;
|
package/src/agent.lib.mjs
CHANGED
|
@@ -21,12 +21,15 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
|
|
|
21
21
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
22
22
|
import Decimal from 'decimal.js-light';
|
|
23
23
|
import semver from 'semver';
|
|
24
|
-
import { agentModels, defaultModels, freeToBaseModelMap } from './models/index.mjs';
|
|
24
|
+
import { agentModels, defaultModels, freeToBaseModelMap, isFormalAiModel } from './models/index.mjs';
|
|
25
25
|
import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
|
|
26
|
+
import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
26
27
|
import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
|
|
27
28
|
import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
|
|
29
|
+
import { createJsonStreamScanner, parseJsonRecords } from './json-stream.lib.mjs';
|
|
28
30
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
29
31
|
import { attachStreamingInput, finalizeBidirectionalHandler, setupBidirectionalHandler } from './bidirectional-interactive.lib.mjs';
|
|
32
|
+
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
30
33
|
|
|
31
34
|
export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
|
|
32
35
|
|
|
@@ -111,6 +114,10 @@ const getBaseModelForPricing = modelName => {
|
|
|
111
114
|
* - opencodeCost: Actual billed cost from OpenCode Zen (free for most models)
|
|
112
115
|
*/
|
|
113
116
|
export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
117
|
+
// Issue #2119: Formal AI requests never reach OpenCode Zen, so neither the
|
|
118
|
+
// provider label nor a models.dev price lookup applies to them.
|
|
119
|
+
if (isFormalAiModel(modelId)) return buildFormalAiPricingInfo(modelId, tokenUsage);
|
|
120
|
+
|
|
114
121
|
// Extract the model name from provider/model format
|
|
115
122
|
// e.g., 'opencode/grok-code' -> 'grok-code'
|
|
116
123
|
const modelName = modelId.includes('/') ? modelId.split('/').pop() : modelId;
|
|
@@ -632,80 +639,92 @@ export const executeAgentCommand = async params => {
|
|
|
632
639
|
}
|
|
633
640
|
};
|
|
634
641
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
} else if (data.type === 'result' && data.result) {
|
|
690
|
-
// Explicit result message (like Claude outputs)
|
|
691
|
-
lastTextContent = data.result;
|
|
692
|
-
}
|
|
693
|
-
// Issue #1276: Detect successful completion events
|
|
694
|
-
// When agent emits session.idle or log with "exiting loop" message, it completed successfully
|
|
695
|
-
// This means any previous error events were recovered from (e.g., timeout then retry)
|
|
696
|
-
if (isAgentSuccessfulCompletionEvent(data)) {
|
|
697
|
-
agentCompletedSuccessfully = true;
|
|
642
|
+
// Issue #2119: agentic CLIs do not all emit strict one-record-per-line
|
|
643
|
+
// NDJSON. `formal-ai with agent --verbose` emits pretty-printed,
|
|
644
|
+
// multi-line records, and records can also be concatenated without a
|
|
645
|
+
// separator (issue #1250) or split across process chunks. A line-based
|
|
646
|
+
// JSON.parse dropped every structured event in those cases, which is how
|
|
647
|
+
// a session that really used 21677/22834 tokens was published as
|
|
648
|
+
// "Token usage: 0 input, 0 output" with no session id and no result
|
|
649
|
+
// summary. The scanner frames records by balanced JSON instead of by
|
|
650
|
+
// newlines, and surfaces anything that is not JSON as plain text.
|
|
651
|
+
const stdoutScanner = createJsonStreamScanner();
|
|
652
|
+
const stderrScanner = createJsonStreamScanner();
|
|
653
|
+
|
|
654
|
+
const handleAgentJsonEvent = async (raw, value) => {
|
|
655
|
+
const data = sanitizeObjectStrings(value);
|
|
656
|
+
// Issue #1968: a bare `null`/primitive record must not abort event
|
|
657
|
+
// processing (any data.X access would throw on null).
|
|
658
|
+
if (data === null || typeof data !== 'object') return;
|
|
659
|
+
// Output formatted JSON
|
|
660
|
+
await log(JSON.stringify(data, null, 2));
|
|
661
|
+
// Capture session ID from the first message (agent may use stdout or stderr)
|
|
662
|
+
const eventSessionId = data.sessionID || data.session_id || data.sessionId;
|
|
663
|
+
if (!sessionId && eventSessionId) {
|
|
664
|
+
sessionId = eventSessionId;
|
|
665
|
+
await log(`๐ Session ID: ${sessionId}`);
|
|
666
|
+
}
|
|
667
|
+
// Issue #1250: Accumulate token usage during streaming
|
|
668
|
+
accumulateTokenUsage(data);
|
|
669
|
+
await markBidirectionalStateFromAgentEvent(data);
|
|
670
|
+
// Issue #1201: Detect error events during streaming for reliable detection
|
|
671
|
+
if (data.type === 'error' || data.type === 'step_error') {
|
|
672
|
+
streamingErrorDetected = true;
|
|
673
|
+
streamingErrorMessage = data.message || data.error || raw.substring(0, 100);
|
|
674
|
+
await log(`โ ๏ธ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
|
|
675
|
+
}
|
|
676
|
+
// Issue #1263: Track text content for result summary
|
|
677
|
+
// Agent outputs text via 'text', 'assistant', or 'message' type events
|
|
678
|
+
if (data.type === 'text' && data.text) {
|
|
679
|
+
lastTextContent = data.text;
|
|
680
|
+
} else if (data.type === 'assistant' && data.message?.content) {
|
|
681
|
+
// Extract text from assistant message content
|
|
682
|
+
const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
|
|
683
|
+
for (const item of content) {
|
|
684
|
+
if (item.type === 'text' && item.text) {
|
|
685
|
+
lastTextContent = item.text;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} else if (data.type === 'message' && data.content) {
|
|
689
|
+
// Direct message content
|
|
690
|
+
if (typeof data.content === 'string') {
|
|
691
|
+
lastTextContent = data.content;
|
|
692
|
+
} else if (Array.isArray(data.content)) {
|
|
693
|
+
for (const item of data.content) {
|
|
694
|
+
if (item.type === 'text' && item.text) {
|
|
695
|
+
lastTextContent = item.text;
|
|
698
696
|
}
|
|
699
|
-
// Issue #1296: Detect step_finish with reason "stop" as successful completion
|
|
700
|
-
// This is a clear marker of success - agent finished normally, not due to error or limit
|
|
701
|
-
// When this event appears, we should ignore any error events that appeared earlier in the stream
|
|
702
|
-
// (e.g., timeout errors that were recovered from via retry logic)
|
|
703
|
-
if (data.type === 'step_finish' && data.part?.reason === 'stop') agentCompletedSuccessfully = true;
|
|
704
|
-
} catch {
|
|
705
|
-
// Not JSON - log as plain text
|
|
706
|
-
await log(line);
|
|
707
697
|
}
|
|
708
698
|
}
|
|
699
|
+
} else if (data.type === 'result' && data.result) {
|
|
700
|
+
// Explicit result message (like Claude outputs)
|
|
701
|
+
lastTextContent = data.result;
|
|
702
|
+
}
|
|
703
|
+
// Issue #1276: Detect successful completion events
|
|
704
|
+
// When agent emits session.idle or log with "exiting loop" message, it completed successfully
|
|
705
|
+
// This means any previous error events were recovered from (e.g., timeout then retry)
|
|
706
|
+
if (isAgentSuccessfulCompletionEvent(data)) {
|
|
707
|
+
agentCompletedSuccessfully = true;
|
|
708
|
+
}
|
|
709
|
+
// Issue #1296: Detect step_finish with reason "stop" as successful completion
|
|
710
|
+
// This is a clear marker of success - agent finished normally, not due to error or limit
|
|
711
|
+
// When this event appears, we should ignore any error events that appeared earlier in the stream
|
|
712
|
+
// (e.g., timeout errors that were recovered from via retry logic)
|
|
713
|
+
if (data.type === 'step_finish' && data.part?.reason === 'stop') agentCompletedSuccessfully = true;
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const handleAgentStreamEvents = async events => {
|
|
717
|
+
for (const event of events) {
|
|
718
|
+
if (event.type === 'json') await handleAgentJsonEvent(event.raw, event.value);
|
|
719
|
+
// Not JSON - log as plain text
|
|
720
|
+
else await log(event.value);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
for await (const chunk of execCommand.stream()) {
|
|
725
|
+
if (chunk.type === 'stdout') {
|
|
726
|
+
const output = chunk.data.toString();
|
|
727
|
+
await handleAgentStreamEvents(stdoutScanner.write(output));
|
|
709
728
|
lastMessage = output;
|
|
710
729
|
fullOutput += output; // Collect for both pricing calculation and error detection
|
|
711
730
|
}
|
|
@@ -714,69 +733,8 @@ export const executeAgentCommand = async params => {
|
|
|
714
733
|
const errorOutput = chunk.data.toString();
|
|
715
734
|
if (errorOutput) {
|
|
716
735
|
// Agent sends all output (including verbose logs and structured events) to stderr
|
|
717
|
-
// Process
|
|
718
|
-
|
|
719
|
-
for (const stderrLine of stderrLines) {
|
|
720
|
-
if (!stderrLine.trim()) continue;
|
|
721
|
-
try {
|
|
722
|
-
const stderrData = sanitizeObjectStrings(JSON.parse(stderrLine));
|
|
723
|
-
// Issue #1968: skip bare `null`/primitive lines (see stdout handler above).
|
|
724
|
-
if (stderrData === null || typeof stderrData !== 'object') continue;
|
|
725
|
-
// Output formatted JSON (same formatting as stdout)
|
|
726
|
-
await log(JSON.stringify(stderrData, null, 2));
|
|
727
|
-
// Capture session ID from stderr too (agent sends it via stderr)
|
|
728
|
-
const eventSessionId = stderrData.sessionID || stderrData.session_id || stderrData.sessionId;
|
|
729
|
-
if (!sessionId && eventSessionId) {
|
|
730
|
-
sessionId = eventSessionId;
|
|
731
|
-
await log(`๐ Session ID: ${sessionId}`);
|
|
732
|
-
}
|
|
733
|
-
// Issue #1250: Accumulate token usage during streaming (stderr)
|
|
734
|
-
accumulateTokenUsage(stderrData);
|
|
735
|
-
await markBidirectionalStateFromAgentEvent(stderrData);
|
|
736
|
-
// Issue #1201: Detect error events during streaming (stderr) for reliable detection
|
|
737
|
-
if (stderrData.type === 'error' || stderrData.type === 'step_error') {
|
|
738
|
-
streamingErrorDetected = true;
|
|
739
|
-
streamingErrorMessage = stderrData.message || stderrData.error || stderrLine.substring(0, 100);
|
|
740
|
-
await log(`โ ๏ธ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
|
|
741
|
-
}
|
|
742
|
-
// Issue #1263: Track text content for result summary (stderr)
|
|
743
|
-
if (stderrData.type === 'text' && stderrData.text) {
|
|
744
|
-
lastTextContent = stderrData.text;
|
|
745
|
-
} else if (stderrData.type === 'assistant' && stderrData.message?.content) {
|
|
746
|
-
const content = Array.isArray(stderrData.message.content) ? stderrData.message.content : [stderrData.message.content];
|
|
747
|
-
for (const item of content) {
|
|
748
|
-
if (item.type === 'text' && item.text) {
|
|
749
|
-
lastTextContent = item.text;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
} else if (stderrData.type === 'message' && stderrData.content) {
|
|
753
|
-
if (typeof stderrData.content === 'string') {
|
|
754
|
-
lastTextContent = stderrData.content;
|
|
755
|
-
} else if (Array.isArray(stderrData.content)) {
|
|
756
|
-
for (const item of stderrData.content) {
|
|
757
|
-
if (item.type === 'text' && item.text) {
|
|
758
|
-
lastTextContent = item.text;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
} else if (stderrData.type === 'result' && stderrData.result) {
|
|
763
|
-
lastTextContent = stderrData.result;
|
|
764
|
-
}
|
|
765
|
-
// Issue #1276: Detect successful completion events (stderr)
|
|
766
|
-
// When agent emits session.idle or log with "exiting loop" message, it completed successfully
|
|
767
|
-
if (isAgentSuccessfulCompletionEvent(stderrData)) {
|
|
768
|
-
agentCompletedSuccessfully = true;
|
|
769
|
-
}
|
|
770
|
-
// Issue #1296: Detect step_finish with reason "stop" as successful completion (stderr)
|
|
771
|
-
// This is a clear marker of success - agent finished normally, not due to error or limit
|
|
772
|
-
if (stderrData.type === 'step_finish' && stderrData.part?.reason === 'stop') {
|
|
773
|
-
agentCompletedSuccessfully = true;
|
|
774
|
-
}
|
|
775
|
-
} catch {
|
|
776
|
-
// Not JSON - log as plain text
|
|
777
|
-
await log(stderrLine);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
736
|
+
// Process it exactly like stdout so telemetry is never stream-specific
|
|
737
|
+
await handleAgentStreamEvents(stderrScanner.write(errorOutput));
|
|
780
738
|
// Also collect stderr for error detection
|
|
781
739
|
fullOutput += errorOutput;
|
|
782
740
|
}
|
|
@@ -785,6 +743,10 @@ export const executeAgentCommand = async params => {
|
|
|
785
743
|
}
|
|
786
744
|
}
|
|
787
745
|
|
|
746
|
+
// Release any record that was still being assembled when the stream ended.
|
|
747
|
+
await handleAgentStreamEvents(stdoutScanner.flush());
|
|
748
|
+
await handleAgentStreamEvents(stderrScanner.flush());
|
|
749
|
+
|
|
788
750
|
// Simplified error detection for agent tool
|
|
789
751
|
// Issue #886: Trust exit code - agent now properly returns code 1 on errors with JSON error response
|
|
790
752
|
// Don't scan output for error patterns as this causes false positives during normal operation
|
|
@@ -795,24 +757,17 @@ export const executeAgentCommand = async params => {
|
|
|
795
757
|
// 2. Explicit JSON error messages from agent (type: "error")
|
|
796
758
|
// 3. Usage limit detection (handled separately)
|
|
797
759
|
const detectAgentErrors = stdoutOutput => {
|
|
798
|
-
|
|
760
|
+
// Issue #2119: frame records by balanced JSON, not by newlines, so
|
|
761
|
+
// pretty-printed and concatenated records are still inspected.
|
|
762
|
+
for (const record of parseJsonRecords(stdoutOutput)) {
|
|
763
|
+
const msg = sanitizeObjectStrings(record);
|
|
799
764
|
|
|
800
|
-
|
|
801
|
-
if (
|
|
765
|
+
// Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
|
|
766
|
+
if (msg === null || typeof msg !== 'object') continue;
|
|
802
767
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
// Issue #1968: ignore bare `null`/primitive lines (msg.type would throw on null).
|
|
807
|
-
if (msg === null || typeof msg !== 'object') continue;
|
|
808
|
-
|
|
809
|
-
// Check for explicit error message types from agent
|
|
810
|
-
if (msg.type === 'error' || msg.type === 'step_error') {
|
|
811
|
-
return { detected: true, type: 'AgentError', match: msg.message || msg.error || line.substring(0, 100) };
|
|
812
|
-
}
|
|
813
|
-
} catch {
|
|
814
|
-
// Not JSON - ignore for error detection
|
|
815
|
-
continue;
|
|
768
|
+
// Check for explicit error message types from agent
|
|
769
|
+
if (msg.type === 'error' || msg.type === 'step_error') {
|
|
770
|
+
return { detected: true, type: 'AgentError', match: msg.message || msg.error || JSON.stringify(msg).substring(0, 100) };
|
|
816
771
|
}
|
|
817
772
|
}
|
|
818
773
|
|
|
@@ -1112,11 +1067,14 @@ export const executeAgentCommand = async params => {
|
|
|
1112
1067
|
export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
|
|
1113
1068
|
// Similar to OpenCode version, check for uncommitted changes
|
|
1114
1069
|
await log('\n๐ Checking for uncommitted changes...');
|
|
1070
|
+
// Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
|
|
1071
|
+
// the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
|
|
1072
|
+
await ensureAiToolScratchIgnored(tempDir, log);
|
|
1115
1073
|
try {
|
|
1116
1074
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
1117
1075
|
|
|
1118
1076
|
if (gitStatusResult.code === 0) {
|
|
1119
|
-
const statusOutput = gitStatusResult.stdout.toString().trim();
|
|
1077
|
+
const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
|
|
1120
1078
|
|
|
1121
1079
|
if (statusOutput) {
|
|
1122
1080
|
await log('๐ Found uncommitted changes');
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #2119: keep AI tools' own scratch state out of the solver's workspace
|
|
5
|
+
* bookkeeping.
|
|
6
|
+
*
|
|
7
|
+
* AI tools drop working files into the directory they are run in. Formal AI
|
|
8
|
+
* writes `.formal-ai/` (a `general-change-plan.lino` plan file and friends);
|
|
9
|
+
* Playwright MCP writes `.playwright-mcp/` (issue #1124). Neither belongs to the
|
|
10
|
+
* user's change, but `git status --porcelain` reports them all the same, so the
|
|
11
|
+
* solver read them as "the AI left uncommitted changes" and restarted the tool -
|
|
12
|
+
* every iteration, forever, because restarting recreates the same scratch dir:
|
|
13
|
+
*
|
|
14
|
+
* ๐ Checking for uncommitted changes...
|
|
15
|
+
* ?? .formal-ai/
|
|
16
|
+
* ๐ Found uncommitted changes
|
|
17
|
+
* ๐ AUTO-RESTART: Restarting Agent to handle uncommitted changes...
|
|
18
|
+
*
|
|
19
|
+
* (docs/case-studies/issue-2119/data/logs/agent-scala-solution-draft.log:5425)
|
|
20
|
+
*
|
|
21
|
+
* The same state also reached `git add -A` on the auto-commit paths, which would
|
|
22
|
+
* have published a tool's private scratch files in the user's pull request.
|
|
23
|
+
*
|
|
24
|
+
* Every tool integration has its own `checkForUncommittedChanges`
|
|
25
|
+
* (claude/codex/agent/opencode/gemini/qwen/agent-commander), so filtering inside
|
|
26
|
+
* any one of them would fix one caller and leave the rest. Instead the paths are
|
|
27
|
+
* written to `.git/info/exclude`, which makes git itself stop reporting them:
|
|
28
|
+
* every status check, every `git add -A` and every diff agrees, without touching
|
|
29
|
+
* the repository's own `.gitignore` (that would show up in the pull request).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from 'fs/promises';
|
|
33
|
+
import path from 'path';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Scratch paths AI tools create inside the workspace they are run in.
|
|
37
|
+
*
|
|
38
|
+
* Keep this list to directories a tool owns entirely. Anything a user might
|
|
39
|
+
* legitimately want committed must not be here.
|
|
40
|
+
*/
|
|
41
|
+
export const AI_TOOL_SCRATCH_PATHS = [
|
|
42
|
+
{ path: '.formal-ai/', tool: 'formal-ai' },
|
|
43
|
+
{ path: '.playwright-mcp/', tool: 'playwright-mcp' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const EXCLUDE_HEADER = '# hive-mind: AI tool scratch directories (issue #2119)';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Does this `git status --porcelain` line describe only AI tool scratch state?
|
|
50
|
+
*
|
|
51
|
+
* @param {string} statusLine - e.g. `?? .formal-ai/`
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export const isAiToolScratchPath = statusLine => {
|
|
55
|
+
if (typeof statusLine !== 'string') return false;
|
|
56
|
+
// Porcelain v1: two status characters, a space, then the path.
|
|
57
|
+
const filePath = statusLine.slice(3).trim().replace(/^"|"$/g, '');
|
|
58
|
+
if (!filePath) return false;
|
|
59
|
+
return AI_TOOL_SCRATCH_PATHS.some(({ path: scratchPath }) => {
|
|
60
|
+
const withoutSlash = scratchPath.replace(/\/$/, '');
|
|
61
|
+
return filePath === withoutSlash || filePath.startsWith(`${withoutSlash}/`);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Drop AI tool scratch entries from `git status --porcelain` output.
|
|
67
|
+
*
|
|
68
|
+
* A fallback for workspaces set up before `ensureAiToolScratchIgnored` ran (an
|
|
69
|
+
* existing clone, a resumed run), so a stale scratch directory cannot restart
|
|
70
|
+
* the restart loop.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} statusOutput - raw `git status --porcelain` output
|
|
73
|
+
* @returns {string} the same output without scratch-only lines
|
|
74
|
+
*/
|
|
75
|
+
export const filterAiToolScratchFromStatus = statusOutput => {
|
|
76
|
+
if (!statusOutput) return '';
|
|
77
|
+
return statusOutput
|
|
78
|
+
.split('\n')
|
|
79
|
+
.filter(line => line.trim() && !isAiToolScratchPath(line))
|
|
80
|
+
.join('\n');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Teach a cloned workspace to ignore AI tool scratch directories.
|
|
85
|
+
*
|
|
86
|
+
* Writes to `.git/info/exclude` rather than `.gitignore`: the exclude file is
|
|
87
|
+
* local to the clone, is never staged, and therefore never appears in the pull
|
|
88
|
+
* request. Idempotent - re-running leaves the file unchanged.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} tempDir - the cloned workspace
|
|
91
|
+
* @param {(msg: string, opts?: object) => Promise<void>} [log]
|
|
92
|
+
* @returns {Promise<{applied: boolean, reason?: string, added?: string[]}>}
|
|
93
|
+
*/
|
|
94
|
+
export const ensureAiToolScratchIgnored = async (tempDir, log = null) => {
|
|
95
|
+
const excludePath = path.join(tempDir, '.git', 'info', 'exclude');
|
|
96
|
+
const report = async msg => {
|
|
97
|
+
if (log) await log(msg, { verbose: true });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let existing = '';
|
|
101
|
+
try {
|
|
102
|
+
existing = await fs.readFile(excludePath, 'utf8');
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.code !== 'ENOENT') {
|
|
105
|
+
await report(`โ ๏ธ Could not read ${excludePath}: ${error.message}`);
|
|
106
|
+
return { applied: false, reason: 'unreadable' };
|
|
107
|
+
}
|
|
108
|
+
// A worktree or a fresh clone may not have the file yet; creating it is fine.
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const existingLines = new Set(
|
|
112
|
+
existing
|
|
113
|
+
.split('\n')
|
|
114
|
+
.map(line => line.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
);
|
|
117
|
+
const missing = AI_TOOL_SCRATCH_PATHS.map(entry => entry.path).filter(scratchPath => !existingLines.has(scratchPath));
|
|
118
|
+
|
|
119
|
+
if (missing.length === 0) {
|
|
120
|
+
return { applied: true, reason: 'already_present', added: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const separator = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
124
|
+
const block = existingLines.has(EXCLUDE_HEADER) ? `${missing.join('\n')}\n` : `${EXCLUDE_HEADER}\n${missing.join('\n')}\n`;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await fs.mkdir(path.dirname(excludePath), { recursive: true });
|
|
128
|
+
await fs.writeFile(excludePath, `${existing}${separator}${block}`, 'utf8');
|
|
129
|
+
} catch (error) {
|
|
130
|
+
await report(`โ ๏ธ Could not update ${excludePath}: ${error.message}`);
|
|
131
|
+
return { applied: false, reason: 'unwritable' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await report(`๐งน Ignoring AI tool scratch directories in this workspace: ${missing.join(', ')}`);
|
|
135
|
+
return { applied: true, added: missing };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export default {
|
|
139
|
+
AI_TOOL_SCRATCH_PATHS,
|
|
140
|
+
ensureAiToolScratchIgnored,
|
|
141
|
+
filterAiToolScratchFromStatus,
|
|
142
|
+
isAiToolScratchPath,
|
|
143
|
+
};
|