@magnusekdahl/parallix 1.3.3 → 1.3.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/docs/agents.md +1 -1
- package/docs/use-cases.md +1 -1
- package/lib/agents/agents.js +20 -3
- package/lib/agents/agents.ts +14 -3
- package/lib/agents/mistral.js +128 -14
- package/lib/agents/mistral.ts +145 -5
- package/lib/commands/active.js +69 -39
- package/lib/commands/active.ts +97 -60
- package/lib/commands/config.ts +3 -3
- package/lib/commands/coverage-gate.ts +1 -1
- package/lib/commands/draft.js +1 -1
- package/lib/commands/draft.ts +1 -1
- package/lib/commands/handoff.js +13 -8
- package/lib/commands/handoff.ts +24 -18
- package/lib/commands/rebase.js +1 -1
- package/lib/commands/rebase.ts +2 -2
- package/lib/commands/repair-handoff.js +141 -20
- package/lib/commands/repair-handoff.ts +185 -45
- package/lib/commands/resolve-conflict.js +1 -1
- package/lib/commands/resolve-conflict.ts +2 -2
- package/lib/commands/stats-backfill.ts +10 -10
- package/lib/commands/stats.js +38 -11
- package/lib/commands/stats.ts +40 -96
- package/lib/core/fmt.ts +2 -2
- package/lib/core/git.ts +2 -2
- package/lib/core/gitignore.ts +2 -2
- package/lib/core/mission-utils.js +2 -2
- package/lib/core/mission-utils.ts +2 -2
- package/lib/core/persistent-data-migration.ts +2 -2
- package/lib/core/spawn-tee.ts +1 -1
- package/lib/core/state-map.ts +2 -2
- package/lib/core/storage.ts +1 -1
- package/lib/core/verification.ts +1 -1
- package/lib/review/rebase.ts +12 -12
- package/lib/review/review-artifacts.ts +35 -35
- package/lib/review/review-commands.ts +40 -40
- package/lib/review/review-events.ts +12 -12
- package/lib/review/review-loop.js +236 -7
- package/lib/review/review-loop.ts +338 -22
- package/lib/review/review-polling.ts +6 -6
- package/lib/review/review-prompts.js +8 -4
- package/lib/review/review-prompts.ts +12 -8
- package/lib/review/review-state.js +1 -1
- package/lib/review/review-state.ts +2 -2
- package/package.json +3 -2
- package/prompts/review-verbose.md +1 -1
- package/prompts/review.md +1 -1
package/docs/agents.md
CHANGED
|
@@ -26,7 +26,7 @@ Opencode (custom agent family) may encounter issues with concurrent tool calls o
|
|
|
26
26
|
|---------|----------------------------------------------------------------------|
|
|
27
27
|
| codex | `codex exec --sandbox danger-full-access --cd <worktree> <prompt>` with a worktree-local `HOME` under `.workflow/codex-home`; resume uses `codex exec resume <session-id-or---last> <prompt>`; the launcher also seeds `.workflow/codex-home/.codex/config.toml` with the repo-standard trusted posture and copies `.codex/auth.json` so headless review commands can start and keep localhost Forgejo access |
|
|
28
28
|
| claude | `claude --dangerously-skip-permissions --output-format stream-json --verbose --include-partial-messages -p <prompt>` (cwd=worktree) — uses `--output-format stream-json --verbose --include-partial-messages` to stream real-time JSONL events (tool calls, assistant text chunks) to the operator's terminal via the spawn-tee mechanism. `--include-partial-messages` is required: without it, the assistant event contains the full response at once and no intermediate progress is emitted. Session-id extraction parses the `result` event from stream-json output, falling back to the `claude --resume <id>` regex on plain text. |
|
|
29
|
-
| mistral | `vibe --prompt <prompt> --trust --output text` (cwd=worktree) — **Note: NOT resume-capable in current Vibe version**; session management uses internal state in `~/.vibe/logs/session/` but does not emit a parseable resume hint to stdout/stderr. |
|
|
29
|
+
| mistral | `vibe --prompt <prompt> --trust --yolo --output text` (cwd=worktree) — `--yolo` approves tool calls non-interactively (mirrors `--dangerously-skip-permissions` for claude/opencode and codex's `trust_level = "trusted"`); `--trust` only bypasses the working-directory trust prompt and does not itself skip tool-call approval. **Note: NOT resume-capable in current Vibe version**; session management uses internal state in `~/.vibe/logs/session/` but does not emit a parseable resume hint to stdout/stderr. |
|
|
30
30
|
| custom | `opencode run --pure --dangerously-skip-permissions <prompt>` (cwd=worktree); resume uses `-s <session>` when a session id is known or `--continue` when only the family marker is known |
|
|
31
31
|
|
|
32
32
|
## Launch output watchdog
|
package/docs/use-cases.md
CHANGED
|
@@ -53,7 +53,7 @@ Each use case carries the four required parts: **(P) persona/buyer**, **(B) befo
|
|
|
53
53
|
### UC-6 — See which agent family actually pays off, across every repo one runtime drives
|
|
54
54
|
|
|
55
55
|
- **(P)** Operator/buyer deciding which paid agent subscriptions to keep or cut.
|
|
56
|
-
- **(B)** *Before:* no durable, cross-repo record of how each agent performs, so the keep/cut decision is a hunch. *After:* a single parallix-owned `stats.csv` accumulates per-agent telemetry (`classification, implementer, pr_fix_rounds`, plus an extended
|
|
56
|
+
- **(B)** *Before:* no durable, cross-repo record of how each agent performs, so the keep/cut decision is a hunch. *After:* a single parallix-owned `stats.csv` accumulates per-agent telemetry (`classification, implementer, pr_fix_rounds`, plus an extended 22-column schema including a `closed` flag) across every repository one runtime drives, keyed so the same mission in different repos stays distinct.
|
|
57
57
|
- **(E)** `lib/commands/stats.js:14` (legacy 5-col schema), `:21-30` (extended schema). Tested: `test/stats.test.js` — `upsertStatsRow writes the workflow stats schema and updates existing missions idempotently`, `task-1314: upsertStatsRow keys on (repo, mission, stage) so same mission in different repos stays distinct`. The kind of agent-comparison this enables is demonstrated in `../visualBoard/docs/missions/2026/task-1023/RETROSPECTIVE_P5.md:198-243` (per-family PRs, reviews/PR, durations).
|
|
58
58
|
- **(C)** **Partial.** Schema and CSV upsert are tested, but the value is bounded: the richest per-agent comparison in the evidence came from Forgejo PR data, not `stats.csv`, and two of four families record honest zeros for token usage (`opencode`/local custom and `mistral`/vibe telemetry are zeroed by design, per `README.md:230-231` describing `opencode-telemetry.js`/`mistral-telemetry.js`). So cross-agent *cost/value* comparison is complete only for `codex` and `claude` today.
|
|
59
59
|
|
package/lib/agents/agents.js
CHANGED
|
@@ -115,11 +115,14 @@ const NON_BLOCKING_LAUNCH_ERROR_PATTERNS = Object.freeze([
|
|
|
115
115
|
/\bmodel\s+(?:identifier|id)\s+(?:is\s+)?invalid\b/i,
|
|
116
116
|
/\b(?:model\s+not\s+found|no\s+such\s+model)\b/i,
|
|
117
117
|
/\bunknown\s+option\b/i,
|
|
118
|
+
/\bunsupported\s+(flag|option)\b/i,
|
|
118
119
|
/\bauth(?:entication)?\b/i,
|
|
119
120
|
/\bunauthorized\b/i,
|
|
120
121
|
/\bforbidden\b/i,
|
|
121
122
|
/\bapi\s+key\b/i,
|
|
122
|
-
/\bread-only file system\b/i
|
|
123
|
+
/\bread-only file system\b/i,
|
|
124
|
+
/\b(home|bootstrap)\s+(error|failed|cannot|denied|not\s+found)\b/i,
|
|
125
|
+
/\bpermission\s+denied\b/i
|
|
123
126
|
]);
|
|
124
127
|
function workflowLauncherStatus(agent) {
|
|
125
128
|
const resolver = RESOLVERS[agent];
|
|
@@ -162,6 +165,10 @@ function commandInPath(name) {
|
|
|
162
165
|
});
|
|
163
166
|
return result.status === 0 && result.stdout.trim().length > 0;
|
|
164
167
|
}
|
|
168
|
+
// Deterministic config/setup errors (invalid model IDs, auth failures,
|
|
169
|
+
// unsupported CLI flags, home/bootstrap failures) must not poison the
|
|
170
|
+
// persistent blocklist — only transient failures (runtime crashes, network
|
|
171
|
+
// errors) deserve a block. Custom agents are never blocked.
|
|
165
172
|
function shouldPersistLaunchFailureBlock(agent, result) {
|
|
166
173
|
if (!result || agent === 'custom') {
|
|
167
174
|
return false;
|
|
@@ -543,7 +550,7 @@ function defaultIsAgentBlockedNow(agent) {
|
|
|
543
550
|
const config = readAgentConfig(CONFIG_PATH, {});
|
|
544
551
|
return isAgentBlocked(agent, config);
|
|
545
552
|
}
|
|
546
|
-
catch (
|
|
553
|
+
catch (_err) {
|
|
547
554
|
// If the config is malformed, surface that through the launcher path
|
|
548
555
|
// (assertAgentSupported / launch) instead of silently rerouting. Treat as
|
|
549
556
|
// not-blocked here so the existing error path runs.
|
|
@@ -823,10 +830,20 @@ async function startAgent(step, opts = { prompt: '' }) {
|
|
|
823
830
|
// setup/config errors (invalid model id, auth failure, read-only HOME, etc.)
|
|
824
831
|
// should fall through to the next family without poisoning agents.local.json.
|
|
825
832
|
if (shouldPersistLaunchFailureBlock(chosen || '', result)) {
|
|
833
|
+
let blockReason = 'transient crash';
|
|
834
|
+
if (result?.signal) {
|
|
835
|
+
blockReason = `signal ${result.signal}`;
|
|
836
|
+
}
|
|
837
|
+
else if (result?.error?.code) {
|
|
838
|
+
blockReason = result.error.code;
|
|
839
|
+
}
|
|
840
|
+
else if (result?.status !== null && result?.status !== 0) {
|
|
841
|
+
blockReason = `exit ${result.status}`;
|
|
842
|
+
}
|
|
826
843
|
const blockUntil = (0, limit_hit_js_1.formatBlockUntil)(new Date(Date.now() + limit_hit_js_1.DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000));
|
|
827
844
|
try {
|
|
828
845
|
const blockResult = updateAgentBlockFn(chosen || '', blockUntil);
|
|
829
|
-
log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${limit_hit_js_1.DEFAULT_FALLBACK_HOURS}h block)`));
|
|
846
|
+
log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${limit_hit_js_1.DEFAULT_FALLBACK_HOURS}h block, ${blockReason})`));
|
|
830
847
|
}
|
|
831
848
|
catch (err) {
|
|
832
849
|
log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${err.message}`));
|
package/lib/agents/agents.ts
CHANGED
|
@@ -115,11 +115,14 @@ const NON_BLOCKING_LAUNCH_ERROR_PATTERNS = Object.freeze([
|
|
|
115
115
|
/\bmodel\s+(?:identifier|id)\s+(?:is\s+)?invalid\b/i,
|
|
116
116
|
/\b(?:model\s+not\s+found|no\s+such\s+model)\b/i,
|
|
117
117
|
/\bunknown\s+option\b/i,
|
|
118
|
+
/\bunsupported\s+(flag|option)\b/i,
|
|
118
119
|
/\bauth(?:entication)?\b/i,
|
|
119
120
|
/\bunauthorized\b/i,
|
|
120
121
|
/\bforbidden\b/i,
|
|
121
122
|
/\bapi\s+key\b/i,
|
|
122
|
-
/\bread-only file system\b/i
|
|
123
|
+
/\bread-only file system\b/i,
|
|
124
|
+
/\b(home|bootstrap)\s+(error|failed|cannot|denied|not\s+found)\b/i,
|
|
125
|
+
/\bpermission\s+denied\b/i
|
|
123
126
|
]);
|
|
124
127
|
|
|
125
128
|
function workflowLauncherStatus(agent: string): LauncherStatus {
|
|
@@ -168,6 +171,10 @@ function commandInPath(name: string) {
|
|
|
168
171
|
return result.status === 0 && result.stdout.trim().length > 0;
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
// Deterministic config/setup errors (invalid model IDs, auth failures,
|
|
175
|
+
// unsupported CLI flags, home/bootstrap failures) must not poison the
|
|
176
|
+
// persistent blocklist — only transient failures (runtime crashes, network
|
|
177
|
+
// errors) deserve a block. Custom agents are never blocked.
|
|
171
178
|
function shouldPersistLaunchFailureBlock(agent: string, result: LaunchResultLike | null | undefined) {
|
|
172
179
|
if (!result || agent === 'custom') {return false;}
|
|
173
180
|
const combined = [
|
|
@@ -592,7 +599,7 @@ function defaultIsAgentBlockedNow(agent: string) {
|
|
|
592
599
|
try {
|
|
593
600
|
const config = readAgentConfig(CONFIG_PATH, {});
|
|
594
601
|
return isAgentBlocked(agent, config);
|
|
595
|
-
} catch (
|
|
602
|
+
} catch (_err) {
|
|
596
603
|
// If the config is malformed, surface that through the launcher path
|
|
597
604
|
// (assertAgentSupported / launch) instead of silently rerouting. Treat as
|
|
598
605
|
// not-blocked here so the existing error path runs.
|
|
@@ -907,10 +914,14 @@ async function startAgent(step: string, opts: StartAgentOptions = { prompt: '' }
|
|
|
907
914
|
// setup/config errors (invalid model id, auth failure, read-only HOME, etc.)
|
|
908
915
|
// should fall through to the next family without poisoning agents.local.json.
|
|
909
916
|
if (shouldPersistLaunchFailureBlock(chosen || '', result)) {
|
|
917
|
+
let blockReason = 'transient crash';
|
|
918
|
+
if (result?.signal) { blockReason = `signal ${result.signal}`; }
|
|
919
|
+
else if (result?.error?.code) { blockReason = result.error.code; }
|
|
920
|
+
else if (result?.status !== null && result?.status !== 0) { blockReason = `exit ${result.status}`; }
|
|
910
921
|
const blockUntil = formatBlockUntil(new Date(Date.now() + DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000));
|
|
911
922
|
try {
|
|
912
923
|
const blockResult = updateAgentBlockFn(chosen || '', blockUntil);
|
|
913
|
-
log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${DEFAULT_FALLBACK_HOURS}h block)`));
|
|
924
|
+
log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${DEFAULT_FALLBACK_HOURS}h block, ${blockReason})`));
|
|
914
925
|
} catch (err) {
|
|
915
926
|
log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${(err as any).message}`));
|
|
916
927
|
}
|
package/lib/agents/mistral.js
CHANGED
|
@@ -1,22 +1,128 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getMistralProviderModel = void 0;
|
|
3
7
|
exports.buildMistralInvocation = buildMistralInvocation;
|
|
4
8
|
exports.extractMistralSessionId = extractMistralSessionId;
|
|
9
|
+
exports.processResult = processResult;
|
|
5
10
|
exports.resolveMistralCommand = resolveMistralCommand;
|
|
6
11
|
exports.startMistralAgent = startMistralAgent;
|
|
7
12
|
const spawn_tee_js_1 = require("../core/spawn-tee.js");
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
const mistral_telemetry_js_1 = require("./mistral-telemetry.js");
|
|
14
|
+
Object.defineProperty(exports, "getMistralProviderModel", { enumerable: true, get: function () { return mistral_telemetry_js_1.getMistralProviderModel; } });
|
|
15
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
16
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
17
|
+
/**
|
|
18
|
+
* Maximum acceptable age (in minutes) for a mistral session's start_time
|
|
19
|
+
* relative to the invocation start. Sessions older than this window are
|
|
20
|
+
* rejected as potentially misattributed across concurrent missions.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_SESSION_AGE_MINUTES = 120;
|
|
23
|
+
function processResult(result, basePath, invocationStart) {
|
|
24
|
+
if (!result || typeof result !== 'object') {
|
|
25
|
+
return { sessionId: null, telemetry: null };
|
|
26
|
+
}
|
|
27
|
+
const scanDir = basePath || mistral_telemetry_js_1.DEFAULT_MISTRAL_LOG_DIR;
|
|
28
|
+
// Determine the invocation window for session correlation.
|
|
29
|
+
// When invocationStart is provided, only consider sessions whose
|
|
30
|
+
// start_time falls within MAX_SESSION_AGE_MINUTES of the invocation.
|
|
31
|
+
// This prevents cross-mission telemetry misattribution when multiple
|
|
32
|
+
// mistral phases run concurrently against a shared session directory.
|
|
33
|
+
let invokeTime = NaN;
|
|
34
|
+
let invokeWindow = null;
|
|
35
|
+
if (invocationStart) {
|
|
36
|
+
invokeTime = Date.parse(invocationStart);
|
|
37
|
+
if (!Number.isNaN(invokeTime)) {
|
|
38
|
+
const deltaMs = MAX_SESSION_AGE_MINUTES * 60000;
|
|
39
|
+
invokeWindow = { start: invokeTime - deltaMs, end: invokeTime + deltaMs };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Scan session directories chronologically (sorted by basename).
|
|
43
|
+
// For each session, check if its start_time falls within the invocation
|
|
44
|
+
// window, then pick the session closest to the invocation start time.
|
|
45
|
+
// This replaces the previous approach of calling extractMistralTelemetry
|
|
46
|
+
// which always returned the globally newest session regardless of which
|
|
47
|
+
// invocation it belonged to.
|
|
48
|
+
let bestTelemetry = null;
|
|
49
|
+
let bestDistance = Infinity;
|
|
50
|
+
try {
|
|
51
|
+
const entries = node_fs_1.default.readdirSync(scanDir);
|
|
52
|
+
const dirs = entries.filter((d) => d.startsWith('session_')).sort();
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
const metaPath = node_path_1.default.join(scanDir, dir, 'meta.json');
|
|
55
|
+
if (!node_fs_1.default.existsSync(metaPath)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
let content;
|
|
59
|
+
try {
|
|
60
|
+
content = node_fs_1.default.readFileSync(metaPath, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch (_) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
let meta;
|
|
66
|
+
try {
|
|
67
|
+
meta = JSON.parse(content);
|
|
68
|
+
}
|
|
69
|
+
catch (_) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const startTime = meta.start_time;
|
|
73
|
+
if (typeof startTime !== 'string') {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const sessionTime = Date.parse(startTime);
|
|
77
|
+
if (Number.isNaN(sessionTime)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Check invocation window if applicable
|
|
81
|
+
if (invokeWindow && (sessionTime < invokeWindow.start || sessionTime > invokeWindow.end)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const telemetry = (0, mistral_telemetry_js_1.parseMistralMeta)(meta);
|
|
85
|
+
if (!telemetry) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// When no invocationStart is provided, pick the first valid session.
|
|
89
|
+
// When invocationStart is provided, pick the session closest in time.
|
|
90
|
+
if (Number.isNaN(invokeTime)) {
|
|
91
|
+
bestTelemetry = telemetry;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
const distance = Math.abs(sessionTime - invokeTime);
|
|
95
|
+
if (distance < bestDistance) {
|
|
96
|
+
bestTelemetry = telemetry;
|
|
97
|
+
bestDistance = distance;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (_) {
|
|
102
|
+
// Directory unreadable — fall through to null telemetry
|
|
103
|
+
}
|
|
104
|
+
if (!bestTelemetry) {
|
|
105
|
+
return { ...result, sessionId: result.sessionId || null, telemetry: null };
|
|
106
|
+
}
|
|
107
|
+
const allToolCalls = (bestTelemetry.toolCallsAgreed || 0) +
|
|
108
|
+
(bestTelemetry.toolCallsRejected || 0) +
|
|
109
|
+
(bestTelemetry.toolCallsFailed || 0) +
|
|
110
|
+
(bestTelemetry.toolCallsSucceeded || 0);
|
|
111
|
+
const pm = (0, mistral_telemetry_js_1.getMistralProviderModel)();
|
|
112
|
+
const model = bestTelemetry.contextTokens > 0 || bestTelemetry.inputTokens > 0 ? 'mistral' : pm.model;
|
|
113
|
+
result.telemetry = {
|
|
114
|
+
provider: pm.provider,
|
|
115
|
+
model,
|
|
116
|
+
inputTokens: bestTelemetry.inputTokens,
|
|
117
|
+
outputTokens: bestTelemetry.outputTokens,
|
|
118
|
+
cachedTokens: bestTelemetry.contextTokens,
|
|
119
|
+
totalTokens: bestTelemetry.totalTokens,
|
|
120
|
+
toolCalls: allToolCalls,
|
|
121
|
+
usagePercent: null,
|
|
122
|
+
cost_usd: bestTelemetry.sessionCost,
|
|
123
|
+
};
|
|
124
|
+
return { ...result, sessionId: result.sessionId || null };
|
|
125
|
+
}
|
|
20
126
|
function extractMistralSessionId(stdout) {
|
|
21
127
|
void stdout;
|
|
22
128
|
// Vibe does not currently emit a resume hint in programmatic mode.
|
|
@@ -29,7 +135,14 @@ function resolveMistralCommand() {
|
|
|
29
135
|
function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model = null }) {
|
|
30
136
|
void resume;
|
|
31
137
|
void sessionId;
|
|
32
|
-
|
|
138
|
+
// --trust only bypasses the working-directory trust prompt; tool-call
|
|
139
|
+
// approval is a separate gate that vibe --help documents as controlled by
|
|
140
|
+
// --auto-approve/--yolo. Without it, any prompt that needs a tool call
|
|
141
|
+
// blocks on interactive approval outside a TTY and fails with a generic
|
|
142
|
+
// error, which then gets misread as a real launch failure and persisted
|
|
143
|
+
// to the blocklist (claude/opencode/codex all pass their own equivalent
|
|
144
|
+
// non-interactive bypass already).
|
|
145
|
+
const args = ['--prompt', prompt, '--trust', '--yolo', '--output', 'text'];
|
|
33
146
|
// Vibe programmatic mode does not support --resume flag in the same way
|
|
34
147
|
// as other agents. The --resume flag exists but requires interactive selection
|
|
35
148
|
// or a session picker. Since we cannot reliably pass a session ID via
|
|
@@ -50,11 +163,12 @@ function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, mode
|
|
|
50
163
|
}
|
|
51
164
|
function startMistralAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {} }) {
|
|
52
165
|
const invocation = buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model });
|
|
166
|
+
const invocationStart = new Date().toISOString();
|
|
53
167
|
const resultPromise = (0, spawn_tee_js_1.spawnAndTee)(invocation.command, invocation.args, { ...invocation.options, ...teeOptions }).then((result) => {
|
|
54
168
|
if (result && result.stdout) {
|
|
55
169
|
result.sessionId = extractMistralSessionId(result.stdout);
|
|
56
170
|
}
|
|
57
|
-
return result;
|
|
171
|
+
return processResult(result, undefined, invocationStart);
|
|
58
172
|
});
|
|
59
173
|
return { invocation, resultPromise };
|
|
60
174
|
}
|
package/lib/agents/mistral.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { spawnAndTee } from '../core/spawn-tee.js';
|
|
2
|
+
import { parseMistralMeta, getMistralProviderModel, DEFAULT_MISTRAL_LOG_DIR } from './mistral-telemetry.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maximum acceptable age (in minutes) for a mistral session's start_time
|
|
8
|
+
* relative to the invocation start. Sessions older than this window are
|
|
9
|
+
* rejected as potentially misattributed across concurrent missions.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_SESSION_AGE_MINUTES = 120;
|
|
2
12
|
|
|
3
13
|
interface MistralInvocationOptions {
|
|
4
14
|
prompt: string;
|
|
@@ -22,10 +32,130 @@ interface StartMistralAgentOptions extends MistralInvocationOptions {
|
|
|
22
32
|
// Current session ID format in meta.json: UUID like "a3dd3d4d-f97d-d57d-4942-a1f694e3a922"
|
|
23
33
|
// Directory naming uses first 8 chars: session_20260521_162703_a3dd3d4d
|
|
24
34
|
// No stdout marker detected in testing, so we leave this as null.
|
|
25
|
-
// Telemetry: mistral/vibe
|
|
26
|
-
//
|
|
27
|
-
//
|
|
35
|
+
// Telemetry: mistral/vibe writes structured token-usage data to meta.json files
|
|
36
|
+
// in ~/.vibe/logs/session/. This module's processResult function scans session
|
|
37
|
+
// directories and correlates by start_time window to prevent cross-mission
|
|
38
|
+
// telemetry misattribution. The mapped telemetry is consumed by
|
|
39
|
+
// telemetryToStatsFields in lib/commands/stats.ts.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
interface ProcessedResult {
|
|
43
|
+
sessionId: string | null;
|
|
44
|
+
telemetry: {
|
|
45
|
+
provider: string;
|
|
46
|
+
model: string;
|
|
47
|
+
inputTokens: number;
|
|
48
|
+
outputTokens: number;
|
|
49
|
+
cachedTokens: number;
|
|
50
|
+
totalTokens: number;
|
|
51
|
+
toolCalls: number;
|
|
52
|
+
usagePercent: null;
|
|
53
|
+
cost_usd: number;
|
|
54
|
+
} | null;
|
|
55
|
+
[key: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function processResult(result: any, basePath?: string, invocationStart?: string): ProcessedResult {
|
|
59
|
+
if (!result || typeof result !== 'object') {
|
|
60
|
+
return { sessionId: null, telemetry: null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const scanDir = basePath || DEFAULT_MISTRAL_LOG_DIR;
|
|
64
|
+
|
|
65
|
+
// Determine the invocation window for session correlation.
|
|
66
|
+
// When invocationStart is provided, only consider sessions whose
|
|
67
|
+
// start_time falls within MAX_SESSION_AGE_MINUTES of the invocation.
|
|
68
|
+
// This prevents cross-mission telemetry misattribution when multiple
|
|
69
|
+
// mistral phases run concurrently against a shared session directory.
|
|
70
|
+
let invokeTime = NaN;
|
|
71
|
+
let invokeWindow: { start: number; end: number } | null = null;
|
|
72
|
+
if (invocationStart) {
|
|
73
|
+
invokeTime = Date.parse(invocationStart);
|
|
74
|
+
if (!Number.isNaN(invokeTime)) {
|
|
75
|
+
const deltaMs = MAX_SESSION_AGE_MINUTES * 60000;
|
|
76
|
+
invokeWindow = { start: invokeTime - deltaMs, end: invokeTime + deltaMs };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Scan session directories chronologically (sorted by basename).
|
|
81
|
+
// For each session, check if its start_time falls within the invocation
|
|
82
|
+
// window, then pick the session closest to the invocation start time.
|
|
83
|
+
// This replaces the previous approach of calling extractMistralTelemetry
|
|
84
|
+
// which always returned the globally newest session regardless of which
|
|
85
|
+
// invocation it belonged to.
|
|
86
|
+
let bestTelemetry: ReturnType<typeof parseMistralMeta> | null = null;
|
|
87
|
+
let bestDistance = Infinity;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const entries = fs.readdirSync(scanDir);
|
|
91
|
+
const dirs = entries.filter((d: string) => d.startsWith('session_')).sort();
|
|
92
|
+
|
|
93
|
+
for (const dir of dirs) {
|
|
94
|
+
const metaPath = path.join(scanDir, dir, 'meta.json');
|
|
95
|
+
if (!fs.existsSync(metaPath)) { continue; }
|
|
96
|
+
|
|
97
|
+
let content: string;
|
|
98
|
+
try { content = fs.readFileSync(metaPath, 'utf8'); } catch (_) { continue; }
|
|
28
99
|
|
|
100
|
+
let meta: Record<string, unknown>;
|
|
101
|
+
try { meta = JSON.parse(content); } catch (_) { continue; }
|
|
102
|
+
|
|
103
|
+
const startTime = meta.start_time;
|
|
104
|
+
if (typeof startTime !== 'string') { continue; }
|
|
105
|
+
const sessionTime = Date.parse(startTime);
|
|
106
|
+
if (Number.isNaN(sessionTime)) { continue; }
|
|
107
|
+
|
|
108
|
+
// Check invocation window if applicable
|
|
109
|
+
if (invokeWindow && (sessionTime < invokeWindow.start || sessionTime > invokeWindow.end)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const telemetry = parseMistralMeta(meta);
|
|
114
|
+
if (!telemetry) { continue; }
|
|
115
|
+
|
|
116
|
+
// When no invocationStart is provided, pick the first valid session.
|
|
117
|
+
// When invocationStart is provided, pick the session closest in time.
|
|
118
|
+
if (Number.isNaN(invokeTime)) {
|
|
119
|
+
bestTelemetry = telemetry;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
const distance = Math.abs(sessionTime - invokeTime);
|
|
123
|
+
if (distance < bestDistance) {
|
|
124
|
+
bestTelemetry = telemetry;
|
|
125
|
+
bestDistance = distance;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (_) {
|
|
129
|
+
// Directory unreadable — fall through to null telemetry
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!bestTelemetry) {
|
|
133
|
+
return { ...result, sessionId: result.sessionId || null, telemetry: null } as ProcessedResult;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const allToolCalls =
|
|
137
|
+
(bestTelemetry.toolCallsAgreed || 0) +
|
|
138
|
+
(bestTelemetry.toolCallsRejected || 0) +
|
|
139
|
+
(bestTelemetry.toolCallsFailed || 0) +
|
|
140
|
+
(bestTelemetry.toolCallsSucceeded || 0);
|
|
141
|
+
|
|
142
|
+
const pm = getMistralProviderModel();
|
|
143
|
+
const model = bestTelemetry.contextTokens > 0 || bestTelemetry.inputTokens > 0 ? 'mistral' : pm.model;
|
|
144
|
+
|
|
145
|
+
result.telemetry = {
|
|
146
|
+
provider: pm.provider,
|
|
147
|
+
model,
|
|
148
|
+
inputTokens: bestTelemetry.inputTokens,
|
|
149
|
+
outputTokens: bestTelemetry.outputTokens,
|
|
150
|
+
cachedTokens: bestTelemetry.contextTokens,
|
|
151
|
+
totalTokens: bestTelemetry.totalTokens,
|
|
152
|
+
toolCalls: allToolCalls,
|
|
153
|
+
usagePercent: null,
|
|
154
|
+
cost_usd: bestTelemetry.sessionCost,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return { ...result, sessionId: result.sessionId || null } as ProcessedResult;
|
|
158
|
+
}
|
|
29
159
|
|
|
30
160
|
function extractMistralSessionId(stdout: string) {
|
|
31
161
|
void stdout;
|
|
@@ -41,7 +171,14 @@ function resolveMistralCommand() {
|
|
|
41
171
|
function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model = null }: MistralInvocationOptions) {
|
|
42
172
|
void resume;
|
|
43
173
|
void sessionId;
|
|
44
|
-
|
|
174
|
+
// --trust only bypasses the working-directory trust prompt; tool-call
|
|
175
|
+
// approval is a separate gate that vibe --help documents as controlled by
|
|
176
|
+
// --auto-approve/--yolo. Without it, any prompt that needs a tool call
|
|
177
|
+
// blocks on interactive approval outside a TTY and fails with a generic
|
|
178
|
+
// error, which then gets misread as a real launch failure and persisted
|
|
179
|
+
// to the blocklist (claude/opencode/codex all pass their own equivalent
|
|
180
|
+
// non-interactive bypass already).
|
|
181
|
+
const args = ['--prompt', prompt, '--trust', '--yolo', '--output', 'text'];
|
|
45
182
|
|
|
46
183
|
// Vibe programmatic mode does not support --resume flag in the same way
|
|
47
184
|
// as other agents. The --resume flag exists but requires interactive selection
|
|
@@ -66,11 +203,12 @@ function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, mode
|
|
|
66
203
|
|
|
67
204
|
function startMistralAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {} }: StartMistralAgentOptions) {
|
|
68
205
|
const invocation = buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model });
|
|
206
|
+
const invocationStart = new Date().toISOString();
|
|
69
207
|
const resultPromise = spawnAndTee(invocation.command, invocation.args, { ...invocation.options, ...teeOptions } as any).then((result: any) => {
|
|
70
208
|
if (result && result.stdout) {
|
|
71
209
|
result.sessionId = extractMistralSessionId(result.stdout);
|
|
72
210
|
}
|
|
73
|
-
return result;
|
|
211
|
+
return processResult(result, undefined, invocationStart);
|
|
74
212
|
});
|
|
75
213
|
|
|
76
214
|
return { invocation, resultPromise };
|
|
@@ -79,6 +217,8 @@ function startMistralAgent({ prompt, worktree, env, resume = false, sessionId =
|
|
|
79
217
|
export {
|
|
80
218
|
buildMistralInvocation,
|
|
81
219
|
extractMistralSessionId,
|
|
220
|
+
getMistralProviderModel,
|
|
221
|
+
processResult,
|
|
82
222
|
resolveMistralCommand,
|
|
83
223
|
startMistralAgent
|
|
84
224
|
};
|
package/lib/commands/active.js
CHANGED
|
@@ -297,14 +297,14 @@ function applyExecuteFallback(opts) {
|
|
|
297
297
|
* @returns {Promise<{relaunched: boolean, error?: string}>} Result of relaunch attempt
|
|
298
298
|
*/
|
|
299
299
|
/**
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
300
|
+
* @param {string} slug
|
|
301
|
+
* @param {string} worktree
|
|
302
|
+
* @param {string} errorMsg
|
|
303
|
+
* @param {string} agent
|
|
304
|
+
* @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function, gateOutput?: {stdout: string, stderr: string}}} [options]
|
|
305
|
+
*/
|
|
306
306
|
async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {}) {
|
|
307
|
-
const { isRelaunchableErrorFn = repairHandoff.isRelaunchableError, buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt, workflowLauncherStatusFn = agents.workflowLauncherStatus, startAgentFn = agents.startAgent, log = fmt.log.plain, error = fmt.log.plainError } = options;
|
|
307
|
+
const { isRelaunchableErrorFn = repairHandoff.isRelaunchableError, buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt, workflowLauncherStatusFn = agents.workflowLauncherStatus, startAgentFn = agents.startAgent, log = fmt.log.plain, error = fmt.log.plainError, gateOutput } = options;
|
|
308
308
|
// Check if this is a relaunchable error
|
|
309
309
|
if (!isRelaunchableErrorFn(errorMsg)) {
|
|
310
310
|
log(`Error is not relaunchable: ${errorMsg}`);
|
|
@@ -316,8 +316,8 @@ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {
|
|
|
316
316
|
error(`Agent ${fmt.agent(agent)} is not available for relaunch: ${status.detail || status.reason || 'unknown'}`);
|
|
317
317
|
return { relaunched: false, error: `Agent ${agent} launcher is not available` };
|
|
318
318
|
}
|
|
319
|
-
// Build the relaunch prompt
|
|
320
|
-
const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree);
|
|
319
|
+
// Build the relaunch prompt, passing captured gate output if available (task-1387)
|
|
320
|
+
const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree, gateOutput);
|
|
321
321
|
log(`Attempting to relaunch ${fmt.agent(agent)} to fix repairable handoff error...`);
|
|
322
322
|
// startAgent handles resume flags internally for resume-capable agents (codex, claude, gemini, custom)
|
|
323
323
|
try {
|
|
@@ -394,7 +394,7 @@ function validateCheckpointsBeforeHandoff(slug, worktree, options = {}) {
|
|
|
394
394
|
* @param {{taskFile?: string | null, validateCheckpointsBeforeHandoffFn?: Function, performHandoff?: Function, startReviewLoop?: Function, repairHandoffFn?: {isRelaunchableError: Function, buildRelaunchPrompt: Function}, attemptAgentRelaunchFn?: Function, log?: Function, error?: Function}} [options]
|
|
395
395
|
*/
|
|
396
396
|
async function runHandoffAndReview(slug, worktree, agent, options = {}) {
|
|
397
|
-
const { taskFile = null, validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff, performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o), startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o), repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */ (repairHandoff), attemptAgentRelaunchFn = attemptAgentRelaunch, log = fmt.log.plain, error = fmt.log.plainError } = options;
|
|
397
|
+
const { taskFile = null, validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff, performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o), startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o), repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */ (repairHandoff.default), attemptAgentRelaunchFn = attemptAgentRelaunch, log = fmt.log.plain, error = fmt.log.plainError } = options;
|
|
398
398
|
// Pre-handoff checkpoint enforcement: validate checkpoints exist before calling performHandoff()
|
|
399
399
|
// This catches missing checkpoints immediately after the execute agent exits,
|
|
400
400
|
// before the repair flow runs, and provides an explicit instruction to create them.
|
|
@@ -407,38 +407,68 @@ async function runHandoffAndReview(slug, worktree, agent, options = {}) {
|
|
|
407
407
|
}
|
|
408
408
|
let handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree });
|
|
409
409
|
if (!handoffResult.ok) {
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
410
|
+
// Check for genuine gate failure (task-1387): automatic relaunch with captured output
|
|
411
|
+
const isGenuineGateFailure = handoffResult.gateOutput ||
|
|
412
|
+
(handoffResult.error && (/verification gate failed/i.test(handoffResult.error) ||
|
|
413
|
+
(/\bdeclared gate\b/i.test(handoffResult.error) && /\bfailed\b/i.test(handoffResult.error))));
|
|
414
|
+
if (isGenuineGateFailure) {
|
|
415
|
+
// Automatic relaunch with captured gate output, bounded to max 2 attempts
|
|
416
|
+
let relaunchCount = 0;
|
|
417
|
+
const maxRelaunches = 2;
|
|
418
|
+
while (relaunchCount < maxRelaunches) {
|
|
419
|
+
relaunchCount++;
|
|
420
|
+
log(`\nGenuine gate failure detected. Relaunch attempt ${relaunchCount}/${maxRelaunches}...`);
|
|
421
|
+
const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error, gateOutput: handoffResult.gateOutput });
|
|
422
|
+
if (relaunched) {
|
|
423
|
+
handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
|
|
424
|
+
if (handoffResult.ok) {
|
|
425
|
+
break; // Success — proceed to review loop
|
|
426
|
+
}
|
|
427
|
+
// Handoff still failed; continue loop for another relaunch attempt
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
|
|
431
|
+
break; // Relaunch itself failed; stop
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (!handoffResult.ok && relaunchCount >= maxRelaunches) {
|
|
435
|
+
handoffResult.error = `Gate failure persisting after ${maxRelaunches} relaunch attempts. Manual intervention required.`;
|
|
436
|
+
}
|
|
422
437
|
}
|
|
423
|
-
else
|
|
424
|
-
//
|
|
425
|
-
log(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
// contract of the repair-success path above.
|
|
431
|
-
log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
|
|
438
|
+
else {
|
|
439
|
+
// Original logic: attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
|
|
440
|
+
log(`\nAutomated handoff failed: ${handoffResult.error}`);
|
|
441
|
+
log(`Attempting post-execute repair...`);
|
|
442
|
+
const { repaired, blocker } = await /** @type{Function} */ (repairHandoffFn)(slug, worktree, /** @type{string} */ (handoffResult.error), { taskFile, log, error });
|
|
443
|
+
if (repaired) {
|
|
444
|
+
log(`Repair successful. Retrying automated handoff...`);
|
|
432
445
|
handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
|
|
433
|
-
if (!handoffResult.ok) {
|
|
434
|
-
handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
|
|
435
|
-
}
|
|
436
|
-
// Fall through to gatekeeper pushback / review loop / failure handling below.
|
|
437
446
|
}
|
|
438
|
-
else {
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
447
|
+
else if (blocker) {
|
|
448
|
+
// If repair failed but provided a specific blocker (e.g. rebase failure),
|
|
449
|
+
// report that blocker as the final error instead of the original handoff error.
|
|
450
|
+
handoffResult.error = blocker;
|
|
451
|
+
}
|
|
452
|
+
else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
|
|
453
|
+
// Attempt agent relaunch for repairable content errors (missing goal-check table)
|
|
454
|
+
log(`Content error detected. Attempting agent relaunch to fix...`);
|
|
455
|
+
const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error });
|
|
456
|
+
if (relaunched) {
|
|
457
|
+
// Agent was relaunched successfully; re-invoke performHandoff to verify
|
|
458
|
+
// the handoff-to-review transition actually completed, matching the
|
|
459
|
+
// contract of the repair-success path above.
|
|
460
|
+
log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
|
|
461
|
+
handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
|
|
462
|
+
if (!handoffResult.ok) {
|
|
463
|
+
handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
|
|
464
|
+
}
|
|
465
|
+
// Fall through to gatekeeper pushback / review loop / failure handling below.
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
// Relaunch failed or was not possible
|
|
469
|
+
log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
|
|
470
|
+
// Fall through to manual handoff message
|
|
471
|
+
}
|
|
442
472
|
}
|
|
443
473
|
}
|
|
444
474
|
}
|