@mycompbox/compbox-cli 1.18.0 → 1.20.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/.tsbuildinfo +1 -1
- package/commands/configure.d.ts.map +1 -1
- package/commands/configure.js +31 -11
- package/commands/configure.js.map +1 -1
- package/commands/doctor.d.ts.map +1 -1
- package/commands/doctor.js +1 -1
- package/commands/doctor.js.map +1 -1
- package/commands/login.d.ts +2 -0
- package/commands/login.d.ts.map +1 -1
- package/commands/login.js +28 -6
- package/commands/login.js.map +1 -1
- package/commands/run.d.ts +12 -1
- package/commands/run.d.ts.map +1 -1
- package/commands/run.js +110 -30
- package/commands/run.js.map +1 -1
- package/index.js +1264 -540
- package/package.json +1 -1
- package/utils/agent-invoker.d.ts +59 -101
- package/utils/agent-invoker.d.ts.map +1 -1
- package/utils/agent-invoker.js +183 -508
- package/utils/agent-invoker.js.map +1 -1
- package/utils/autostart.d.ts +41 -0
- package/utils/autostart.d.ts.map +1 -0
- package/utils/autostart.js +122 -0
- package/utils/autostart.js.map +1 -0
- package/utils/key-run.d.ts +32 -0
- package/utils/key-run.d.ts.map +1 -0
- package/utils/key-run.js +77 -0
- package/utils/key-run.js.map +1 -0
- package/utils/loopback.d.ts +3 -3
- package/utils/loopback.d.ts.map +1 -1
- package/utils/loopback.js +3 -3
- package/utils/loopback.js.map +1 -1
- package/utils/mailbox-client.d.ts +31 -1
- package/utils/mailbox-client.d.ts.map +1 -1
- package/utils/mailbox-client.js +60 -2
- package/utils/mailbox-client.js.map +1 -1
- package/utils/mcp-config-writer.d.ts +2 -0
- package/utils/mcp-config-writer.d.ts.map +1 -1
- package/utils/mcp-config-writer.js +9 -1
- package/utils/mcp-config-writer.js.map +1 -1
- package/utils/repo-setup.d.ts +44 -0
- package/utils/repo-setup.d.ts.map +1 -0
- package/utils/repo-setup.js +178 -0
- package/utils/repo-setup.js.map +1 -0
- package/utils/run-workspace.d.ts +120 -0
- package/utils/run-workspace.d.ts.map +1 -0
- package/utils/run-workspace.js +298 -0
- package/utils/run-workspace.js.map +1 -0
package/utils/agent-invoker.js
CHANGED
|
@@ -2,64 +2,39 @@
|
|
|
2
2
|
* Agent invoker (ENG-E23 / ENG-122-2) — the daemon's "heart": actually runs a
|
|
3
3
|
* claimed job instead of immediately reporting it as unimplemented.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
5
|
+
* Request conversations (`epic_request` jobs) run one AI reply per claim
|
|
6
|
+
* (invokeEpicRequestStep, on the shared runConversationStep): the reply is
|
|
7
|
+
* posted, the job is left waiting on the person, and the server hands it back
|
|
8
|
+
* when they write again. Nothing waits in between, so a conversation never
|
|
9
|
+
* times out and stays open after the request is saved. Two ways to reply:
|
|
8
10
|
* - `api`: Anthropic Messages API, directly (Anthropic API key, billed
|
|
9
|
-
* per-token).
|
|
11
|
+
* per-token).
|
|
10
12
|
* - `claude-subscription`: shells out to the local `claude` CLI
|
|
11
13
|
* non-interactively, using whichever account is already logged in on
|
|
12
14
|
* this machine (personal Pro/Max or a company Team seat) — no API key
|
|
13
|
-
* ever touches CompBox.
|
|
15
|
+
* ever touches CompBox.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
* - The interview is driven by the shared, declarative INTERVIEW_DEFINITION
|
|
17
|
-
* (@mycompbox/shared, ENG-143) so the machine/mailbox interview reaches the
|
|
18
|
-
* SAME depth as the CLI request-formulator: it covers all seven sections
|
|
19
|
-
* (problem, solution, impact, success metrics, target audience, technical
|
|
20
|
-
* context, alternatives) rather than only the 3 required fields. The system
|
|
21
|
-
* prompt is rendered from that definition (buildInterviewSystemPrompt), and
|
|
22
|
-
* the completion object carries the required fields plus any optional ones
|
|
23
|
-
* the employee provided; there is no shortened variant left in this path.
|
|
24
|
-
*
|
|
25
|
-
* Turn relay: the mailbox has no "wait for a new turn" primitive for a
|
|
26
|
-
* machine caller (next-job's "resume active job" path returns immediately
|
|
27
|
-
* with whatever the job's current state is — see jobService.ts tryClaimOnce).
|
|
28
|
-
* So after posting an agent question, this module polls next-job on a short
|
|
29
|
-
* client-side interval until it observes a new trailing user turn, with a
|
|
30
|
-
* bounded overall timeout so an abandoned interview eventually fails cleanly
|
|
31
|
-
* instead of polling forever.
|
|
17
|
+
* `start_execution` jobs run the planner/orchestrator headless (below).
|
|
32
18
|
*/
|
|
33
19
|
import Anthropic from '@anthropic-ai/sdk';
|
|
34
20
|
import { spawn, spawnSync } from 'node:child_process';
|
|
35
21
|
import { mkdirSync } from 'node:fs';
|
|
36
22
|
import { homedir } from 'node:os';
|
|
37
23
|
import { join } from 'node:path';
|
|
38
|
-
import { resolveRouting,
|
|
24
|
+
import { resolveRouting, extractCompletionJson, looksLikeCompletionAttempt, meetsComplianceTarget, buildAuditFeedback, COMPLETION_REPAIR_INSTRUCTION, renderConversationPrompt, runConversationStep, savedContextFromRecord, } from '@mycompbox/shared';
|
|
25
|
+
// Completion parsing lives in @mycompbox/shared so CompBox's server-side
|
|
26
|
+
// (API-key) interview finishes the same way; re-exported for existing callers.
|
|
27
|
+
export { extractCompletionJson, looksLikeCompletionAttempt, meetsComplianceTarget, buildAuditFeedback, COMPLETION_REPAIR_INSTRUCTION, };
|
|
39
28
|
import { claudeChildEnvPath, claudeNotFoundMessage, resolveClaudeBinary, } from './claude-runtime.js';
|
|
40
29
|
import { isGitRepoDir } from './config-store.js';
|
|
30
|
+
import { keyRunArgs, keyRunEnv, parseClaudeJsonResult } from './key-run.js';
|
|
41
31
|
// Fable is the model CompBox agents run on (ENG-E26): it is pxpipe's 100/100
|
|
42
32
|
// image reader, so routing it through the proxy cuts input tokens without
|
|
43
33
|
// quality loss. Opus/Sonnet are deliberately NOT used here — pxpipe passes
|
|
44
34
|
// Opus through uncompressed, defeating the token savings.
|
|
45
35
|
const ANTHROPIC_MODEL = 'claude-fable-5';
|
|
46
|
-
// Turn budget for the interview loop. Raised from 8: a full 7-section interview
|
|
47
|
-
// (problem / solution / impact + success metrics / audience / technical context /
|
|
48
|
-
// alternatives) plus follow-ups AND audit-quality rounds needs more headroom
|
|
49
|
-
// than 8 (deep interviews were being cut off mid-way — bug #3).
|
|
50
|
-
const MAX_INTERVIEW_TURNS = 18;
|
|
51
|
-
// Audit-quality gate (bug #2): the interview must reach the `pass` tier
|
|
52
|
-
// (>=90, zero critical issues) before it may finish. After the model says
|
|
53
|
-
// "done", we submit, read the server-computed compliance score, and if it is
|
|
54
|
-
// not yet `pass` we feed the failing rules back and keep going. This bounds how
|
|
55
|
-
// many re-submit/re-audit rounds we allow before finalizing with the best draft
|
|
56
|
-
// (so we never loop forever, and never strand the request at a failing score).
|
|
57
|
-
const MAX_QUALITY_ROUNDS = 4;
|
|
58
|
-
const TARGET_COMPLIANCE_TIER = 'pass';
|
|
59
|
-
const REPLY_POLL_INTERVAL_MS = 3_000;
|
|
60
|
-
const REPLY_TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes
|
|
61
36
|
/** Per-turn wall-clock budget for a single `claude -p` child process. */
|
|
62
|
-
const CLAUDE_CLI_TURN_TIMEOUT_MS =
|
|
37
|
+
const CLAUDE_CLI_TURN_TIMEOUT_MS = 4 * 60 * 1_000; // 4 minutes: a full write-up can take a while
|
|
63
38
|
/**
|
|
64
39
|
* pxpipe routing (ENG-136). Anthropic SDK options carrying the proxy `baseURL`
|
|
65
40
|
* when routed through pxpipe, otherwise just the API key. Pure — unit tested.
|
|
@@ -225,13 +200,6 @@ export function appendAttachmentNote(text, imageCount) {
|
|
|
225
200
|
const note = `[${String(imageCount)} ${noun} attached — not shown in subscription mode; vision is handled when the daemon runs in API mode]`;
|
|
226
201
|
return text.trim().length > 0 ? `${text}\n\n${note}` : note;
|
|
227
202
|
}
|
|
228
|
-
/**
|
|
229
|
-
* The interview system prompt, rendered from the shared, declarative
|
|
230
|
-
* INTERVIEW_DEFINITION (ENG-143). This is the single wiring point that gives
|
|
231
|
-
* the machine/mailbox interview the same section depth as the CLI
|
|
232
|
-
* request-formulator — there is no shortened hardcoded variant anymore.
|
|
233
|
-
*/
|
|
234
|
-
const SYSTEM_PROMPT = buildInterviewSystemPrompt();
|
|
235
203
|
/**
|
|
236
204
|
* ENG-149 — parse a claimed job's `payload` as an interview-CONTINUATION seed
|
|
237
205
|
* (the ENG-148 dispatch shape), or return null for a normal fresh-start job
|
|
@@ -260,17 +228,6 @@ export function parseSeedPayload(payload) {
|
|
|
260
228
|
priorTranscript,
|
|
261
229
|
};
|
|
262
230
|
}
|
|
263
|
-
/**
|
|
264
|
-
* ENG-149 — the interview system prompt for a job: a seed-aware RESUME/ENRICH
|
|
265
|
-
* prompt for a continuation, otherwise the module-level fresh-start prompt.
|
|
266
|
-
*/
|
|
267
|
-
export function resolveSystemPrompt(seed) {
|
|
268
|
-
return seed
|
|
269
|
-
? buildInterviewSystemPrompt(INTERVIEW_DEFINITION, {
|
|
270
|
-
priorTranscript: seed.priorTranscript,
|
|
271
|
-
})
|
|
272
|
-
: SYSTEM_PROMPT;
|
|
273
|
-
}
|
|
274
231
|
/**
|
|
275
232
|
* Submit the current draft and read back the server-computed compliance score
|
|
276
233
|
* (bug #2 — audit-aware finishing). Fresh interview: the FIRST submit
|
|
@@ -287,338 +244,6 @@ export async function submitAndScore(ctx, currentErId, draft) {
|
|
|
287
244
|
const created = await createPersonalEpicRequest(ctx.apiBaseUrl, ctx.userApiToken, draft);
|
|
288
245
|
return { erId: created.id, compliance: created.compliance };
|
|
289
246
|
}
|
|
290
|
-
/**
|
|
291
|
-
* Whether the request is good enough to finish. Reaching the target tier
|
|
292
|
-
* (`pass` = >=90 with no criticals) finishes early. A null score is fail-open:
|
|
293
|
-
* the gate can only act on a score the server actually returned.
|
|
294
|
-
*/
|
|
295
|
-
export function meetsComplianceTarget(compliance) {
|
|
296
|
-
return !compliance || compliance.tier === TARGET_COMPLIANCE_TIER;
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Turn a failing compliance score into a corrective instruction: fix by
|
|
300
|
-
* EXPANDING what's already known where possible, otherwise ask ONE question.
|
|
301
|
-
*/
|
|
302
|
-
export function buildAuditFeedback(compliance) {
|
|
303
|
-
const ordered = [
|
|
304
|
-
...compliance.issues.filter((i) => i.tier === 'critical'),
|
|
305
|
-
...compliance.issues.filter((i) => i.tier === 'advisory'),
|
|
306
|
-
].slice(0, 8);
|
|
307
|
-
const list = ordered.length > 0
|
|
308
|
-
? ordered.map((i) => `- ${i.message}`).join('\n')
|
|
309
|
-
: '- (no specific issues returned; expand the required fields with more concrete detail)';
|
|
310
|
-
return `The epic request you just produced scored ${String(compliance.overall)}/100 (tier: ${compliance.tier}). It must reach 90/100 with ZERO critical issues before the interview can finish. Fix these:
|
|
311
|
-
${list}
|
|
312
|
-
|
|
313
|
-
If you can resolve these by EXPANDING what the employee already told you — a longer, more complete and concrete problemStatement / proposedSolution / impactAssessment / description that comfortably clear the length minimums — re-emit the completion JSON now with richer fields. Do NOT re-ask something you already have an answer for. Only if you genuinely need NEW information from the employee, ask ONE focused question to get it.`;
|
|
314
|
-
}
|
|
315
|
-
/** The optional draft keys, kept in one place so the guard and parser agree. */
|
|
316
|
-
const OPTIONAL_DRAFT_KEYS = [
|
|
317
|
-
'successMetrics',
|
|
318
|
-
'targetAudience',
|
|
319
|
-
'alternatives',
|
|
320
|
-
'dependencies',
|
|
321
|
-
'estimatedEffort',
|
|
322
|
-
];
|
|
323
|
-
/** Type guard validating the model's completion JSON has the required shape. */
|
|
324
|
-
function isEpicRequestDraft(value) {
|
|
325
|
-
if (typeof value !== 'object' || value === null)
|
|
326
|
-
return false;
|
|
327
|
-
const v = value;
|
|
328
|
-
// 🔴 We deliberately do NOT require a `done: true` marker. Fable frequently
|
|
329
|
-
// emits a complete, valid completion object WITHOUT `done` (verified against a
|
|
330
|
-
// real stuck interview), which caused the daemon to reject a finished request
|
|
331
|
-
// and relay the raw JSON back to the user forever. The completion signal is the
|
|
332
|
-
// PRESENCE of all required fields as non-empty strings — a plain-text question
|
|
333
|
-
// never carries these, so this is unambiguous.
|
|
334
|
-
const nonEmpty = (x) => typeof x === 'string' && x.trim().length > 0;
|
|
335
|
-
const requiredOk = nonEmpty(v.title) &&
|
|
336
|
-
nonEmpty(v.problemStatement) &&
|
|
337
|
-
nonEmpty(v.proposedSolution) &&
|
|
338
|
-
nonEmpty(v.impactAssessment) &&
|
|
339
|
-
nonEmpty(v.description);
|
|
340
|
-
if (!requiredOk)
|
|
341
|
-
return false;
|
|
342
|
-
// Optional fields, when present, must be strings (a wrong-typed optional is a
|
|
343
|
-
// malformed completion, not a silently-ignored one).
|
|
344
|
-
for (const key of OPTIONAL_DRAFT_KEYS) {
|
|
345
|
-
if (key in v && v[key] !== undefined && typeof v[key] !== 'string')
|
|
346
|
-
return false;
|
|
347
|
-
}
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
350
|
-
/** Removes trailing commas before } or ] — a common LLM JSON defect. */
|
|
351
|
-
function stripTrailingCommas(json) {
|
|
352
|
-
return json.replace(/,(\s*[}\]])/g, '$1');
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Escapes raw control chars (newline/carriage-return/tab) that appear INSIDE a
|
|
356
|
-
* JSON string literal — the single most common reason a model's completion JSON
|
|
357
|
-
* fails `JSON.parse` (long multi-line description/problem fields written with
|
|
358
|
-
* literal line breaks). Walks the text tracking string state so control chars
|
|
359
|
-
* OUTSIDE strings (formatting whitespace) are left untouched.
|
|
360
|
-
*/
|
|
361
|
-
function escapeRawControlCharsInStrings(json) {
|
|
362
|
-
let out = '';
|
|
363
|
-
let inString = false;
|
|
364
|
-
let escaped = false;
|
|
365
|
-
for (const ch of json) {
|
|
366
|
-
if (escaped) {
|
|
367
|
-
out += ch;
|
|
368
|
-
escaped = false;
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
if (ch === '\\') {
|
|
372
|
-
out += ch;
|
|
373
|
-
escaped = true;
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
if (ch === '"') {
|
|
377
|
-
inString = !inString;
|
|
378
|
-
out += ch;
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
if (inString && (ch === '\n' || ch === '\r' || ch === '\t')) {
|
|
382
|
-
out += ch === '\n' ? '\\n' : ch === '\r' ? '\\r' : '\\t';
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
out += ch;
|
|
386
|
-
}
|
|
387
|
-
return out;
|
|
388
|
-
}
|
|
389
|
-
/** Candidate JSON substrings to try, in priority order, from a model reply. */
|
|
390
|
-
function completionJsonCandidates(text) {
|
|
391
|
-
const candidates = [];
|
|
392
|
-
// A fenced block (```json … ``` or a plain ``` … ``` fence), case-insensitive.
|
|
393
|
-
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
394
|
-
if (fenced?.[1])
|
|
395
|
-
candidates.push(fenced[1]);
|
|
396
|
-
const trimmed = text.trim();
|
|
397
|
-
if (trimmed.startsWith('{'))
|
|
398
|
-
candidates.push(trimmed);
|
|
399
|
-
// Last resort: the widest {...} span (handles prose wrapped around the JSON).
|
|
400
|
-
const first = text.indexOf('{');
|
|
401
|
-
const last = text.lastIndexOf('}');
|
|
402
|
-
if (first !== -1 && last > first)
|
|
403
|
-
candidates.push(text.slice(first, last + 1));
|
|
404
|
-
return candidates;
|
|
405
|
-
}
|
|
406
|
-
/** Builds the draft from a validated parse (required fields + non-empty optionals). */
|
|
407
|
-
function toDraft(parsed) {
|
|
408
|
-
const draft = {
|
|
409
|
-
title: parsed.title,
|
|
410
|
-
problemStatement: parsed.problemStatement,
|
|
411
|
-
proposedSolution: parsed.proposedSolution,
|
|
412
|
-
impactAssessment: parsed.impactAssessment,
|
|
413
|
-
description: parsed.description,
|
|
414
|
-
};
|
|
415
|
-
for (const key of OPTIONAL_DRAFT_KEYS) {
|
|
416
|
-
const raw = parsed[key];
|
|
417
|
-
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
418
|
-
draft[key] = raw;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return draft;
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* Extracts a completion draft from a model reply, tolerant of the common LLM
|
|
425
|
-
* JSON defects (fenced/plain/bare, trailing commas, and — the big one — literal
|
|
426
|
-
* newlines inside string values). Returns null only when no repair strategy
|
|
427
|
-
* yields a VALID {@link isEpicRequestDraft} object.
|
|
428
|
-
*/
|
|
429
|
-
export function extractCompletionJson(text) {
|
|
430
|
-
for (const candidate of completionJsonCandidates(text)) {
|
|
431
|
-
for (const variant of [
|
|
432
|
-
candidate,
|
|
433
|
-
stripTrailingCommas(candidate),
|
|
434
|
-
escapeRawControlCharsInStrings(candidate),
|
|
435
|
-
escapeRawControlCharsInStrings(stripTrailingCommas(candidate)),
|
|
436
|
-
]) {
|
|
437
|
-
try {
|
|
438
|
-
const parsed = JSON.parse(variant);
|
|
439
|
-
if (isEpicRequestDraft(parsed))
|
|
440
|
-
return toDraft(parsed);
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
// Try the next repair variant / candidate.
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
return null;
|
|
448
|
-
}
|
|
449
|
-
/**
|
|
450
|
-
* True when a reply is clearly an ATTEMPT to finish (a JSON completion) even if
|
|
451
|
-
* it failed to parse/validate — so the daemon can correct the model instead of
|
|
452
|
-
* relaying raw JSON to the employee as if it were a question (which loops).
|
|
453
|
-
*/
|
|
454
|
-
export function looksLikeCompletionAttempt(text) {
|
|
455
|
-
// Must open a JSON object (fenced or bare) — note we do NOT require a closing
|
|
456
|
-
// brace, so a truncated/malformed completion still counts.
|
|
457
|
-
const hasObject = /```(?:json)?\s*\{/i.test(text) || text.trim().startsWith('{');
|
|
458
|
-
if (!hasObject)
|
|
459
|
-
return false;
|
|
460
|
-
// ...carrying completion field names (or a `done` marker) — even if malformed
|
|
461
|
-
// or missing `done`. A plain-text question never matches this.
|
|
462
|
-
return /"(?:done|title|problemStatement|proposedSolution|impactAssessment|description)"\s*:/i.test(text);
|
|
463
|
-
}
|
|
464
|
-
/** Correction sent to the model when its completion JSON couldn't be used. */
|
|
465
|
-
const COMPLETION_REPAIR_INSTRUCTION = 'Your last message looked like a completion but I could NOT parse it as a valid JSON object with all required fields. Re-emit ONLY a single valid JSON object inside a ```json code fence — no other text — with every string value properly escaped (escape any internal double-quotes as \\" and any line breaks as \\n), and make sure title, problemStatement, proposedSolution, impactAssessment, and description are all present and non-empty:\n```json\n{"done": true, "title": "...", "problemStatement": "...", "proposedSolution": "...", "impactAssessment": "...", "description": "..."}\n```';
|
|
466
|
-
/** How many times to ask the model to fix a malformed completion before failing. */
|
|
467
|
-
const MAX_JSON_REPAIRS = 3;
|
|
468
|
-
function sleep(ms) {
|
|
469
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* Polls next-job (the "resume active job" path) until a new trailing user
|
|
473
|
-
* turn appears, or the timeout elapses. Returns the updated job, or null on
|
|
474
|
-
* timeout.
|
|
475
|
-
*/
|
|
476
|
-
async function waitForUserReply(client, machineToken, jobId, turnsSoFar) {
|
|
477
|
-
const deadline = Date.now() + REPLY_TIMEOUT_MS;
|
|
478
|
-
while (Date.now() < deadline) {
|
|
479
|
-
await sleep(REPLY_POLL_INTERVAL_MS);
|
|
480
|
-
const job = await client.nextJob(machineToken, 0);
|
|
481
|
-
if (job?.id !== jobId)
|
|
482
|
-
continue;
|
|
483
|
-
const last = job.turns[job.turns.length - 1];
|
|
484
|
-
if (job.turns.length > turnsSoFar && last?.role === 'user') {
|
|
485
|
-
return job;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return null;
|
|
489
|
-
}
|
|
490
|
-
/**
|
|
491
|
-
* Runs the epic_request interview end-to-end: converses with the user via
|
|
492
|
-
* post-turn/next-job until it has a title + the 3 required structured-desc
|
|
493
|
-
* fields, then creates a personal-scope epic request via the real API
|
|
494
|
-
* (POST /api/v1/me/epic-requests) using the user's own stored token.
|
|
495
|
-
*/
|
|
496
|
-
export async function invokeEpicRequestViaApi(job, ctx) {
|
|
497
|
-
// pxpipe (ENG-136): route API-mode calls through the proxy when enabled+healthy.
|
|
498
|
-
const routing = await resolveRouting();
|
|
499
|
-
const anthropic = new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing));
|
|
500
|
-
// ENG-149: a continuation job carries a seed → resume/enrich prompt + update
|
|
501
|
-
// the target ER in place; a fresh job → normal prompt + create a new ER.
|
|
502
|
-
const seed = parseSeedPayload(job.payload);
|
|
503
|
-
const systemPrompt = resolveSystemPrompt(seed);
|
|
504
|
-
// Audit-gate state (bug #2): the ER we are building (a continuation starts with
|
|
505
|
-
// its target), and how many re-submit/re-audit rounds we've spent.
|
|
506
|
-
let currentErId = seed?.targetEpicRequestId ?? null;
|
|
507
|
-
let qualityRounds = 0;
|
|
508
|
-
// Malformed-completion recovery: a model can emit "done" JSON we can't parse.
|
|
509
|
-
let jsonRepairs = 0;
|
|
510
|
-
const messages = [];
|
|
511
|
-
// Seed the conversation with any turns already on the job (e.g. a resumed
|
|
512
|
-
// daemon restart), so we don't re-ask questions already answered. A seeded
|
|
513
|
-
// USER turn may carry image attachments (ENG-153) — fetch + attach them as
|
|
514
|
-
// vision blocks; agent turns and image-free user turns are unchanged.
|
|
515
|
-
for (const turn of job.turns) {
|
|
516
|
-
if (turn.role === 'agent') {
|
|
517
|
-
messages.push({ role: 'assistant', content: turn.content });
|
|
518
|
-
}
|
|
519
|
-
else {
|
|
520
|
-
messages.push({ role: 'user', content: await apiUserTurnContent(turn) });
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
let turnsSoFar = job.turns.length;
|
|
524
|
-
for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
|
|
525
|
-
let reply;
|
|
526
|
-
try {
|
|
527
|
-
const response = await anthropic.messages.create({
|
|
528
|
-
model: ANTHROPIC_MODEL,
|
|
529
|
-
max_tokens: 1024,
|
|
530
|
-
system: systemPrompt,
|
|
531
|
-
messages: messages.length > 0 ? messages : [{ role: 'user', content: 'Let\'s get started.' }],
|
|
532
|
-
});
|
|
533
|
-
const textBlock = response.content.find((b) => b.type === 'text');
|
|
534
|
-
reply = textBlock?.type === 'text' ? textBlock.text : '';
|
|
535
|
-
}
|
|
536
|
-
catch (error) {
|
|
537
|
-
const message = error instanceof Error ? error.message : 'Anthropic API request failed';
|
|
538
|
-
return { status: 'failed', result: { error: `Agent invocation failed: ${message}` } };
|
|
539
|
-
}
|
|
540
|
-
const draft = extractCompletionJson(reply);
|
|
541
|
-
if (draft) {
|
|
542
|
-
let submitted;
|
|
543
|
-
try {
|
|
544
|
-
submitted = await submitAndScore(ctx, currentErId, draft);
|
|
545
|
-
}
|
|
546
|
-
catch (error) {
|
|
547
|
-
const message = error instanceof Error ? error.message : 'Failed to save the epic request';
|
|
548
|
-
return { status: 'failed', result: { error: message } };
|
|
549
|
-
}
|
|
550
|
-
currentErId = submitted.erId;
|
|
551
|
-
const compliance = submitted.compliance;
|
|
552
|
-
// Finish only once the request passes the audit (>=90, no criticals), or
|
|
553
|
-
// when the quality-round budget is spent — best-effort, never stranded.
|
|
554
|
-
if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
|
|
555
|
-
return { status: 'done', result: { epicRequestId: currentErId } };
|
|
556
|
-
}
|
|
557
|
-
// Not good enough: feed the failing rules back (a model-facing round, no
|
|
558
|
-
// user turn) and loop — the model re-emits a richer completion or asks the
|
|
559
|
-
// employee a targeted question next iteration. (compliance is non-null here:
|
|
560
|
-
// meetsComplianceTarget(null) is true, so a null score would have finished.)
|
|
561
|
-
qualityRounds++;
|
|
562
|
-
if (compliance !== null) {
|
|
563
|
-
messages.push({ role: 'assistant', content: reply });
|
|
564
|
-
messages.push({ role: 'user', content: buildAuditFeedback(compliance) });
|
|
565
|
-
}
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
// The model TRIED to finish but the JSON was unusable (bug: malformed
|
|
569
|
-
// completion relayed to the employee as a question, looping forever). Correct
|
|
570
|
-
// the model instead of posting raw JSON — bounded, then fail cleanly.
|
|
571
|
-
if (looksLikeCompletionAttempt(reply)) {
|
|
572
|
-
if (jsonRepairs >= MAX_JSON_REPAIRS) {
|
|
573
|
-
return {
|
|
574
|
-
status: 'failed',
|
|
575
|
-
result: {
|
|
576
|
-
error: 'The interview produced malformed completion data and could not be finalized after several attempts.',
|
|
577
|
-
},
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
jsonRepairs++;
|
|
581
|
-
messages.push({ role: 'assistant', content: reply });
|
|
582
|
-
messages.push({ role: 'user', content: COMPLETION_REPAIR_INSTRUCTION });
|
|
583
|
-
continue;
|
|
584
|
-
}
|
|
585
|
-
// Not done yet — `reply` is the next question. Post it and wait for the answer.
|
|
586
|
-
messages.push({ role: 'assistant', content: reply });
|
|
587
|
-
let updated;
|
|
588
|
-
try {
|
|
589
|
-
updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, reply);
|
|
590
|
-
}
|
|
591
|
-
catch (error) {
|
|
592
|
-
const message = error instanceof Error ? error.message : 'post-turn failed';
|
|
593
|
-
return { status: 'failed', result: { error: message } };
|
|
594
|
-
}
|
|
595
|
-
turnsSoFar = updated.turns.length;
|
|
596
|
-
const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
|
|
597
|
-
if (!answered) {
|
|
598
|
-
return {
|
|
599
|
-
status: 'failed',
|
|
600
|
-
result: { error: 'Timed out waiting for a reply — no response within 10 minutes.' },
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
const lastTurn = answered.turns[answered.turns.length - 1];
|
|
604
|
-
// ENG-153: the employee's reply may include image attachments — attach them
|
|
605
|
-
// as vision blocks (image-free replies stay a plain string, as before).
|
|
606
|
-
messages.push({
|
|
607
|
-
role: 'user',
|
|
608
|
-
content: lastTurn ? await apiUserTurnContent(lastTurn) : '',
|
|
609
|
-
});
|
|
610
|
-
turnsSoFar = answered.turns.length;
|
|
611
|
-
}
|
|
612
|
-
// Turn budget exhausted. If we already produced an ER (fresh create or a
|
|
613
|
-
// continuation's target), finalize with it best-effort rather than losing the
|
|
614
|
-
// work (bug #3); only fail outright if nothing was ever submitted.
|
|
615
|
-
return currentErId
|
|
616
|
-
? { status: 'done', result: { epicRequestId: currentErId } }
|
|
617
|
-
: {
|
|
618
|
-
status: 'failed',
|
|
619
|
-
result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` },
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
247
|
/**
|
|
623
248
|
* Fixed working directory for every `claude` CLI invocation made by the
|
|
624
249
|
* subscription-mode invoker. Two reasons this must be a stable, dedicated
|
|
@@ -676,11 +301,11 @@ function claudeCliSessionCwd() {
|
|
|
676
301
|
*/
|
|
677
302
|
function runClaudeCliTurn(prompt, opts) {
|
|
678
303
|
return new Promise((resolve, reject) => {
|
|
304
|
+
const model = opts.model === undefined ? ANTHROPIC_MODEL : opts.model;
|
|
679
305
|
const args = [
|
|
680
306
|
'-p',
|
|
681
307
|
prompt,
|
|
682
|
-
'--model',
|
|
683
|
-
ANTHROPIC_MODEL,
|
|
308
|
+
...(model ? ['--model', model] : []),
|
|
684
309
|
'--output-format',
|
|
685
310
|
'json',
|
|
686
311
|
'--tools',
|
|
@@ -768,123 +393,91 @@ function runClaudeCliTurn(prompt, opts) {
|
|
|
768
393
|
});
|
|
769
394
|
});
|
|
770
395
|
}
|
|
396
|
+
// ---------------------------------------------------------------------
|
|
397
|
+
// Request conversations (always open): one AI reply per claim.
|
|
398
|
+
// ---------------------------------------------------------------------
|
|
399
|
+
/** The request this conversation already saved, read with the person's token. */
|
|
400
|
+
async function loadSavedRequest(apiBaseUrl, userApiToken, epicRequestId) {
|
|
401
|
+
const res = await fetch(`${apiBaseUrl.replace(/\/+$/, '')}/api/v1/epic-requests/${encodeURIComponent(epicRequestId)}`, { headers: { Authorization: `Bearer ${userApiToken}` } });
|
|
402
|
+
if (!res.ok)
|
|
403
|
+
return null;
|
|
404
|
+
const body = (await res.json().catch(() => null));
|
|
405
|
+
return savedContextFromRecord(body?.data);
|
|
406
|
+
}
|
|
771
407
|
/**
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
* Mirrors invokeEpicRequestViaApi's turn loop and completion handling
|
|
775
|
-
* exactly — only how a single assistant turn is produced differs.
|
|
408
|
+
* True when the person's Claude plan can't use the requested model (for
|
|
409
|
+
* example Fable needs usage credits on some plans): retry on their default.
|
|
776
410
|
*/
|
|
777
|
-
export
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
411
|
+
export function needsDefaultModel(error) {
|
|
412
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
413
|
+
return /requires usage credits|switch to another model|model.*not available|not have access to/i.test(message);
|
|
414
|
+
}
|
|
415
|
+
function savedRequestIdOf(job) {
|
|
416
|
+
const result = job.result;
|
|
417
|
+
if (typeof result?.epicRequestId === 'string')
|
|
418
|
+
return result.epicRequestId;
|
|
419
|
+
return parseSeedPayload(job.payload)?.targetEpicRequestId ?? null;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Takes one step of a request conversation on this computer: the AI's reply to
|
|
423
|
+
* the person's latest message, which may save or update their request. Then
|
|
424
|
+
* the job is left waiting on the person (never completed), so the chat stays
|
|
425
|
+
* open: there is no reply timeout, and the computer is free for other work in
|
|
426
|
+
* the meantime. The server hands the job back when they write again.
|
|
427
|
+
*
|
|
428
|
+
* `anthropicApiKey` set → the Anthropic API; otherwise the local `claude` CLI
|
|
429
|
+
* on the person's own Claude sign-in.
|
|
430
|
+
*/
|
|
431
|
+
export async function invokeEpicRequestStep(job, ctx) {
|
|
432
|
+
try {
|
|
433
|
+
return await takeConversationStep(job, ctx);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
// Leave the conversation waiting on the person rather than retrying a
|
|
437
|
+
// broken step over and over; their next message tries again.
|
|
438
|
+
const reason = error instanceof Error ? error.message : 'something went wrong';
|
|
439
|
+
await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, `I couldn't continue: ${reason}\n\nSend another message (for example "try again") and I'll pick up where we left off.`);
|
|
440
|
+
return { status: 'yield' };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function takeConversationStep(job, ctx) {
|
|
781
444
|
const routing = await resolveRouting();
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
: // ENG-153 (A4): subscription mode has no inline-image channel, so a
|
|
799
|
-
// user turn's images degrade to an explicit textual note.
|
|
800
|
-
`They replied: ${appendAttachmentNote(t.content, imageAttachmentCount(t.attachments))}`)
|
|
801
|
-
.join('\n') +
|
|
802
|
-
'\n\nContinue the interview from exactly where this left off — ask the next question, or if you already have everything, respond with the completion JSON.'
|
|
803
|
-
: "Let's begin the interview.";
|
|
804
|
-
for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
|
|
805
|
-
let turn;
|
|
806
|
-
try {
|
|
807
|
-
turn = await runClaudeCliTurn(nextPrompt, {
|
|
808
|
-
systemPrompt,
|
|
809
|
-
resumeSessionId: sessionId,
|
|
810
|
-
routing,
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
catch (error) {
|
|
814
|
-
const message = error instanceof Error ? error.message : 'claude CLI invocation failed';
|
|
815
|
-
return { status: 'failed', result: { error: `Agent invocation failed: ${message}` } };
|
|
816
|
-
}
|
|
817
|
-
sessionId = turn.sessionId;
|
|
818
|
-
const draft = extractCompletionJson(turn.text);
|
|
819
|
-
if (draft) {
|
|
820
|
-
let submitted;
|
|
445
|
+
const requestId = savedRequestIdOf(job);
|
|
446
|
+
const saved = requestId ? await loadSavedRequest(ctx.apiBaseUrl, ctx.userApiToken, requestId) : null;
|
|
447
|
+
const turns = job.turns.map((t) => ({
|
|
448
|
+
role: t.role,
|
|
449
|
+
content: t.role === 'user' ? appendAttachmentNote(t.content, imageAttachmentCount(t.attachments)) : t.content,
|
|
450
|
+
}));
|
|
451
|
+
const anthropic = ctx.anthropicApiKey ? new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing)) : null;
|
|
452
|
+
let cliModel = ANTHROPIC_MODEL;
|
|
453
|
+
const outcome = await runConversationStep({ turns, saved }, {
|
|
454
|
+
reply: async (system, messages) => {
|
|
455
|
+
if (anthropic) {
|
|
456
|
+
const response = await anthropic.messages.create({ model: ANTHROPIC_MODEL, max_tokens: 4096, system, messages });
|
|
457
|
+
const text = response.content.find((b) => b.type === 'text');
|
|
458
|
+
return text?.type === 'text' ? text.text : '';
|
|
459
|
+
}
|
|
460
|
+
const prompt = renderConversationPrompt(messages);
|
|
821
461
|
try {
|
|
822
|
-
|
|
462
|
+
return (await runClaudeCliTurn(prompt, { systemPrompt: system, resumeSessionId: undefined, routing, model: cliModel })).text;
|
|
823
463
|
}
|
|
824
464
|
catch (error) {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
const compliance = submitted.compliance;
|
|
830
|
-
if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
|
|
831
|
-
return { status: 'done', result: { epicRequestId: currentErId } };
|
|
832
|
-
}
|
|
833
|
-
// Feed the failing rules back into the SAME resumed session (no user turn);
|
|
834
|
-
// the model re-emits a richer completion or asks a targeted question next.
|
|
835
|
-
// (compliance is non-null here — see the api-mode note above.)
|
|
836
|
-
qualityRounds++;
|
|
837
|
-
if (compliance !== null) {
|
|
838
|
-
nextPrompt = buildAuditFeedback(compliance);
|
|
839
|
-
}
|
|
840
|
-
continue;
|
|
841
|
-
}
|
|
842
|
-
// Malformed completion (see api-mode): correct the model rather than relaying
|
|
843
|
-
// raw JSON to the employee; bounded, then fail cleanly.
|
|
844
|
-
if (looksLikeCompletionAttempt(turn.text)) {
|
|
845
|
-
if (jsonRepairs >= MAX_JSON_REPAIRS) {
|
|
846
|
-
return {
|
|
847
|
-
status: 'failed',
|
|
848
|
-
result: {
|
|
849
|
-
error: 'The interview produced malformed completion data and could not be finalized after several attempts.',
|
|
850
|
-
},
|
|
851
|
-
};
|
|
465
|
+
if (cliModel === null || !needsDefaultModel(error))
|
|
466
|
+
throw error;
|
|
467
|
+
cliModel = null;
|
|
468
|
+
return (await runClaudeCliTurn(prompt, { systemPrompt: system, resumeSessionId: undefined, routing, model: null })).text;
|
|
852
469
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
862
|
-
catch (error) {
|
|
863
|
-
const message = error instanceof Error ? error.message : 'post-turn failed';
|
|
864
|
-
return { status: 'failed', result: { error: message } };
|
|
865
|
-
}
|
|
866
|
-
turnsSoFar = updated.turns.length;
|
|
867
|
-
const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
|
|
868
|
-
if (!answered) {
|
|
869
|
-
return {
|
|
870
|
-
status: 'failed',
|
|
871
|
-
result: { error: 'Timed out waiting for a reply — no response within 10 minutes.' },
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
const lastTurn = answered.turns[answered.turns.length - 1];
|
|
875
|
-
// ENG-153 (A4): note any attached images textually (subscription-mode degradation).
|
|
876
|
-
nextPrompt = lastTurn
|
|
877
|
-
? appendAttachmentNote(lastTurn.content, imageAttachmentCount(lastTurn.attachments))
|
|
878
|
-
: '';
|
|
879
|
-
turnsSoFar = answered.turns.length;
|
|
470
|
+
},
|
|
471
|
+
save: async (existingId, draft) => {
|
|
472
|
+
const submitted = await submitAndScore(ctx, existingId, draft);
|
|
473
|
+
return { id: submitted.erId, title: draft.title, compliance: submitted.compliance };
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
if (outcome.kind === 'saved') {
|
|
477
|
+
await ctx.mailboxClient.markRequestSaved(ctx.machineToken, job.id, outcome.saved.id);
|
|
880
478
|
}
|
|
881
|
-
|
|
882
|
-
return
|
|
883
|
-
? { status: 'done', result: { epicRequestId: currentErId } }
|
|
884
|
-
: {
|
|
885
|
-
status: 'failed',
|
|
886
|
-
result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` },
|
|
887
|
-
};
|
|
479
|
+
await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, outcome.text);
|
|
480
|
+
return { status: 'yield' };
|
|
888
481
|
}
|
|
889
482
|
// ---------------------------------------------------------------------
|
|
890
483
|
// start_execution / kind:'plan' (ENG-164) — run the local @planner headless
|
|
@@ -922,6 +515,25 @@ export const PLANNER_HEARTBEAT_INTERVAL_MS = 20 * 1_000;
|
|
|
922
515
|
const PLANNER_FINAL_TURN_MAX_CHARS = 4_000;
|
|
923
516
|
/** The literal marker token the orchestrator emits (ENG-162); kept in sync for compatibility. */
|
|
924
517
|
const ORCHESTRATOR_REVIEW_MARKER = 'COMPBOX_NEEDS_HUMAN_REVIEW';
|
|
518
|
+
/**
|
|
519
|
+
* Finishes a run made on the person's API key: reads Claude Code's JSON
|
|
520
|
+
* result, reports the run's usage to CompBox (best-effort), and returns the
|
|
521
|
+
* plain text the run would otherwise have printed.
|
|
522
|
+
*/
|
|
523
|
+
export function takeKeyRunResult(ctx, keyRun, stdout) {
|
|
524
|
+
const parsed = parseClaudeJsonResult(stdout, keyRun.model);
|
|
525
|
+
if (!parsed)
|
|
526
|
+
return { text: stdout, reported: Promise.resolve() };
|
|
527
|
+
const reported = ctx.mailboxClient
|
|
528
|
+
.reportAiUsage(ctx.machineToken, ctx.jobId, parsed.usage)
|
|
529
|
+
.catch(() => undefined);
|
|
530
|
+
return { text: parsed.text, reported };
|
|
531
|
+
}
|
|
532
|
+
async function settleKeyRun(ctx, keyRun, stdout) {
|
|
533
|
+
const { text, reported } = takeKeyRunResult(ctx, keyRun, stdout);
|
|
534
|
+
await reported;
|
|
535
|
+
return text;
|
|
536
|
+
}
|
|
925
537
|
/**
|
|
926
538
|
* Build the `claude` argv for a headless planner run (H1). Pure + exported so a
|
|
927
539
|
* unit test can assert the exact command shape: the `@planner` routing prompt,
|
|
@@ -1099,16 +711,17 @@ export async function invokeStartExecutionPlan(ctx, deps = {}) {
|
|
|
1099
711
|
}
|
|
1100
712
|
const routing = await resolveRoutingImpl();
|
|
1101
713
|
const command = resolveClaudeBinary() ?? 'claude';
|
|
1102
|
-
const args = buildPlannerArgs(ctx.epicRequestId);
|
|
714
|
+
const args = ctx.keyRun ? keyRunArgs(buildPlannerArgs(ctx.epicRequestId)) : buildPlannerArgs(ctx.epicRequestId);
|
|
1103
715
|
// pxpipe (ENG-136): inject ANTHROPIC_BASE_URL when routed. COMPBOX_NONINTERACTIVE
|
|
1104
716
|
// forces the planner's headless path even if the prompt flag were dropped. PATH
|
|
1105
717
|
// is augmented (ENG-135) so `claude` and any helpers it spawns resolve.
|
|
1106
718
|
const routedEnv = claudeSpawnEnv(process.env, routing);
|
|
1107
|
-
const
|
|
719
|
+
const baseEnv = {
|
|
1108
720
|
...routedEnv,
|
|
1109
721
|
COMPBOX_NONINTERACTIVE: '1',
|
|
1110
722
|
PATH: claudeChildEnvPath(routedEnv.PATH),
|
|
1111
723
|
};
|
|
724
|
+
const childEnv = ctx.keyRun ? keyRunEnv(baseEnv, ctx.keyRun) : baseEnv;
|
|
1112
725
|
// H4: relay a START turn immediately so the web shows the run began even if the
|
|
1113
726
|
// planner produces no stdout for minutes. postTurn failures are swallowed —
|
|
1114
727
|
// streaming is best-effort and must never fail the job.
|
|
@@ -1171,6 +784,8 @@ export async function invokeStartExecutionPlan(ctx, deps = {}) {
|
|
|
1171
784
|
});
|
|
1172
785
|
child.on('close', (code) => {
|
|
1173
786
|
void (async () => {
|
|
787
|
+
if (ctx.keyRun)
|
|
788
|
+
stdout = await settleKeyRun(ctx, ctx.keyRun, stdout);
|
|
1174
789
|
const combined = `${stdout}\n${stderr}`;
|
|
1175
790
|
// A needs-review outcome wins regardless of exit code: the planner may
|
|
1176
791
|
// exit 0 after emitting the marker. Surface it to the web (not silent).
|
|
@@ -1356,6 +971,18 @@ export function defaultToolchainPresent(_dir) {
|
|
|
1356
971
|
}
|
|
1357
972
|
return false;
|
|
1358
973
|
}
|
|
974
|
+
/** Plain-language summary of where a run's code ended up. */
|
|
975
|
+
export function describeRunBranch(finish) {
|
|
976
|
+
if (finish.prUrl)
|
|
977
|
+
return `Pull request: ${finish.prUrl}`;
|
|
978
|
+
if (finish.compareUrl)
|
|
979
|
+
return `Branch ${finish.branch} was pushed. Open the pull request here: ${finish.compareUrl}`;
|
|
980
|
+
if (finish.commits === 0)
|
|
981
|
+
return 'No code changes were made.';
|
|
982
|
+
if (finish.pushed)
|
|
983
|
+
return `Branch ${finish.branch} was pushed.`;
|
|
984
|
+
return `The work is on branch ${finish.branch}${finish.keptWorktree ? ` (in ${finish.keptWorktree})` : ''}.`;
|
|
985
|
+
}
|
|
1359
986
|
/** Read a string field off a parsed marker object, or undefined. */
|
|
1360
987
|
function markerString(marker, key) {
|
|
1361
988
|
const v = marker?.[key];
|
|
@@ -1427,23 +1054,39 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
|
|
|
1427
1054
|
if (!toolchainPresent(projectDir)) {
|
|
1428
1055
|
return { status: 'failed', result: { error: toolchainMissingMessage(projectDir) } };
|
|
1429
1056
|
}
|
|
1057
|
+
// Each run gets its own branch + worktree, so it never touches the person's
|
|
1058
|
+
// checkout and always ends as a reviewable pull request.
|
|
1059
|
+
let workspace = null;
|
|
1060
|
+
let workspaceNotes = [];
|
|
1061
|
+
if (deps.workspace) {
|
|
1062
|
+
const prepared = await deps.workspace.prepare(projectDir, ctx.epicId);
|
|
1063
|
+
if (!prepared.ok) {
|
|
1064
|
+
return { status: 'failed', result: { error: prepared.error } };
|
|
1065
|
+
}
|
|
1066
|
+
workspace = prepared.workspace;
|
|
1067
|
+
workspaceNotes = prepared.notes;
|
|
1068
|
+
}
|
|
1069
|
+
const runDir = workspace?.dir ?? projectDir;
|
|
1430
1070
|
const routing = await resolveRoutingImpl();
|
|
1431
1071
|
const command = resolveClaudeBinary() ?? 'claude';
|
|
1432
|
-
const args = buildOrchestratorArgs(ctx.epicId);
|
|
1072
|
+
const args = ctx.keyRun ? keyRunArgs(buildOrchestratorArgs(ctx.epicId)) : buildOrchestratorArgs(ctx.epicId);
|
|
1433
1073
|
// pxpipe (ENG-136): inject ANTHROPIC_BASE_URL when routed. COMPBOX_NONINTERACTIVE
|
|
1434
1074
|
// forces the orchestrator's headless serial-in-process path (H3) even if the
|
|
1435
1075
|
// prompt flag were dropped. PATH is augmented (ENG-135) so `claude` and any
|
|
1436
1076
|
// helpers it spawns resolve on a launchd/GUI-spawned daemon.
|
|
1437
1077
|
const routedEnv = claudeSpawnEnv(process.env, routing);
|
|
1438
|
-
const
|
|
1078
|
+
const baseEnv = {
|
|
1439
1079
|
...routedEnv,
|
|
1440
1080
|
COMPBOX_NONINTERACTIVE: '1',
|
|
1441
1081
|
PATH: claudeChildEnvPath(routedEnv.PATH),
|
|
1442
1082
|
};
|
|
1083
|
+
const childEnv = ctx.keyRun ? keyRunEnv(baseEnv, ctx.keyRun) : baseEnv;
|
|
1443
1084
|
// H4: relay a START turn immediately so the web shows the run began even if the
|
|
1444
1085
|
// orchestrator produces no stdout for minutes. postTurn failures are swallowed.
|
|
1445
1086
|
try {
|
|
1446
|
-
await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, `Started the orchestrator for epic ${ctx.epicId} (headless, non-interactive). This executes the full epic — every phase and feature — and may take a long time.`
|
|
1087
|
+
await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, `Started the orchestrator for epic ${ctx.epicId} (headless, non-interactive). This executes the full epic — every phase and feature — and may take a long time.` +
|
|
1088
|
+
(workspace ? ` Working on a new branch, ${workspace.branch}, which will end as a pull request into ${workspace.baseBranch}.` : '') +
|
|
1089
|
+
(workspaceNotes.length > 0 ? ` Note: ${workspaceNotes.join(' ')}` : ''));
|
|
1447
1090
|
}
|
|
1448
1091
|
catch {
|
|
1449
1092
|
// Best-effort start turn.
|
|
@@ -1453,7 +1096,7 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
|
|
|
1453
1096
|
let stderr = '';
|
|
1454
1097
|
let settled = false;
|
|
1455
1098
|
const child = spawnImpl(command, args, {
|
|
1456
|
-
cwd:
|
|
1099
|
+
cwd: runDir,
|
|
1457
1100
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1458
1101
|
env: childEnv,
|
|
1459
1102
|
});
|
|
@@ -1502,6 +1145,9 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
|
|
|
1502
1145
|
});
|
|
1503
1146
|
});
|
|
1504
1147
|
child.on('close', (code) => {
|
|
1148
|
+
// Usage is reported in the background; the run's outcome doesn't wait on it.
|
|
1149
|
+
if (ctx.keyRun)
|
|
1150
|
+
stdout = takeKeyRunResult(ctx, ctx.keyRun, stdout).text;
|
|
1505
1151
|
const combined = `${stdout}\n${stderr}`;
|
|
1506
1152
|
// ENG-165-3: a HARD-gate needs-review outcome wins regardless of exit code
|
|
1507
1153
|
// — the orchestrator emits the marker and STOPs, and may exit 0. Surface
|
|
@@ -1529,11 +1175,40 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
|
|
|
1529
1175
|
finish({ status: 'done', result: { epicId: ctx.epicId } });
|
|
1530
1176
|
});
|
|
1531
1177
|
});
|
|
1178
|
+
// Commit what's left, push the branch and open the pull request (draft when
|
|
1179
|
+
// the run stopped early), then attach where the code is to the job result.
|
|
1180
|
+
let branchLine = '';
|
|
1181
|
+
if (deps.workspace && workspace) {
|
|
1182
|
+
const resultError = outcome.result;
|
|
1183
|
+
const stopReason = typeof resultError.message === 'string'
|
|
1184
|
+
? resultError.message
|
|
1185
|
+
: typeof resultError.error === 'string'
|
|
1186
|
+
? resultError.error.split('\n')[0]
|
|
1187
|
+
: undefined;
|
|
1188
|
+
const finished = await deps.workspace.finish({
|
|
1189
|
+
workspace,
|
|
1190
|
+
epicId: ctx.epicId,
|
|
1191
|
+
jobId: ctx.jobId,
|
|
1192
|
+
completed: outcome.status === 'done',
|
|
1193
|
+
...(stopReason ? { stopReason } : {}),
|
|
1194
|
+
});
|
|
1195
|
+
branchLine = describeRunBranch(finished);
|
|
1196
|
+
outcome.result = {
|
|
1197
|
+
...outcome.result,
|
|
1198
|
+
branch: finished.branch,
|
|
1199
|
+
commits: finished.commits,
|
|
1200
|
+
pushed: finished.pushed,
|
|
1201
|
+
...(finished.prUrl ? { prUrl: finished.prUrl } : {}),
|
|
1202
|
+
...(finished.compareUrl ? { compareUrl: finished.compareUrl } : {}),
|
|
1203
|
+
...(finished.notes.length > 0 ? { branchNotes: finished.notes } : {}),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1532
1206
|
// H4: relay a FINAL turn summarizing the outcome (best-effort).
|
|
1533
1207
|
try {
|
|
1534
|
-
const summary = outcome.status === 'done'
|
|
1208
|
+
const summary = (outcome.status === 'done'
|
|
1535
1209
|
? `Execution complete — epic ${ctx.epicId} finished all phases.`
|
|
1536
|
-
: `Orchestrator run ended: ${JSON.stringify(outcome.result).slice(0, ORCHESTRATOR_FINAL_TURN_MAX_CHARS)}
|
|
1210
|
+
: `Orchestrator run ended: ${JSON.stringify(outcome.result).slice(0, ORCHESTRATOR_FINAL_TURN_MAX_CHARS)}`) +
|
|
1211
|
+
(branchLine ? `\n${branchLine}` : '');
|
|
1537
1212
|
await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, summary);
|
|
1538
1213
|
}
|
|
1539
1214
|
catch {
|