amicus 4.9.3 → 4.9.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +242 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +5 -4
- package/docs/architecture-map.md +732 -0
- package/docs/configuration.md +148 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +76 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
|
@@ -64,6 +64,9 @@ function validatePack(pack, { mode } = { mode: 'run' }) {
|
|
|
64
64
|
for (const key of Object.keys(opts)) {
|
|
65
65
|
if (!KIND_OPTIONS[pack.kind].includes(key)) { errors.push(`unknown option '${key}' for kind '${pack.kind}'`); }
|
|
66
66
|
}
|
|
67
|
+
// #218 PR 4 whole-branch review (VCMD-2): the VALUE too — a saved 'turbo' would reach the wire.
|
|
68
|
+
const { VARIANT_LEVELS } = require('../utils/thinking-validators');
|
|
69
|
+
if (opts.thinking !== undefined && !VARIANT_LEVELS.includes(opts.thinking)) { errors.push(`options.thinking must be one of: ${VARIANT_LEVELS.join(', ')}`); }
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
const { getEffectiveAliases, getCouncilWithSource } = require('../utils/config');
|
package/src/session-manager.js
CHANGED
|
@@ -78,7 +78,7 @@ function resolveExistingSessionDir(projectDir, taskId) {
|
|
|
78
78
|
* @param {string} metadata.project - Project path
|
|
79
79
|
* @param {string} [metadata.briefing] - Task briefing
|
|
80
80
|
* @param {string} [metadata.mode] - Mode: 'interactive' or 'headless'
|
|
81
|
-
* @param {string} [metadata.thinking
|
|
81
|
+
* @param {string} [metadata.thinking] - Reasoning effort requested; recorded only when one was (#218 PR 4)
|
|
82
82
|
* @throws {Error} If session already exists
|
|
83
83
|
*/
|
|
84
84
|
function createSession(projectDir, taskId, metadata) {
|
|
@@ -99,7 +99,7 @@ function createSession(projectDir, taskId, metadata) {
|
|
|
99
99
|
project: metadata.project || projectDir,
|
|
100
100
|
briefing: metadata.briefing || '',
|
|
101
101
|
mode: metadata.mode || 'interactive',
|
|
102
|
-
thinking: metadata.thinking
|
|
102
|
+
...(metadata.thinking ? { thinking: metadata.thinking } : {}), // #218 PR 4: emit-when-requested (see start-metadata.js)
|
|
103
103
|
status: SESSION_STATUS.RUNNING,
|
|
104
104
|
createdAt: new Date().toISOString(),
|
|
105
105
|
completedAt: null,
|
package/src/sidecar/continue.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
createHeartbeat
|
|
16
16
|
} = require('./session-utils');
|
|
17
17
|
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
18
|
+
const { noticeDroppedLevel } = require('./reopen-notices');
|
|
18
19
|
const { runHeadless } = require('../headless');
|
|
19
20
|
const { buildPrompts } = require('../prompt-builder');
|
|
20
21
|
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
@@ -140,6 +141,7 @@ async function continueSidecar(options) {
|
|
|
140
141
|
// Load previous session data
|
|
141
142
|
const { metadata: oldMetadata, summary: previousSummary, conversation: previousConversation } =
|
|
142
143
|
loadPreviousSession(oldTaskId, project);
|
|
144
|
+
noticeDroppedLevel(oldMetadata, { taskId: oldTaskId, kind: 'continue' }); // council #235 r5 (J1/A3): read against the PARENT's metadata — a continuation opens a NEW session, sends no variant, and `continue` rejects --thinking, so a level the parent ran with silently becomes the provider's default here. Named mutant "CONTINUELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js).
|
|
143
145
|
|
|
144
146
|
// Lock the previous (EXISTING) session directory to prevent concurrent
|
|
145
147
|
// continue operations — resolve dual-dir so a legacy session is locked too.
|
|
@@ -245,11 +247,14 @@ async function continueSidecar(options) {
|
|
|
245
247
|
if (terminal.status === 'error') {
|
|
246
248
|
meta.status = 'error';
|
|
247
249
|
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
250
|
+
if (result && typeof result.finish === 'string') { meta.finish = result.finish; } // #218 PR 3: emit-when-set; a fresh session's metadata has no prior finish to remove (resume's does -- resume.js)
|
|
251
|
+
if (result && typeof result.variant === 'string') { meta.variant = result.variant; } // #218 PR 4: emit-when-set, like finish (named mutant "CONTINUEERRORNOVARIANT", tests/continue-resume-spend.test.js)
|
|
252
|
+
if (result && result.variantUnverified === true) { meta.variantUnverified = true; }
|
|
248
253
|
meta.completedAt = new Date().toISOString();
|
|
249
254
|
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
250
255
|
logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
|
|
251
256
|
} else {
|
|
252
|
-
finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status });
|
|
257
|
+
finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified }); // named mutant "CONTINUEVARIANTDROPPED" (tests/continue-resume-spend.test.js): drop the variant args
|
|
253
258
|
}
|
|
254
259
|
// v4.3: attribute continue spend (C9/E4). Reload meta, write usage + append a
|
|
255
260
|
// ledger row (status: statusFromResult, matching start.js — not terminal.status).
|
|
@@ -32,18 +32,39 @@ function createMirrorState() {
|
|
|
32
32
|
receivingReported: false,
|
|
33
33
|
output: '', // accumulated assistant text
|
|
34
34
|
seenReasoningParts: new Map(), // partId -> last captured reasoning length
|
|
35
|
-
reasoningOutput: '', // accumulated reasoning text (promoted to output
|
|
35
|
+
reasoningOutput: '', // accumulated reasoning text (promoted to output when no text part has arrived; see promotedOutput)
|
|
36
36
|
usageByMsg: new Map(), // msgId -> {tokens, cost}
|
|
37
|
+
// #218 PR 3: three facts about the LAST assistant message in the snapshot
|
|
38
|
+
// -- its `finish` (stamped at finalization, beside tokens/cost), whether it
|
|
39
|
+
// carries answer text, whether it carries reasoning -- the whole input of
|
|
40
|
+
// the death test in utils/output-length.js. Per MESSAGE on purpose: `output`
|
|
41
|
+
// above accumulates across a tool loop's messages and would let earlier
|
|
42
|
+
// text hide a final length stop, or earlier promoted reasoning condemn a
|
|
43
|
+
// later message that answered (council #232 r1 B2/D1).
|
|
44
|
+
lastAssistantFinish: null,
|
|
45
|
+
lastAssistantHasText: false,
|
|
46
|
+
lastAssistantHasReasoning: false,
|
|
47
|
+
promotedOutput: '', // the reasoning a promotion put into `output` as a stand-in; the first real answer text on a later message replaces it (council #232 r1)
|
|
37
48
|
};
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
/**
|
|
41
|
-
* Capture one assistant message's usage snapshot into
|
|
52
|
+
* Capture one assistant message's usage snapshot AND its finish into the state.
|
|
42
53
|
* The poll loop re-reads ALL messages every poll, so the latest snapshot per
|
|
43
54
|
* message id wins (keyed Map, never additive) — see pricing.sumPerMessageUsage.
|
|
44
55
|
* @returns {boolean} true when this message carried a usage payload
|
|
45
56
|
*/
|
|
46
57
|
function captureMsgUsage(msg, state) {
|
|
58
|
+
// #218 PR 3: `finish` was observed beside tokens/cost on every probe L row, so
|
|
59
|
+
// both mirror passes record it here; the last assistant message in the
|
|
60
|
+
// snapshot wins, and one still streaming (no finish yet) resets it to null; '' counts as none (council #232 r3 C1; mutant "EMPTYFINISH").
|
|
61
|
+
// The two part flags are read off THIS message's parts, never off `output`
|
|
62
|
+
// (council #232 r1 B2/D1). Named mutants "NOFINISH", "TEXTOFFOUTPUT"
|
|
63
|
+
// (tests/conversation-mirror.test.js).
|
|
64
|
+
state.lastAssistantFinish = (typeof msg.info.finish === 'string' && msg.info.finish.length > 0) ? msg.info.finish : null;
|
|
65
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : [];
|
|
66
|
+
state.lastAssistantHasText = parts.some((p) => p && p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0);
|
|
67
|
+
state.lastAssistantHasReasoning = parts.some((p) => p && p.type === 'reasoning' && typeof p.text === 'string' && p.text.length > 0);
|
|
47
68
|
if (msg.info.tokens || typeof msg.info.cost === 'number') {
|
|
48
69
|
state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
|
|
49
70
|
return true;
|
|
@@ -52,9 +73,9 @@ function captureMsgUsage(msg, state) {
|
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/**
|
|
55
|
-
* USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost`
|
|
56
|
-
* fresh getMessages() snapshot and NOTHING else —
|
|
57
|
-
* `state.output` growth, no progress updates, no pending-tool bookkeeping.
|
|
76
|
+
* USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost` — and, since #218 PR 3,
|
|
77
|
+
* the last assistant message's `finish` — from a fresh getMessages() snapshot and NOTHING else —
|
|
78
|
+
* no appendLines, no `state.output` growth, no progress updates, no pending-tool bookkeeping.
|
|
58
79
|
*
|
|
59
80
|
* This exists because the headless poll loop's fast-path exits (trailing fold
|
|
60
81
|
* marker, SDK `idle`) break BEFORE OpenCode stamps usage at finalization, so a
|
|
@@ -63,7 +84,7 @@ function captureMsgUsage(msg, state) {
|
|
|
63
84
|
* conversation.jsonl a second time; this function cannot, because it never
|
|
64
85
|
* touches seenTextParts/output at all.
|
|
65
86
|
* @param {Array} messages getMessages() snapshot
|
|
66
|
-
* @param {object} state from createMirrorState() (only usageByMsg
|
|
87
|
+
* @param {object} state from createMirrorState() (only usageByMsg and the three lastAssistant* facts are mutated)
|
|
67
88
|
* @returns {number} count of messages whose usage was captured
|
|
68
89
|
*/
|
|
69
90
|
function mirrorUsageOnly(messages, state) {
|
|
@@ -138,6 +159,9 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
138
159
|
if (part.text.length > prevLen) {
|
|
139
160
|
// Append only the new portion (handles streaming growth)
|
|
140
161
|
const newText = part.text.slice(prevLen);
|
|
162
|
+
// The first non-whitespace text replaces the stand-in; output restarts from the answer;
|
|
163
|
+
// conversation.jsonl keeps its reasoning. KEEPPROMOTED; WHITESPACERESET drops the trim.
|
|
164
|
+
if (state.promotedOutput && newText.trim().length > 0) { state.output = ''; state.promotedOutput = ''; }
|
|
141
165
|
state.output += newText;
|
|
142
166
|
state.seenTextParts.set(partId, part.text.length);
|
|
143
167
|
appendLines.push({ role: 'assistant', content: newText, timestamp: now() });
|
|
@@ -244,12 +268,12 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
244
268
|
const lastAssistant = list.filter(m => m.info && m.info.role === 'assistant').pop();
|
|
245
269
|
assistantFinished = !!(lastAssistant && lastAssistant.info.time && lastAssistant.info.time.completed);
|
|
246
270
|
|
|
247
|
-
// Reasoning-only fallback: if the assistant finished but emitted only reasoning
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// `
|
|
271
|
+
// Reasoning-only fallback: if the assistant finished but emitted only reasoning parts (no
|
|
272
|
+
// visible text), promote the reasoning text to `output` so the headless completion gates fire
|
|
273
|
+
// and the answer isn't lost as "No Output". Runs once — a non-empty `output` skips it on later
|
|
274
|
+
// polls. `promotedOutput` records the stand-in, which the first real text part replaces above.
|
|
251
275
|
if (assistantFinished && !state.output && state.reasoningOutput) {
|
|
252
|
-
state.output = state.reasoningOutput;
|
|
276
|
+
state.output = state.promotedOutput = state.reasoningOutput;
|
|
253
277
|
appendLines.push({ role: 'assistant', content: state.reasoningOutput, timestamp: now() });
|
|
254
278
|
}
|
|
255
279
|
|
|
@@ -47,6 +47,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
|
|
|
47
47
|
project, attempt: leg && leg.attempt, substitutedFor: leg && leg.substitutedFor,
|
|
48
48
|
retryOfWaveId: leg && leg.retryOfWaveId,
|
|
49
49
|
tag: (leg && leg.tag) || null,
|
|
50
|
+
finish: doc.finish, variant: doc.variant, // #218 PR 3 / PR 4: appendSpend keeps each only when it is a string (named mutant "LEGROWNOVARIANT", tests/sidecar/fanout.test.js)
|
|
50
51
|
};
|
|
51
52
|
if (attempt > 0) { row.attempt = attempt; row.substitutedFor = originalModel; }
|
|
52
53
|
appendSpend(row, deps.spendDir ? { dir: deps.spendDir } : undefined);
|
|
@@ -76,7 +76,7 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
|
|
|
76
76
|
* Adds `.reason` (alias of buildRunResult's `.error`) and `.legId` so the
|
|
77
77
|
* fallback loop reads a stable shape without re-deriving them.
|
|
78
78
|
*/
|
|
79
|
-
async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength,
|
|
79
|
+
async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, variant, quiet, foldNonce, noOutputBackstopMs }) {
|
|
80
80
|
const { IdleWatchdog } = require('../utils/idle-watchdog');
|
|
81
81
|
const { markAborted } = require('../utils/session-abort');
|
|
82
82
|
const { runHeadless } = require('../headless');
|
|
@@ -123,7 +123,7 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
123
123
|
result = await runHeadless(
|
|
124
124
|
leg.model, systemPrompt, userMessage, legId, project,
|
|
125
125
|
timeoutMs, agent || 'build',
|
|
126
|
-
{ client, server, watchdog, summaryLength,
|
|
126
|
+
{ client, server, watchdog, summaryLength, variant, nonce: foldNonce, directory, noOutputBackstopMs }
|
|
127
127
|
);
|
|
128
128
|
} catch (err) {
|
|
129
129
|
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
|
|
@@ -215,6 +215,14 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
215
215
|
// nothing whichever one holds: it is one `&&` against a value already in a
|
|
216
216
|
// register, and it is dead code if `result` is truly always assigned.
|
|
217
217
|
ttftMs: result && isMeasuredTtft(result.ttftMs) ? result.ttftMs : undefined,
|
|
218
|
+
// #218 PR 3: the engine's `finish` for the leg's last assistant message
|
|
219
|
+
// ('length' = stopped at the reservation), emit-when-set like ttftMs above.
|
|
220
|
+
finish: (result && typeof result.finish === 'string') ? result.finish : undefined,
|
|
221
|
+
// #218 PR 4: the effort level SENT (emit-when-sent) and whether the engine's
|
|
222
|
+
// catalogue knew the model when it was sent. Named mutant "LEGVARIANTDROPPED"
|
|
223
|
+
// (tests/sidecar/fanout.test.js).
|
|
224
|
+
variant: (result && typeof result.variant === 'string') ? result.variant : undefined,
|
|
225
|
+
variantUnverified: (result && result.variantUnverified === true) ? true : undefined,
|
|
218
226
|
};
|
|
219
227
|
let finalMeta = legPatch;
|
|
220
228
|
if (legDir) {
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -246,7 +246,7 @@ async function runFanout(options) {
|
|
|
246
246
|
HEARTBEAT_INTERVAL
|
|
247
247
|
);
|
|
248
248
|
const timeoutMs = (options.timeout || 15) * 60 * 1000;
|
|
249
|
-
const
|
|
249
|
+
const variant = options.thinking || undefined; // #218 PR 4: one level for every leg, sent as the engine's `variant` field (named mutant "FANOUTVARIANTDROPPED", tests/sidecar/fanout.test.js: drop `variant` from the runLeg args)
|
|
250
250
|
let legDocs;
|
|
251
251
|
try {
|
|
252
252
|
// retryContexts/retryOfWaveId (v4.3 Task 19): absent on a normal wave, so
|
|
@@ -261,7 +261,7 @@ async function runFanout(options) {
|
|
|
261
261
|
systemPrompt: saved ? rc.systemPrompt : systemPrompt,
|
|
262
262
|
userMessage: saved ? rc.userMessage : userMessage,
|
|
263
263
|
timeoutMs, agent: options.agent, client, server,
|
|
264
|
-
summaryLength: options.summaryLength,
|
|
264
|
+
summaryLength: options.summaryLength, variant, quiet: options.quiet,
|
|
265
265
|
foldNonce, directory: options.directory, follow,
|
|
266
266
|
fallback: options.fallback, catalog: options.catalog, noOutputBackstopMs: options.noOutputBackstopMs,
|
|
267
267
|
});
|
|
@@ -18,6 +18,7 @@ const { canonicalProjectPath } = require('../utils/project-path');
|
|
|
18
18
|
const { ensureElectron } = require('./electron-ensure');
|
|
19
19
|
const { writeProgress } = require('./progress');
|
|
20
20
|
const { getElectronPath, buildElectronEnv, handleElectronProcess } = require('./interactive-process');
|
|
21
|
+
const { readOutputBudgetSafe } = require('../headless');
|
|
21
22
|
|
|
22
23
|
/** Run sidecar in interactive mode (Electron GUI) */
|
|
23
24
|
async function runInteractive(model, systemPrompt, userMessage, taskId, project, options = {}) {
|
|
@@ -33,7 +34,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
const { agent, isResume, conversation, mcp,
|
|
37
|
+
const { agent, isResume, conversation, mcp, variant, opencodeSessionId, client, foldNonce } = options;
|
|
37
38
|
|
|
38
39
|
// F6c: mirror headless's lifecycle stages (best-effort — a write failure must
|
|
39
40
|
// never break the GUI) so the heartbeat/status never read "Starting up...".
|
|
@@ -70,6 +71,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
let sent = null; // #218 PR 4 whole-branch review (EP-4): what sendPrompt SENT, for the record
|
|
73
75
|
// Create or reconnect to session
|
|
74
76
|
let sessionId;
|
|
75
77
|
try {
|
|
@@ -93,9 +95,29 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
93
95
|
|
|
94
96
|
// Always set agent — defaults to 'chat' when not specified
|
|
95
97
|
promptOptions.agent = agentConfig.agent;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
// #218 PR 4: the engine's `variant` field, validated in sendPrompt; the
|
|
99
|
+
// spawn-time budget rides the handle (PR 3). A refusal lands in the catch
|
|
100
|
+
// below as "Session setup failed: VARIANT_…" — nothing was sent.
|
|
101
|
+
// Named mutants "GUIBUDGETDROPPED" / "GUIREFUSALPREFIX" (tests/sidecar/interactive-variant.test.js).
|
|
102
|
+
if (variant) {
|
|
103
|
+
promptOptions.variant = variant;
|
|
104
|
+
promptOptions.outputBudget = readOutputBudgetSafe(server); // council #235 r1 (A1): the SAME reader headless uses (src/headless.js :: readOutputBudgetSafe) — the handle's spawn value, else config
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const promptResult = await sendPromptAsync(ocClient, sessionId, promptOptions);
|
|
108
|
+
sent = promptResult && promptResult.sentVariant;
|
|
109
|
+
if (sent && !sent.verified) {
|
|
110
|
+
const { formatUnverifiedVariantNote } = require('../utils/engine-variants');
|
|
111
|
+
const note = formatUnverifiedVariantNote({ model, variant: sent.variant, waitedMs: sent.waitedMs, unreadable: sent.unreadable });
|
|
112
|
+
logger.warn('Variant sent unverified', { taskId, sessionId, note });
|
|
113
|
+
// council #235 r2 (B2): logger.warn is DROPPED at the shipped default
|
|
114
|
+
// (LOG_LEVEL defaults to 'error', utils/logger.js), so the structured line alone
|
|
115
|
+
// told the user nothing — the silent degrade the product principle forbids, and the
|
|
116
|
+
// same invisibility this release cites against 4.9.3's silent adjustment. stderr
|
|
117
|
+
// carries it in every mode; stdout keeps the run document intact. Named mutant
|
|
118
|
+
// "UNVERIFIEDNOTICESILENT": drop the stderr write.
|
|
119
|
+
process.stderr.write(`Notice: ${note}\n`);
|
|
120
|
+
}
|
|
99
121
|
progressStage('prompt_sent');
|
|
100
122
|
}
|
|
101
123
|
logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
|
|
@@ -201,6 +223,11 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
201
223
|
} catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
|
|
202
224
|
try { await server.close(); } catch { /* best-effort */ }
|
|
203
225
|
logger.debug('OpenCode server closed after Electron exit');
|
|
226
|
+
// #218 PR 4 whole-branch review (EP-4/REC-2/PRT-2): the level SENT rides the interactive
|
|
227
|
+
// result too (emit-when-sent, the derivation headless.js:811-814 makes), so start.js's
|
|
228
|
+
// writers stamp `variant` / `variantUnverified` for the default GUI mode as well.
|
|
229
|
+
// Named mutant "INTERACTIVEVARIANTDROPPED" (tests/sidecar/interactive-variant.test.js).
|
|
230
|
+
if (sent) { result.variant = sent.variant; if (!sent.verified) { result.variantUnverified = true; } }
|
|
204
231
|
result.opencodeSessionId = sessionId;
|
|
205
232
|
resolve(result);
|
|
206
233
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module models-ceiling-line
|
|
3
|
+
* The one `Ceilings:` line `amicus models --refresh` prints (#218 P3).
|
|
4
|
+
*
|
|
5
|
+
* It is an honest report of where the direct-provider context/output ceilings
|
|
6
|
+
* came from, or why they did not come at all — filled, already complete, still
|
|
7
|
+
* missing a number, failed, or skipped.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from `models.js` (council #230 r4) because that file sat at 298 of
|
|
10
|
+
* the 300-line budget and this round adds two more branches to the formatter.
|
|
11
|
+
* It owns the WORDING only: the outcome object it renders is built by
|
|
12
|
+
* `src/utils/model-ceilings-modelsdev.js` and persisted by
|
|
13
|
+
* `src/utils/model-catalog.js` as the cache document's `ceilingEnrichment`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How a ceiling-enrichment failure is worded, keyed by its `failure.reason`
|
|
20
|
+
* (council #230 D4/C1). `http-status`, `parse-error`, `too-large` and
|
|
21
|
+
* `bad-shape` all mean models.dev ANSWERED; `exception` — and any reason a later
|
|
22
|
+
* failure invents — is a local bug, so it falls through to a neutral lead.
|
|
23
|
+
*/
|
|
24
|
+
const CEILING_FAILURE_LEAD = {
|
|
25
|
+
timeout: 'models.dev unreachable',
|
|
26
|
+
'network-error': 'models.dev unreachable',
|
|
27
|
+
'http-status': 'models.dev answered but could not be used',
|
|
28
|
+
'parse-error': 'models.dev answered but could not be used',
|
|
29
|
+
'too-large': 'models.dev answered but could not be used',
|
|
30
|
+
'bad-shape': 'models.dev answered but could not be used',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* #218 P3: one honest line about where the direct-provider ceilings came from.
|
|
35
|
+
* @param {object|null} e the persisted `ceilingEnrichment` outcome
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function fmtCeilingLine(e) {
|
|
39
|
+
// Unreachable in production after #218 P3 (every successful refresh persists a
|
|
40
|
+
// ceilingEnrichment object); kept for a hand-built or pre-field cache doc.
|
|
41
|
+
if (!e) { return 'Ceilings: not attempted'; }
|
|
42
|
+
if (e.failure) {
|
|
43
|
+
const f = e.failure;
|
|
44
|
+
const why = f.reason + (f.status ? ` ${f.status}` : '') + (f.detail ? `: ${f.detail}` : '');
|
|
45
|
+
const lead = CEILING_FAILURE_LEAD[f.reason] || 'ceiling enrichment failed';
|
|
46
|
+
// #218 PR 4 (whole-branch review VCMD-4): the flag never reaches a direct openai row — M5/M13/M22, the same fact the disabled literal below states.
|
|
47
|
+
return `Ceilings: ${lead} (${why}); rows without a ceiling get an outputBudget through the engine flag alone, clamped only where the engine's own catalog knows the model; direct openai rows send no output reservation at all (#218 PR 4, M5/M13/M22)`;
|
|
48
|
+
}
|
|
49
|
+
// A skip is not a failure and not a fill: naming which one it was is the
|
|
50
|
+
// difference between "you turned this off" and "there was nothing to do".
|
|
51
|
+
if (e.skipped === 'disabled') {
|
|
52
|
+
// Named, not "direct routes": Google publishes its own ceiling first-party
|
|
53
|
+
// and OpenRouter rows keep OpenRouter's. Since PR 2 the budget still
|
|
54
|
+
// reaches the anthropic/deepseek rows through the engine flag, clamped by
|
|
55
|
+
// the engine's own catalog (probe K5/K12); it never reaches a direct
|
|
56
|
+
// openai row at all (#218 PR 4, M5/M13/M22).
|
|
57
|
+
return 'Ceilings: models.dev lookup disabled (modelsDevCeilings: false); anthropic/deepseek direct rows carry no ceiling here and are clamped by the engine\'s own catalog instead; direct openai rows send no output reservation at all (#218 PR 4) (Google publishes its own ceiling and OpenRouter rows keep OpenRouter\'s)';
|
|
58
|
+
}
|
|
59
|
+
if (e.skipped === 'nothing-to-fill') {
|
|
60
|
+
// NOT "every row": routers, local rows and malformed rows are not
|
|
61
|
+
// candidates and are never asked about (council #230 C2).
|
|
62
|
+
return 'Ceilings: nothing to fill (no candidate row is missing a number)';
|
|
63
|
+
}
|
|
64
|
+
// `?? 0`: a hand-built or pre-field cache doc can carry a partial object, and
|
|
65
|
+
// `undefined already complete` would be a worse lie than a zero. `stillMissing`
|
|
66
|
+
// deliberately overlaps the other counters — it is the STATE the pass left,
|
|
67
|
+
// and it is the number that says whether outputBudget can clamp those rows.
|
|
68
|
+
return `Ceilings: ${e.filled ?? 0} rows filled from models.dev (${e.alreadyKnown ?? 0} already complete, ` +
|
|
69
|
+
`${e.unknown ?? 0} unknown to models.dev, ${e.stillMissing ?? 0} still missing a number)`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { fmtCeilingLine };
|
package/src/sidecar/models.js
CHANGED
|
@@ -22,6 +22,7 @@ const { pickCurrent } = require('../utils/quick-picks');
|
|
|
22
22
|
const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
|
|
23
23
|
const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
|
|
24
24
|
const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
|
|
25
|
+
const { fmtCeilingLine } = require('./models-ceiling-line');
|
|
25
26
|
|
|
26
27
|
const CHECK_EXIT_CAP = 100;
|
|
27
28
|
|
|
@@ -87,7 +88,7 @@ function fmtLiveSkipped(reason) {
|
|
|
87
88
|
|
|
88
89
|
async function runRefresh(args) {
|
|
89
90
|
const models = await refreshCatalog();
|
|
90
|
-
const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
91
|
+
const { fetchedAt, lastRefreshAttempt, lastRefreshError, ceilingEnrichment } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
91
92
|
// --refresh short-circuits --check below (args.check is guaranteed true here) — must announce, not silently skip.
|
|
92
93
|
if (args.live) {
|
|
93
94
|
const line = fmtLiveSkipped('refresh-precedes-check');
|
|
@@ -95,7 +96,7 @@ async function runRefresh(args) {
|
|
|
95
96
|
}
|
|
96
97
|
if (args.json) {
|
|
97
98
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
98
|
-
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
|
|
99
|
+
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError, ceilingEnrichment
|
|
99
100
|
}), null, 2) + '\n');
|
|
100
101
|
return models.length === 0 && !fetchedAt ? 1 : 0;
|
|
101
102
|
}
|
|
@@ -112,6 +113,7 @@ async function runRefresh(args) {
|
|
|
112
113
|
return 1; // no cache at all: a real failure
|
|
113
114
|
}
|
|
114
115
|
process.stdout.write(`Refreshed catalog: ${models.length} models.\n`);
|
|
116
|
+
process.stdout.write(fmtCeilingLine(ceilingEnrichment) + '\n');
|
|
115
117
|
process.stdout.write(`Cache: ${catalogPath()}\n`);
|
|
116
118
|
return 0;
|
|
117
119
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module reopen-notices
|
|
3
|
+
* The stderr Notice a reopen owes the user for the effort level it does NOT carry (#218 PR 4, council #235 r5 J1/A3).
|
|
4
|
+
* A session started with `--thinking <level>` sends no variant on any resumed or
|
|
5
|
+
* continued leg, and since council #235 r2 both commands REJECT the flag, so the
|
|
6
|
+
* user cannot ask for one there either. Level inheritance was scoped out of this PR
|
|
7
|
+
* deliberately and stays out (filed, not built) — but the silence is the defect: it
|
|
8
|
+
* is the same mid-conversation degrade this release cites against 4.9.3, and the
|
|
9
|
+
* project's rule is that a level which will not take effect says so. Named mutants
|
|
10
|
+
* "RESUMELEVELSILENT" / "CONTINUELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js):
|
|
11
|
+
* drop the call at either reopen site.
|
|
12
|
+
*
|
|
13
|
+
* Notices go to STDERR only — stdout carries the `--json` run document and the
|
|
14
|
+
* fold summary, and nothing here may perturb either (same rule as the unverified
|
|
15
|
+
* note in src/headless.js and src/sidecar/interactive.js).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
19
|
+
|
|
20
|
+
/** Longest LEVEL echoed from on-disk metadata into a one-line Notice — a level is one word. */
|
|
21
|
+
const MAX_LEVEL_CHARS = 40;
|
|
22
|
+
/**
|
|
23
|
+
* Longest TASK ID echoed. It is not an attacker-shaped fragment: `validators.js ::
|
|
24
|
+
* TASK_ID_PATTERN` is `/^[a-zA-Z0-9_-]{1,64}$/`, which already excludes every character the
|
|
25
|
+
* sanitizer strips — so the cap is what a VALID id can be. Capping it at the LEVEL's 40
|
|
26
|
+
* named a session that does not exist (council #235 r5 wave 6 repair).
|
|
27
|
+
*/
|
|
28
|
+
const MAX_TASK_ID_CHARS = 64;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collapse an on-disk fragment to one safe line.
|
|
32
|
+
* metadata.json is a FILE — a hand-edited or corrupted `thinking` value must not be
|
|
33
|
+
* able to forge a second `Notice:` line or smuggle control characters into a terminal
|
|
34
|
+
* (the hazard src/utils/alias-shadow.js :: safeFragment was written against).
|
|
35
|
+
*
|
|
36
|
+
* The SANITIZING is the house sanitizer's: `utils/text-sanitize.js :: collapseExcerpt` is the
|
|
37
|
+
* only one ("a second implementation would be a second set of holes", and the holes are the
|
|
38
|
+
* point — ANSI sequences and the PRINTABLE-range bidi controls are classes a private
|
|
39
|
+
* control-character regex cannot see, and this module quotes both straight out of a file).
|
|
40
|
+
* The fence/tag defang here is ADDITIVE and runs BEFORE that pass, so the house whitespace
|
|
41
|
+
* collapse and cap still govern the result: it adds a class, it never replaces one. Same
|
|
42
|
+
* shape as src/utils/alias-shadow.js :: safeFragment (council #235 r5 wave 6 repair).
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
* @param {number} maxChars
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function safeFragment(value, maxChars) {
|
|
48
|
+
return collapseExcerpt(String(value).replace(/[`<>]/g, ' '), maxChars);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The value 4.9.3 and earlier stamped on EVERY session's metadata whether or not the flag was
|
|
53
|
+
* typed — and never sent (probe F1). It is the one recorded level whose provenance is unknowable
|
|
54
|
+
* from disk, so the line SAYS that rather than reporting it as something the user asked for.
|
|
55
|
+
*/
|
|
56
|
+
const LEGACY_UNCONDITIONAL_STAMP = 'medium';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The Notice line for a reopen that drops the session's recorded effort level, or null.
|
|
60
|
+
* Null whenever the session records no level — nothing was asked for, so nothing is dropped.
|
|
61
|
+
*
|
|
62
|
+
* It reports what the metadata RECORDS, not what was typed (council #235 r5 wave 6 repair).
|
|
63
|
+
* Those are not the same proposition: 4.9.3 and earlier wrote `thinking: 'medium'` on every
|
|
64
|
+
* session, flag or no flag, so the entire on-disk session history at upgrade time reaches this
|
|
65
|
+
* line carrying a level nobody requested — and for those legs nothing degraded, because that
|
|
66
|
+
* `medium` was never sent either. Nothing on disk tells the two apart (the same reason this
|
|
67
|
+
* release refuses a pack migration), so the line states the record and names the ambiguity.
|
|
68
|
+
* @param {{taskId: string, level: unknown, kind: 'resume'|'continue'}} a
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
function formatDroppedLevelNotice({ taskId, level, kind }) {
|
|
72
|
+
if (typeof level !== 'string') { return null; }
|
|
73
|
+
const shown = safeFragment(level, MAX_LEVEL_CHARS);
|
|
74
|
+
if (!shown) { return null; }
|
|
75
|
+
// Named mutant "NOTICECLAIMSINTENT": say "was started with" again, or drop this clause.
|
|
76
|
+
const provenance = shown === LEGACY_UNCONDITIONAL_STAMP
|
|
77
|
+
? ' (4.9.3 and earlier recorded medium on every session, typed or not, and never sent it)'
|
|
78
|
+
: '';
|
|
79
|
+
const what = kind === 'continue'
|
|
80
|
+
? 'this continuation opens a NEW session and sends no effort level, so it runs at the provider\'s default — a level belongs on the `start` that opens a session and is not carried across a reopen'
|
|
81
|
+
: 'this resumed leg sends no effort level and runs at the provider\'s default — a level is not carried across a reopen';
|
|
82
|
+
return `Notice: session ${safeFragment(taskId, MAX_TASK_ID_CHARS)} records --thinking ${shown}${provenance}; ${what}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write that Notice to stderr when the session recorded a level. No-op otherwise.
|
|
87
|
+
* @param {object} metadata - the session metadata whose `thinking` is read (resume: the session's own; continue: the PARENT's)
|
|
88
|
+
* @param {{taskId: string, kind: 'resume'|'continue'}} a
|
|
89
|
+
* @returns {string|null} the line written, or null when nothing was written
|
|
90
|
+
*/
|
|
91
|
+
function noticeDroppedLevel(metadata, { taskId, kind }) {
|
|
92
|
+
const note = formatDroppedLevelNotice({ taskId, level: metadata && metadata.thinking, kind });
|
|
93
|
+
if (note) { process.stderr.write(`${note}\n`); }
|
|
94
|
+
return note;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { formatDroppedLevelNotice, noticeDroppedLevel };
|
|
@@ -23,8 +23,9 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
|
|
|
23
23
|
const { gatewayOf } = require('../utils/gateway-router');
|
|
24
24
|
const gateway = metadata.gateway || gatewayOf(model);
|
|
25
25
|
// v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
|
|
26
|
-
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:
|
|
27
|
-
|
|
26
|
+
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:240.
|
|
27
|
+
// #218 PR 3/PR 4: `finish` and `variant` are the OPPOSITE — absent-not-null: appendSpend writes each key only when it is a string. Named mutant "SOLOROWNOVARIANT" (tests/continue-resume-spend.test.js): drop `variant`.
|
|
28
|
+
appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null, finish: result && result.finish, variant: result && result.variant }, ctx);
|
|
28
29
|
} catch { /* best-effort */ }
|
|
29
30
|
}
|
|
30
31
|
return { usage };
|
package/src/sidecar/resume.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
checkSessionLiveness
|
|
17
17
|
} = require('./session-utils');
|
|
18
18
|
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
19
|
+
const { noticeDroppedLevel } = require('./reopen-notices');
|
|
19
20
|
const { runHeadless } = require('../headless');
|
|
20
21
|
const { extractNonceFromText, generateFoldNonce, stripFoldMarkers } = require('../utils/fold-marker');
|
|
21
22
|
const { logger } = require('../utils/logger');
|
|
@@ -41,7 +42,8 @@ function loadInitialContext(sessionDir) {
|
|
|
41
42
|
/** Check for file drift - files that were read may have changed */
|
|
42
43
|
function checkFileDrift(metadata, project) {
|
|
43
44
|
const filesRead = metadata.filesRead || [];
|
|
44
|
-
|
|
45
|
+
// council #235 r5 (J2/A4): the previous attempt's terminal timestamps are all deleted on every running write (updateSessionStatus below), so this — their one legitimate reader — falls back through them. `abortedAt` is a terminal stamp of that attempt and later than its start, so it is read first (wave 6 repair); `resumedAt` is when a crashed attempt STARTED, strictly more accurate than the previous attempt's completion. Named mutants "DRIFTNORESUMEDAT" / "DRIFTNOABORTEDAT": drop either middle term.
|
|
46
|
+
const lastActivity = metadata.completedAt || metadata.abortedAt || metadata.resumedAt || metadata.createdAt;
|
|
45
47
|
const lastActivityTime = new Date(lastActivity).getTime();
|
|
46
48
|
const changedFiles = [];
|
|
47
49
|
|
|
@@ -112,6 +114,13 @@ function updateSessionStatus(sessionDir, status) {
|
|
|
112
114
|
meta.status = status;
|
|
113
115
|
if (status === 'running') {
|
|
114
116
|
meta.resumedAt = new Date().toISOString();
|
|
117
|
+
// #218 PR 4 whole-branch review (REC-3): the per-attempt fields are this attempt's to stamp
|
|
118
|
+
// — an abort or crash of the resumed run must not ship the previous attempt's as its own
|
|
119
|
+
// (the terminal writers preserve every key they do not set). Named mutant
|
|
120
|
+
// "RESUMESTALEVARIANT" (tests/sidecar/resume.test.js): drop the three deletes.
|
|
121
|
+
// council #235 r5 (J2/A4): `reason` and the terminal timestamps are stamped ONLY by a terminal writer, so an attempt that never reaches one must not inherit the previous attempt's — a resume that crashes mid-attempt used to leave `status: 'running'` beside the last failure's reason and completion time, and both are read (src/utils/result-schema.js reports `metadata.reason` for every non-complete status and `completedAt || abortedAt` as the end; the MCP server prints the reason and adds `crashedAt` to that chain). ALL THREE timestamps go (wave 6 repair): `abortedAt` is the one an `amicus abort` writes and `completedAt` is never written on that path, so clearing only `completedAt` left the defect live on the commonest precursor to a resume — and, for an attempt that stamped both, made the reported end fall through to the OLDER one. Named mutants "RESUMESTALEREASON" (drop `reason`/`completedAt`) and "RESUMESTALEABORTEDAT" (drop `abortedAt`/`crashedAt`).
|
|
122
|
+
delete meta.finish; delete meta.variant; delete meta.variantUnverified;
|
|
123
|
+
delete meta.reason; delete meta.completedAt; delete meta.abortedAt; delete meta.crashedAt;
|
|
115
124
|
}
|
|
116
125
|
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2));
|
|
117
126
|
return meta;
|
|
@@ -135,6 +144,7 @@ async function resumeSidecar(options) {
|
|
|
135
144
|
|
|
136
145
|
// Load previous session data
|
|
137
146
|
const metadata = loadSessionMetadata(sessionDir);
|
|
147
|
+
noticeDroppedLevel(metadata, { taskId, kind: 'resume' }); // council #235 r5 (J1/A3): this leg sends no variant and `resume` rejects --thinking, so a session started with a level silently degrades to the provider's default — the very degrade this release cites against 4.9.3. Inheritance stays filed, not built; the silence does not. Named mutant "RESUMELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js).
|
|
138
148
|
const systemPrompt = loadInitialContext(sessionDir);
|
|
139
149
|
|
|
140
150
|
// Dead-process detection: log if the previous process is no longer alive
|
|
@@ -242,11 +252,14 @@ async function resumeSidecar(options) {
|
|
|
242
252
|
if (terminal.status === 'error') {
|
|
243
253
|
updatedMetadata.status = 'error';
|
|
244
254
|
updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
255
|
+
if (result && typeof result.finish === 'string') { updatedMetadata.finish = result.finish; } else { delete updatedMetadata.finish; } // #218 PR 3: emit-when-set; a stale one is removed (council #232 r1 B1)
|
|
256
|
+
if (result && typeof result.variant === 'string') { updatedMetadata.variant = result.variant; } else { delete updatedMetadata.variant; } // #218 PR 4: same rule as finish (named mutant "RESUMEERRORNOVARIANT", tests/continue-resume-spend.test.js)
|
|
257
|
+
if (result && result.variantUnverified === true) { updatedMetadata.variantUnverified = true; } else { delete updatedMetadata.variantUnverified; }
|
|
245
258
|
updatedMetadata.completedAt = new Date().toISOString();
|
|
246
259
|
writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
|
|
247
260
|
logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
|
|
248
261
|
} else {
|
|
249
|
-
finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status });
|
|
262
|
+
finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified }); // named mutant "RESUMEVARIANTDROPPED" (tests/continue-resume-spend.test.js)
|
|
250
263
|
}
|
|
251
264
|
// v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
|
|
252
265
|
// a ledger row (status: statusFromResult, matching start.js — not terminal.status).
|
|
@@ -51,6 +51,9 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
|
|
|
51
51
|
fs.writeFileSync(SessionPaths.summaryFile(sessionDir), result && result.summary ? result.summary : '', { mode: 0o600 });
|
|
52
52
|
metadata.status = 'error';
|
|
53
53
|
metadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
54
|
+
if (result && typeof result.finish === 'string') { metadata.finish = result.finish; } else { delete metadata.finish; } // #218 PR 3: emit-when-set; a stale one is removed (council #232 r1 B1)
|
|
55
|
+
if (result && typeof result.variant === 'string') { metadata.variant = result.variant; } else { delete metadata.variant; } // #218 PR 4: same rule as finish (named mutant "SHAREDNOVARIANT", tests/shared-server-finalize.test.js)
|
|
56
|
+
if (result && result.variantUnverified === true) { metadata.variantUnverified = true; } else { delete metadata.variantUnverified; }
|
|
54
57
|
metadata.completedAt = new Date().toISOString();
|
|
55
58
|
writeFileAtomic(
|
|
56
59
|
path.join(sessionDir, 'metadata.json'),
|
|
@@ -61,7 +64,7 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
|
|
|
61
64
|
}
|
|
62
65
|
// complete / timed-out / aborted: persist the (possibly partial) summary with
|
|
63
66
|
// the resolved status. Explicit status means the #36 guard won't re-classify.
|
|
64
|
-
finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status });
|
|
67
|
+
finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified });
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
module.exports = { resolveTerminalState, finalizeHeadlessResult };
|
|
@@ -70,7 +70,7 @@ function saveInitialContext(sessionDir, systemPrompt, userMessage) {
|
|
|
70
70
|
fs.writeFileSync(SessionPaths.contextFile(sessionDir), content, { mode: 0o600 });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** Finalize session - detect conflicts, save summary, update metadata */
|
|
73
|
+
/** Finalize session - detect conflicts, save summary, update metadata. opts.finish (#218 PR 3) stamps metadata.finish when set and REMOVES a prior one otherwise; opts.variant / opts.variantUnverified (#218 PR 4) follow the same rule — a resumed run reuses the same metadata and must not inherit the last attempt's finish (council #232 r1 B1). */
|
|
74
74
|
function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
|
|
75
75
|
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
76
76
|
|
|
@@ -102,6 +102,10 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
|
|
|
102
102
|
// summary must never silently default to 'complete' — that hid errored/empty
|
|
103
103
|
// shared-server runs behind a 0-byte summary and a false success.
|
|
104
104
|
const hasSummary = typeof summary === 'string' && summary.trim().length > 0;
|
|
105
|
+
if (typeof opts.finish === 'string') { metadata.finish = opts.finish; } else { delete metadata.finish; }
|
|
106
|
+
// #218 PR 4: the effort level SENT and whether the engine's catalogue knew the model — the same emit-when-set / delete-when-absent rule as finish. Named mutants "SOLOVARIANTDROPPED" / "STALEVARIANT" (tests/sidecar/session-utils.test.js).
|
|
107
|
+
if (typeof opts.variant === 'string') { metadata.variant = opts.variant; } else { delete metadata.variant; }
|
|
108
|
+
if (opts.variantUnverified === true) { metadata.variantUnverified = true; } else { delete metadata.variantUnverified; }
|
|
105
109
|
metadata.status = opts.status || (hasSummary ? 'complete' : 'error');
|
|
106
110
|
metadata.completedAt = new Date().toISOString();
|
|
107
111
|
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
@@ -42,7 +42,7 @@ function createSessionMetadata(taskId, project, options) {
|
|
|
42
42
|
briefing: effectiveBriefing,
|
|
43
43
|
mode: isHeadless ? 'headless' : 'interactive',
|
|
44
44
|
agent: agent || (isHeadless ? 'build' : 'chat'),
|
|
45
|
-
thinking
|
|
45
|
+
...(thinking ? { thinking } : {}), // #218 PR 4: emit-when-requested — 'medium' was never sent (probe F1), so a run with no --thinking runs at the provider's default and records none (named mutant "MEDIUMDEFAULT")
|
|
46
46
|
status: 'running',
|
|
47
47
|
pid: existing.pid || process.pid,
|
|
48
48
|
createdAt: existing.createdAt || new Date().toISOString(),
|